diff --git a/FEATURES.md b/FEATURES.md index 7b4aba7a..19595075 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ 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 · 237 built (86 partial) · 330 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 252 built (91 partial) · 346 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 140 | 168 | -| P1 | 219 | 82 | 145 | +| P0 | 174 | 147 | 173 | +| P1 | 219 | 90 | 156 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -220,7 +220,7 @@ Spec: `spec/split-view.md` | SV-23 | Pinned and Favorite splits | P1 | 🟡 partial | ✅ e2e+unit | – | pinning keeps panes/sizes/orientation; drops onto pinned tabs stay pinned; Favorite splits show a combined tile and drag like tiles; resetting every pane and Peek from pinned splits not built | | SV-24 | Splits persist | P0 | ✅ | ✅ e2e+unit | – | panes, order, orientation, sizes and focused pane survive relaunch; a Today split closed or auto-archived as one unit restores from the Archive as one unit, with its layout and sizes | | SV-25 | Limits and edge cases | P1 | ✅ | ✅ e2e+unit | – | fifth pane refused with a toast on every route; no split from an empty window; HTML full screen (core) fills the window, hides the strip and returns to the split | -| SV-26 | Feel: create, resize, close | P1 | 🟡 partial | ✅ e2e+unit | – | springs for create/close/flip, closing card fades, 1:1 resize, drop indicator fade/slide, frame colour until loaded, split row entrance; sidebar row-merge reflow belongs to the tabs lane's list animation | +| SV-26 | Feel: create, resize, close | P1 | 🟡 partial | ✅ e2e+unit | – | springs for create/close/flip, closing card fades, 1:1 resize, drop indicator fades in and moves between halves at once (FL-18), frame colour until loaded, split row entrance; sidebar row-merge reflow belongs to the tabs lane's list animation | ## Page navigation, windows, links and page-level controls (prefix NV) @@ -373,36 +373,36 @@ Spec: `spec/feel.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| FL-01 | Motion principles and shared tokens | P0 | – | ✅ e2e+unit | – | | -| FL-02 | Sidebar show/hide (docked toggle) | P0 | ✅ | ✅ e2e | – | 260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps | +| FL-01 | Motion principles and shared tokens | P0 | ✅ | ✅ e2e+unit | – | one token set in shared/motion.ts and motion.css (instant 90 ms, quick 130 ms, pop 240 ms; snappy, standard and pop springs), checked against each other by a unit test; interrupted motions turn round from where they are (Space slide, command bar, hover card, folders, ⌘S), checked as speeds from the first frame that shows the input handled | +| FL-02 | Sidebar show/hide (docked toggle) | P0 | ✅ | ✅ e2e | – | 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 | Sidebar hover-reveal overlay | P0 | ✅ | ✅ e2e | – | 0 ms dwell; card in 200 ms (snappy), out 160 ms (quick); hysteresis band; re-entering while it leaves brings it back | -| FL-04 | Tab row hover | P0 | – | – | – | | -| FL-05 | Tab selection | P0 | – | – | – | | -| FL-06 | New-tab insertion | P0 | – | – | – | | -| FL-07 | Rows moving between sections without a drag | P1 | – | ✅ e2e | – | | -| FL-08 | Row buttons (close ×, folder search, reset) | P1 | – | – | – | | -| FL-09 | Close and archive exit | P0 | – | – | – | | -| FL-10 | Drag and drop: lift, follow, spring, drop | P0 | – | ✅ e2e | – | | -| FL-11 | Clear Today: staggered sweep | P1 | – | – | – | | -| FL-12 | Folder and pinned-section expand/collapse | P1 | – | – | – | | -| FL-13 | Space switching: swipe physics, settle and rubber-band | P0 | ✅ | ✅ e2e+unit | – | strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade | +| FL-04 | Tab row hover | P0 | ✅ | ✅ e2e | – | row hover lights on the instant timing and fades out from where it is when the pointer leaves; every row a sweep enters lights | +| FL-05 | Tab selection | P0 | ✅ | ✅ e2e | – | a press selects the row in its frame, before the page shows (within 50 ms); a press on a row still growing in selects it; a selected row out of sight scrolls into view with a 225 ms ease-out, or at once from more than two screens away or with reduced motion | +| FL-06 | New-tab insertion | P0 | ✅ | ✅ e2e | – | a new row grows with the snappy spring, its content fading in over the last 60 %; the rows below glide | +| FL-07 | Rows moving between sections without a drag | P1 | ✅ | ✅ e2e | – | pinned, unpinned or moved by key or menu: the row travels with the standard spring while the room it leaves and the room it takes close and open with it; to a place out of sight or another Space it collapses out in place and the list does not scroll; ⌘Z plays it back | +| FL-08 | Row buttons (close ×, folder search, reset) | P1 | ✅ | ✅ e2e | – | ×, audio and folder search buttons: circle fades in on the instant timing; the glyph presses to 0.9 at once and springs back (pop); the click fires on release and the row starts leaving once main has closed the tab, about 2 frames after the release (D70: main decides first, as a pinned tab's × resets it and a page may ask to stay); the reset acts on press (PN-04) | +| FL-09 | Close and archive exit | P0 | ✅ | ✅ e2e | – | a closing row fades and slides 12 px while its room collapses with the snappy spring and the rows below rise; the next tab is selected no later than the closing row starts to leave, in the first frame after ⌘W at the median of five closes (main wakes a sleeping page only once the change has gone out); the first close after a launch can take 2 frames; rows out of sight go without motion | +| FL-10 | Drag and drop: lift, follow, spring, drop | P0 | ✅ | ✅ e2e | – | a 4 px move lifts the row (1.03, shadow, 95 %) in that frame; the row is drawn under the pointer in every frame; Escape springs it back to its slot | +| FL-11 | Clear Today: staggered sweep | P1 | ✅ | ✅ e2e | – | Clear sweeps the rows out one after another (25 ms apart, at most 12 staggered) within 450 ms; the divider and New Tab stay; ⌘Z brings them back in one group | +| FL-12 | Folder and pinned-section expand/collapse | P1 | ✅ | ✅ e2e | – | folders and the pinned section: the chevron turns in 160 ms; the box grows with the snappy spring while its rows fade in 12 ms apart (only when the user opens it); closing is quicker; reduced motion fades | +| FL-13 | Space switching: swipe physics, settle and rubber-band | P0 | ✅ | ✅ e2e+unit | – | strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade; the rubber band keeps the spring FL-13 names (response 0.3 s, ratio 1.0) and comes back within 0.5 px about 480 ms after the last event: FL-29's 350 ms is for swipes that land on a Space (D70) | | FL-14 | Theme-colour transition between Spaces | P0 | ✅ | ✅ e2e+unit | – | theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade | -| FL-15 | Button press micro-animations | P1 | 🟡 partial | – | – | top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion | +| FL-15 | Button press micro-animations | P1 | ✅ | ✅ e2e | – | every icon button presses to 0.94 at once and springs back (pop), the Library button too; Back/Forward nudge, Reload's glyph turns once, the + scales to 0.92; restarts on repeat; none when disabled; no spring with reduced motion | | FL-16 | Command bar open and close | P0 | ✅ | ✅ e2e | – | motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window | | FL-17 | Command bar typing and result updates | P0 | ✅ | ✅ e2e+unit | – | per-keystroke local results, identity-keyed memoized rows, instant highlight; engine suggestions fill free rows without moving the highlight | -| FL-18 | Split view create and resize | P1 | – | – | – | | -| FL-19 | Link preview open, expand, close | P1 | ✅ | ✅ e2e+unit | – | open spring done in ~280 ms with no overshoot, 18% dim; expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade; drag/swipe dismiss; ⌘Z/⌘⇧T replay the open | -| FL-20 | Favorites tiles | P1 | – | – | – | | -| FL-21 | Sidebar scrolling and large-sidebar performance | P0 | – | ✅ e2e | – | | -| FL-22 | Toasts | P1 | – | – | – | | +| FL-18 | Split view create and resize | P1 | 🟡 partial | ✅ e2e | – | a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop | +| FL-19 | Link preview open, expand, close | P1 | ✅ | ✅ e2e+unit | – | open: a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92; the standard token FL-19 names takes longer over a card's travel, D70), within 2 px in ~320 ms with no overshoot, 18% dim; expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade, the page behind takes clicks again 173–194 ms after Escape (best of up to three rounds, at the edge of 200 ms); drag/swipe dismiss; ⌘Z/⌘⇧T replay the open | +| FL-20 | Favorites tiles | P1 | ✅ | ✅ e2e | – | a new Favorite pops in from 0.85 with the pop spring while the other tiles re-flow with the snappy spring; a removed one shrinks to 0.85 and fades where it was while the grid closes up | +| FL-21 | Sidebar scrolling and large-sidebar performance | P0 | 🟡 partial | ✅ e2e | – | only the rows near the visible band are drawn, and every row tells screen readers its place and its list's size, drawn or not; a Space shown for the first time draws a stand-in at once and its list within ~160 ms; on this machine under load a 200-tab fling and a 500-tab scroll still have a few frames of two intervals (5 and 12), so p99 is two intervals, against none over one (docs/FEEL.md) | +| FL-22 | Toasts | P1 | 🟡 partial | ✅ e2e | – | rise 12 px from 0.96 with the pop spring, at most three 8 px apart, a new one pushes the others up with the snappy spring, stays 3 s (4 s with a button), a hovered one with a button waits until 1 s after the pointer leaves; fade and 8 px drop in 160 ms; under reduced motion a toast fades in where it rests, at its size, and the older ones move at once. Shown at the bottom of the sidebar (not over the page card, so hidden behind the page when the sidebar is), plain toasts let clicks through, and no Space theme yet (AP-14) | | FL-23 | Page loading indicator | P1 | ✅ | ✅ e2e | – | 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-24 | Library and Archive motion | P2 | ✅ | ✅ e2e+unit | – | Library opens with a 220 ms fade and 0.98→1 scale (opacity only with reduced motion) and fades out; sections cross-fade in 90 ms; search answers within 50 ms; restored rows fade and the rows below rise; long lists virtualised; a previewed entry opens and closes with the Peek's motion (FL-19) | -| FL-25 | Menus, popovers and tooltips | P1 | – | – | – | | -| FL-26 | Recent-tab switcher (Ctrl+Tab) | P1 | – | – | – | | -| FL-27 | Window-level motion | P1 | – | – | – | | -| FL-28 | Reduced motion | P0 | – | ✅ e2e+unit | – | | -| FL-29 | Responsiveness budgets (input to visual) | P0 | – | – | – | | -| FL-30 | What makes it feel native and smooth | P0 | – | – | – | | +| FL-25 | Menus, popovers and tooltips | P1 | ✅ | ✅ e2e | – | folder search and icon picker: drawn in the first frame, from 0.97 with the snappy timing (150 ms); close with the quick fade; keys go back to the sidebar at once | +| FL-26 | Recent-tab switcher (Ctrl+Tab) | P1 | – | ✅ e2e | – | | +| FL-27 | Window-level motion | P1 | ✅ | ✅ e2e | – | a resized window keeps the card's margin in every frame and the page view gets each width within a frame at the median (18 ms at worst), over the page's own colour; the OS full-screen transition; new windows open a step down and right (NV-10) with the command bar's entrance; an inactive window's sidebar dims over 150 ms and comes back at once on focus; no launch sheen (optional) | +| FL-28 | Reduced motion | P0 | ✅ | ✅ e2e+unit | – | follows the OS setting, with a setting to force it on (AP-17); springs, slides and scales become short fades or instant changes, toasts included, also for what is already on screen when it turns on; a unit guard checks that every motion component sits under the config or reads the setting, and that every element with a layout animation, an entrance, an exit, a press or a moving animate asks useReducedMotion(), since motion reads the config only as an element mounts; drags still follow the pointer and settle with a short ease | +| FL-29 | Responsiveness budgets (input to visual) | P0 | 🟡 partial | ✅ e2e | – | measured by the feel specs against each budget (docs/FEEL.md); press, ⌘W, ⌘S, Space switch (settles in ~290 ms), command bar and drag budgets met; still missed: p99 at two intervals in some animations under load (⌘S hide, long lists, a Peek opening), the hover sidebar and a Peek's close start in the second frame, and the first ⌘T or ⌘W after a launch can show in the second frame. The rubber band comes back in ~480 ms on FL-13's own spring: the 350 ms settle is for swipes that land on a Space (D70) | +| FL-30 | What makes it feel native and smooth | P0 | 🟡 partial | – | – | the nine points as a checklist in docs/FEEL.md: pass, gap or fixed, each with the test that shows it | | FL-31 | Haptics (macOS trackpads) | P2 | – | – | – | | | FL-32 | Picture-in-picture and media tray motion | P2 | ✅ | ✅ e2e+unit | – | corner resize follows the pointer, resists past its limits (rubber band) and springs back on release; a fling heads for the corner in its direction and settles with the spring; tray slides up with the snappy timing; Back to tab flies to the video's place; pinch zooms (MD-06) rather than resizes | @@ -428,7 +428,7 @@ Spec: `spec/appearance.md` | AP-14 | Toast notifications (in-app) | P1 | – | – | – | | | AP-15 | Link-hover status pill | P1 | – | – | – | | | AP-16 | Tooltips with keyboard shortcuts | P1 | – | – | – | | -| AP-17 | Motion principles and reduced motion | P1 | – | – | – | | +| AP-17 | Motion principles and reduced motion | P1 | 🟡 partial | ✅ e2e+unit | – | one set of motion tokens for every surface (FL-01): ease-out curves of 90–270 ms and springs of 0.25–0.32 s; buttons and the × press to 0.9 (FL-08); the loading pill at the top centre of the card (FL-23); reduced motion from the OS setting or Orbit's own switch in Appearance, turning slides, flights and springs into short fades (FL-28). No shimmer when the app starts | ## Site customizations diff --git a/docs/FEEL.md b/docs/FEEL.md new file mode 100644 index 00000000..2dc2c799 --- /dev/null +++ b/docs/FEEL.md @@ -0,0 +1,292 @@ +# Feel: measured against the spec + +`spec/feel.md` gives each motion a number and an observable. This page holds what Orbit measures against those numbers, before and after the feel lane's changes, and the FL-30 review checklist. Orbit is judged against the spec's numbers and observables only. + +## How the numbers are taken + +- The feel specs (`tests/e2e/feel-*.spec.ts`) drive Orbit with real input. Keys and pointer events are sent through main, so shortcuts take the same path as a user's. The shared sampler, `tests/e2e/frames.ts`, records the DOM once per rendered frame. Each frame holds: + - the rAF time and the wall time; + - the boxes, opacity and chosen styles of the elements under test, read after every animation-frame callback of that frame. + + Each input is stamped with the first frame drawn after it. Native views (pages, the overlay) are timed on the wall clock in main. +- Each test writes its numbers as JSON lines when `FEEL_LOG` is set: `FEEL_LOG=/path/feel.jsonl xvfb-run -a -s "-screen 0 1440x900x24" ./node_modules/.bin/playwright test --workers 1 tests/e2e/feel-*.spec.ts`. +- The asserts are loose under load (`FRAME_SLACK`, D17). Where one try can be unlucky, they take the best of three (D46). The numbers below are medians of three full runs, with the range in brackets for times. Counts, sizes and ratios show the median alone. +- The machine is a 16-core Xeon VM (Skylake) running Xvfb at 1440×900 and 60 Hz, so one frame is 16.7 ms. Other lanes' tests run beside it; "load" below is the 1-minute load average during the runs. + - Frame times come in whole intervals. On the frame clock one interval reads 16.8 ms and two read 33.4 ms, so a frame that misses its slot by a millisecond counts a whole extra interval. + - Treat p99 and max as upper bounds: the same runs are better on a quiet machine. + - Durations taken between two frames come in 16.7 ms steps. A reading one step outside a range (183 ms against 150–180) is as close as frames can show. +- A frame's time is when the frame began (its rAF time). A change that arrives while a frame is being made is drawn in that frame, and can read a millisecond under 0 (FL-06). +- "Visible" follows feel.md: the first frame on screen that shows a response. A fade that starts at opacity 0 is not visible in its first frame. +- "In place" means within 0.5 px of where the motion ends. The standard spring (FL-01: 0.32 s, damping 0.88) overshoots by 0.3 % of its travel, about 1.5 px over half a page. + - It is within 2 px after 270–300 ms, which is what the spec's "about 260–300 ms" describes. + - It comes back within 0.5 px only about 450 ms after it starts. + + Rows on that spring give both times. + +## Measured vs target + +- **Before:** + - P0 rows (FL-01 to 06, 09, 10, 13, 14, 16, 17, 21, 28, 29): `8c5849f`, which is `main` at `70b3f9d` plus the feel specs. Three runs on 2026-10-10 at load 6–9. + - P1 rows (FL-07, 08, 11, 12, 15, 18, 20, 22, 25) and FL-19: `1920500`, the lane after its P0 fixes and before any P1 change, run with the P1 specs. Three runs on 2026-10-11 at load 3–4 (FL-19 at load 2). + - FL-22's Before is read from the old toast code (a 220 ms CSS keyframe, toasts 6 px apart, removed after 2.2 s), because the test doesn't run on the old toasts. +- **After:** the lane's app code at `5416e5c`. Three runs on 2026-10-11 at load 3–4.5. FL-19 ran at load 0.7–1. FL-03's leave row ran at load 5, after its poll was made finer. FL-26 ran nine times on the lane merged with `main` at `592096b`, which has the keys lane's switcher, at load 2.5–5.6. + - Fix round 1 (PR #25's review) measured the rows it touched again, on the lane after its fixes: FL-01's interruptions, FL-05's scroll, FL-09, FL-19's close, FL-21's accessibility row and the toasts under reduced motion. Their runs and loads are in the notes. The Before of a row new in this round is the lane just before its fix, in one run: FL-05's scroll at `a4adf9b` (load 4.4), the toasts at `0f9fbfb` (load 6.2–7.9). FL-21's accessibility row is the review's reading of `0f9fbfb`. +- A blank Before cell has one of three causes: + - the row's test came with its fix; + - on the old build the test failed an earlier check and stopped before the row (FL-07's ⌘Z and other-Space rows, FL-12's closing); + - the row is an audit with no change (FL-23, FL-26, FL-27), so its After is also how it was before. +- **gap** marks a value that misses its target. The notes below the table say why each After gap remains. No number was tuned to pass on one run. + +| ID | Observable | Target | Before | After | +|---|---|---|---|---| +| FL-01 | Space shortcut pressed again mid-slide: speed after, in px a frame | ≤ 2 × the speed before + 2 | 23 px (23–24) | 24 (19–38); 27 (22–33) before the press | +| FL-01 | Escape while the bar appears: opacity / scale speed after, a frame | ≤ 2 × before + 0.05 / + 0.003 | 0.40 / 0.010 | 0.29 / 0.006 | +| FL-01 | Escape while the bar appears: frames the exit fades over | ≥ 3 (no jump to the end) | | 5 (3–6) | +| FL-01 | Hover sidebar: edge re-entered as it slides out: furthest out | turns back mid-way (< 270 px) | **gap**: 270 px | 226 px (188–243) | +| FL-01 | Folder collapsed while it opens: speed after the click, in px a frame | ≤ 2 × before + 2 | 40 px (17–58) | 23 (16–29); 26 (22–30) before the click | +| FL-01 | ⌘S pressed again at 90 ms: jump ratio of the edge | ≤ 6 × average speed | 2.8× (2.8–3.1) | 3.1× | +| FL-02 | ⌘S → sidebar starts moving | 1 frame | **gap**: 30 ms (23–38), 2 frames | 5 ms (3–19), 1 frame | +| FL-02 | ⌘S → sidebar gone | ≤ 300 ms | 223 ms (221–230) | 236 ms (222–236) | +| FL-02 | Sidebar edge to card edge, min–max | never apart (6–8 px margin) | 0 px–6 px | 0 px–6 px | +| FL-02 | Frame p99 / max while hiding | ≤ 16.7 / ≤ 33.3 ms | **gap**: 33.3 / 33.3 ms | **gap**: 33.4 / 33.4 ms | +| FL-03 | Left edge → overlay starts moving | at once (1 frame) | **gap**: 53 ms (51–76) | **gap**: 42 ms (29–45) | +| FL-03 | Left edge → overlay fully in | ≤ 250 ms | 220 ms (218–243) | 195 ms (192–196) | +| FL-03 | Overshoot | ≤ 2 px | 0 px | 0 px | +| FL-03 | Pointer leaves → overlay gone | 300–500 ms + a quick slide (≈ 450–700 ms); < 1 s | 893 ms (891–893), polled at 100, 350 and 850 ms | 627 ms (625–632), polled every 16 ms | +| FL-04 | Sweep over 20 rows: rows lit of those entered | all | 17 of 17 | 17 of 17 | +| FL-04 | Frames to highlight a row, worst | ≤ 2 (fade from 0) | 2 | 1 | +| FL-04 | Highlight gone after leaving, worst | ≤ 150 ms (quick) | 112 ms (112–113) | 63 ms | +| FL-05 | Press → selected highlight | 1 frame | **gap**: 92 ms (92–94) | 8 ms (5–9) | +| FL-05 | Press → page shown | ≤ 50 ms | 20 ms | 24 ms (22–24) | +| FL-05 | Press: time with no row highlighted | 0 (one hands over to the other) | **gap**: 83 ms | 0 ms | +| FL-05 | Press in the first frame after a row grows in (PR #10 review F1) | 3 of 3 select the row | **gap**: lost (it lands on the row’s slot) | 3 | +| FL-05 | Selected row out of sight, 100 px below the edge: scroll / share of the way in its first frame | 200–250 ms ease-out | **gap**: 200 ms / 2 % (eases in; fastest in its third frame) | 217 ms / 12 % (fastest in its second frame) | +| FL-05 | … a screen away (846 px): scroll | 200–250 ms | **gap**: 425 ms (417–433) | 233 ms | +| FL-05 | … more than two screens away (2,034 px) / with reduced motion: frames moving | 1 / 1 (at once) | **gap**: 27 / 1 | 1 / 1 | +| FL-05 | … rows on screen drawing nothing while it scrolls (FL-21) | 0 | 0 | 0 | +| FL-06 | Enter → new row fully grown | ≤ 250 ms | 236 ms (224–247) | 216 ms (208–219) | +| FL-06 | Enter → new row first drawn | 1 frame after the state | **gap**: 40 ms (36–47) | 8 ms (−1–19) | +| FL-06 | Rows below: jump ratio | ≤ 6 | 3.2× (3.1–3.3) | 2.4× (2.2–3.4) | +| FL-07 | ⌘D on the third Today tab → in its new slot (within 0.5 px) | ≈ 250 ms (standard spring) | 280 ms (272–286) | 286 ms (276–289) | +| FL-07 | ⌘D: frames with the row gone or drawn twice | 0 | 0 | 0 | +| FL-07 | ⌘D: divider and rows above, largest step share | < 60 % (no jump) | **gap**: 100% | 19% (16–19) | +| FL-07 | ⌘D: rows below, furthest from their place | ≈ 0 px | **gap**: 29.9 px (28.9–32.0) | 1.0 px | +| FL-07 | ⌘Z → back in its slot / rows below | ≈ 250 ms / ≈ 0 px | | 276 ms (275–292) / 1.0 px | +| FL-07 | Pinned section out of sight, ⌘D: the row above moves / the row slides left | 0 px (no scroll) / ≈ 12 px | **gap**: 300.0 px (299.0–300.0) / 12 px | 0.0 px / 12 px | +| FL-07 | Moved to another Space: the row above moves | 0 px (no scroll) | | 0.0 px | +| FL-08 | Hover the ×: circle fully in / frames part-way | ≈ 90 ms / ≥ 1 | **gap**: 13 ms / 0 | 95 ms (95–96) / 4 | +| FL-08 | Press: frames to the glyph at 0.9 / smallest scale | 1 / ≈ 0.9 | 1 / 0.90 | 1 / 0.90 | +| FL-08 | Release → row starts leaving | the release's frame; once main has closed the tab (D70) | 2 frames | 2 frames | +| FL-09 | ⌘W → next tab selected (a sleeping tab below), median of five closes | 1 frame | | 1 frame, 9 ms (8–11) | +| FL-09 | ⌘W → next tab selected, the first close after a launch | 1 frame | **gap**: 33 ms (30–41), 3 frames | **gap**: 22 ms (22–24), 2 frames | +| FL-09 | Next row selected no later than the closed one starts to leave | every close | yes | yes | +| FL-09 | ⌘W → gap closed | ≤ 250 ms | **gap**: 397 ms (383–407) | 222 ms (222–224) | +| FL-10 | 4 px → row lifted | 1 frame | 1 frame | 1 frame | +| FL-10 | Frames where the row lags the pointer | 0 | 0 | 0 | +| FL-10 | Row centre to the pointer while held (pressed at its centre) | 0 px | | 0 px | +| FL-10 | Escape → row back in its slot (within 0.5 px) | ≤ 300 ms | **gap**: 355 ms (351–368) | 270 ms (269–272) | +| FL-10 | Frame p99 while dragging | ≤ 16.7 ms (FL-29) | 16.8 ms | 16.8 ms | +| FL-11 | ⌘⇧K with 8 Today tabs → Today empty | ≤ 450 ms | 441 ms (440–442) | 439 ms (436–441) | +| FL-11 | First row starts → last row starts | 7 × 20–30 ms | 183 ms (167–200) | 167 ms (167–183) | +| FL-11 | Rows slide left / divider and New Tab moved | ≈ 24 px / 0 px | 22 px / 0 px | 22 px / 0 px | +| FL-11 | ⌘Z: first row back → last row back | 0 (one group) | 0 ms | 0 ms | +| FL-12 | Folder of 6: chevron turn, first to last moving frame | 150–180 ms | 117 ms (117–133) | 133 ms | +| FL-12 | Click → content below in place / largest step share | ≤ 250 ms / < 60 % | 243 ms (237–245) / 17% | 239 ms (238–240) / 17% | +| FL-12 | Children: first shown → last shown | 5 × 10–15 ms | **gap**: 0 ms | 67 ms | +| FL-12 | Closing: content below moves for / click → closed | 150–180 ms / ≤ 250 ms | | 167 ms (150–167) / 175 ms (167–179) | +| FL-13 | Swipe: frames off the fingers by > 0.5 px | 0 | 0 | 0 | +| FL-13 | Swipe release → settled (release seen 90 ms after the last event) | ≤ 350 + 90 ms | **gap**: 476 ms (396–479) | 412 ms (394–412) | +| FL-13 | Flick: travel / last event → settled | < 25 % / ≤ 350 + 90 ms | 23% / 361 ms (356–364) | 23% / 276 ms (275–291) | +| FL-13 | Rubber band at the first Space, one width of travel | ≈ 35.5 % (formula) | 35.7% | 35.7% | +| FL-13 | Rubber band: last event → back at rest (within 0.5 px) | on FL-13's spring, ≈ 480 ms (D70: FL-29's 350 ms is for swipes that land on a Space) | 478 ms (477–480) | 478 ms (477–479) | +| FL-13 | Frame p99 while swiping and settling | ≤ 16.7 ms (FL-29) | 16.8 ms (16.8–33.3) | 16.8 ms (16.8–33.3) | +| FL-14 | Theme during a swipe: in-between colours / grey frames / contrast | > 2 / 0 / ≥ 4.5:1 | 16 / 0 / 9.8:1 | 16 / 0 / 9.8:1 | +| FL-15 | Icon button: press → at 0.94 / smallest scale | at once (60 ms) / ≈ 0.94 | 14 ms (1–15) / 0.94 | 25 ms (17–31) / 0.94 | +| FL-15 | Release → settled at full size (within 0.2 %) | the pop spring | **gap**: back in 1 frame (no spring) | 193 ms | +| FL-15 | Disabled button: size change on a press | 0 px | 0 px | 0 px | +| FL-16 | ⌘T → bar visible (opacity > 0), warm | 1 frame | 7 ms (6–14) | 10 ms (1–14) | +| FL-16 | ⌘T → bar visible, first open after launch | 1 frame | **gap**: 35 ms (27–57) | **gap**: 22 ms (14–22) | +| FL-16 | ⌘L → bar visible | 1 frame | 16 ms (8–21) | 10 ms (4–11) | +| FL-16 | Entrance: first frame to opaque and full size | 120–160 ms | 150 ms | 133 ms (117–133) | +| FL-16 | Entrance: first frame scale / drop / overshoot | 0.97 / −4 px / none | 0.97 / −3.8 px / 1.00 | 0.98 / −2.5 px / 1.00 | +| FL-16 | Page dimming (scrim alpha) | none or very slight | 0.07 | 0.07 | +| FL-16 | ⌘T then "abc" at once: keys kept | 3 of 3 | 3 | 3 | +| FL-16 | Escape → bar gone / smallest scale | 100–120 ms / 0.98 | 91 ms (87–98) / 0.98 | 100 ms (91–102) / 0.98 | +| FL-16 | Enter on a result → bar gone | ≈ 80 ms | 91 ms (84–93) | 74 ms (73–82) | +| FL-17 | Keystroke → field shows it, worst | 1 frame | 1 frame | 1 frame | +| FL-17 | Keystroke → local results, worst / median | ≤ 50 ms / 1 frame | 23 ms (22–35) / 14 ms (9–15) | 20 ms (19–26) / 14 ms (14–15) | +| FL-17 | Late suggestions: highlight moved / list bounces | 0 / 0 | 0 / 0 | 0 / 0 | +| FL-17 | ↑/↓ → highlight moved | 1 frame | 1 frame | 1 frame | +| FL-18 | Tab dragged onto the page: zone drawn (the page the drag began on comes back first) | while over the page | 56 ms (52–72) | 86 ms (65–88) | +| FL-18 | The move that finds the zone → zone drawn / its fade in | ≤ 2 frames (a fade from 0) / ≈ 100 ms | **gap**: 3 frames / 83 ms (83–100) | **gap**: 3 frames / 83 ms (83–100) | +| FL-18 | Zone width, share of the page | ≈ 50 % (the half) | 49% | 49% | +| FL-18 | Pointer to another half → zone in its new place | at once (FL-18); ≈ 120 ms ease (SV-26) | **gap**: 341 ms (326–341) | 24 ms (22–24) | +| FL-18 | Release → both panes within 2 px / within 0.5 px | ≤ 350 ms | 25 ms (11–27) / 25 ms (11–27) | 26 ms (11–27) / 26 ms (11–27) | +| FL-18 | Pane added: first frame → both within 2 px / within 0.5 px | 260–300 ms, ≤ 350 ms | **gap**: 283 ms (283–317) / 483 ms (467–500) | **gap**: 300 ms (283–333) / 483 ms (467–483) | +| FL-18 | Pane added: overshoot | none visible | 1.4 px | 1.4 px | +| FL-18 | Divider drag: frames off the pointer by > 1 px | 0 (1:1) | 0 | 0 | +| FL-18 | Flip: first move → panes within 2 px / within 0.5 px | ≈ 300 ms | 267 ms (267–283) / 450 ms (450–467) | 267 ms (233–283) / 450 ms | +| FL-18 | Close a pane: first move → the other within 2 px / within 0.5 px | 260–300 ms | 283 ms (267–283) / 450 ms (433–450) | 267 ms / 433 ms | +| FL-18 | Largest step of a pane edge: add / flip / close | < 60 % (no jump) | 25% / 26% / 25% | 24% / 26% / 24% | +| FL-19 | Click on a link in a pinned tab → card first drawn / its width, share of the card | – / small (grows from the link) | 57 ms (46–63) / 14% | 47 ms (44–51) / 14% | +| FL-19 | Open: first frame → card within 2 px / within 0.5 px | 280–320 ms | 317 ms (317–333) / 350 ms (350–383) | 317 ms / 367 ms (350–383) | +| FL-19 | Open: overshoot / card size, share of the page area / page dimmed by | ≤ 2 % / ≈ 90 % / 10–20 % | 0% / 90% / 18% | 0% / 90% / 18% | +| FL-19 | Open: frame p99 / max | ≤ 16.7 / ≤ 33.3 ms (FL-29) | **gap**: 50.0 / 50.0 ms | **gap**: 33.4 / 33.4 ms | +| FL-19 | Escape → card starts closing | 1 frame (FL-29) | **gap**: 2 frames | **gap**: 2 frames | +| FL-19 | Close: first frame → last frame of the fade / smallest scale | 150–180 ms / ≈ 0.96 | 133 ms (117–133) / 0.96 | 150 ms (133–150) / 0.96 | +| FL-19 | Escape → no layer over the page (it takes clicks) | ≤ 200 ms | 192 ms (187–200) | 183 ms (173–194), best of up to three rounds; 4 of 10 rounds took 201–215 ms | +| FL-19 | ⌘O: first move → edges within 2 px / meet the page card (0.5 px) | 220–260 ms | 183 ms (183–200) / 233 ms (233–250) | 200 ms (167–217) / 250 ms (233–250) | +| FL-20 | New Favorite: first frame scale / largest scale | ≈ 0.85 / ≤ 1.04 | 0.85 / 1.007 | 0.85 / 1.007 | +| FL-20 | Other tiles: largest step share | < 60 % | 17% (17–18) | 17% (16–17) | +| FL-20 | Removed Favorite: smallest scale drawn / frames fading | ≈ 0.85 / ≈ 150 ms of frames | **gap**: 0.99 / 10 | 0.85 / 10 | +| FL-21 | 200 tabs, fling: frame p99 / max | ≤ 16.7 / ≤ 33.3 ms | **gap**: 33.4 / 33.4 ms | **gap**: 33.4 / 33.4 ms | +| FL-21 | 200 tabs, fling: frames over one interval | 0 | **gap**: 8 (4–10) | **gap**: 5 (5–11) | +| FL-21 | 500 tabs + 50 folders, scroll: frame p99 / max | ≤ 16.7 / ≤ 33.3 ms | **gap**: 50.1 / 50.1 ms | **gap**: 33.4 / 33.4 ms | +| FL-21 | 500 tabs + 50 folders: frames over one interval | 0 | **gap**: 102 (84–128) | **gap**: 12 (5–24) | +| FL-21 | Switch to a 200-tab Space never shown: first frame with its rows | ≤ 100 ms (§8) | **gap**: 231 ms (210–247) | 11 ms (11–13) | +| FL-21 | … its own list drawn (interactive) | ≤ 350 ms (before the slide ends) | 231 ms (210–247) | 161 ms (144–163) | +| FL-21 | … longest task / frame max | < 50 / ≤ 33.3 ms | **gap**: 258 / 283 ms | **gap**: 57 / 67 ms | +| FL-21 | 300 tabs: rows on screen drawing nothing (wheel, keys, a tab found, a drag) | 0 in every frame | | 0 | +| FL-21 | 200 Today tabs, the 151st selected, list at the top: the place and list size a screen reader is told | each row's own place, of 200 | **gap**: of 69 (69 rows in the tree; the 151st comes right after the 68th) | its own place, of 200 (45 rows in the tree, the 151st among them) | +| FL-22 | Toast in: first frame below its place / scale | ≈ 12 px / ≈ 0.96 | **gap**: 6 px / 0.98 (a 220 ms keyframe) | 12.6 px / 0.96 | +| FL-22 | Toast in: first frame → in place / largest scale | 200–250 ms / ≤ 1.03 | 220 ms / 1 | 233 ms (233–250) / 1.002 | +| FL-22 | Three stacked: gaps off 8 px / older pushed up, largest step share | 0 px / < 60 % | **gap**: 2 px (6 apart) / 100 % (jumped) | 0 px / 16% (16–17) | +| FL-22 | Shown → starts leaving, shortest | 3–4 s | **gap**: 2200 ms | 3000 ms (3000–3017) | +| FL-22 | Leaving: fade / drop | 150–180 ms / ≈ 8 px | **gap**: none (removed) / 0 px | 183 ms (167–183) / 8.0 px | +| FL-23 | Slow page: load start → pill drawn | ≈ 150 ms (not before 120) | | 150 ms (145–152) | +| FL-23 | Sweep: one turn | 1–1.4 s | | 1200 ms | +| FL-23 | Frame p99 / max in the indicator while sweeping | ≤ 16.7 / ≤ 33.3 ms | | 16.8 / 33.3 ms | +| FL-23 | Load end → pill gone / frames of its fade | ≤ 200 ms / a fade | | 182 ms / 9 | +| FL-23 | Cached reload (start → stop, longest of 3): indicator shown | 0 times (a 50 ms load) | | 54 ms (44–57): 0 | +| FL-25 | Folder search: click → first drawn / first frame scale | 1 frame / ≈ 0.97 | **gap**: 2 frames / 0.98 | 1 frame / 0.99 | +| FL-25 | Click → fully open | ≈ 150 ms (snappy) | 131 ms (130–136) | 120 ms (117–127) | +| FL-25 | Escape → gone / frames closing | ≈ 90–130 ms / ≥ 2 | **gap**: 9 ms (6–15) / 0 | 84 ms (82–92) / 5 | +| FL-26 | Holding ⌃: Tab → the previous tab highlighted (its sidebar row: the switcher has no list yet) | 1 frame (at once) | | **gap**: 16 ms (7–35), 2 frames | +| FL-26 | … a second Tab → the next tab highlighted | 1 frame | | 10 ms (3–26), 1 frame | +| FL-26 | … each Tab → that tab's page shown | when ⌃ is let go (TB-05) | | **gap**: 8 ms (6–12) after the first Tab, 1 ms (1–2) after the second | +| FL-26 | Letting go of ⌃ → frames that change the highlight | 0 (the page switches in that frame) | | 0 | +| FL-26 | The switcher's list: shown after the second Tab / its fade | 1 frame, or after a 150 ms hold / ≤ 100 ms, no slide | | **gap**: not built (TB-05) | +| FL-27 | Window loses focus (main’s blur) → sidebar starts dimming | at once | | 20 ms (16–43) | +| FL-27 | Blur: sidebar dimming takes / frames in between | ≈ 150 ms / a fade | | 133 ms / 8 | +| FL-27 | Focus → sidebar bright again / frames in between | at once / 0 | | 10 ms (9–11) / 0 | +| FL-27 | Window resized a step a frame: frames with the card off its margin | 0 | | 0 | +| FL-27 | … card draws a width → main gives the page view that width, median / worst | ≤ 1 frame | | **gap**: 9.3 ms / 18.2 ms | +| FL-27 | … frames where the card under the page is not the page’s colour | 0 (no white) | | 0 | +| FL-28 | Reduced motion (system), ⌘⌥→: horizontal movement | 0 px | 0 px | 0 px | +| FL-28 | Reduced motion (system), ⌘⌥→: new Space shown / cross-fade done | first frame / < 150 ms | **gap**: 15 ms (7–17) / 182 ms (174–184) | 12 ms (9–19) / 129 ms (126–136) | +| FL-28 | Reduced motion (Orbit setting), ⌘⌥→: movement / cross-fade done | 0 px / < 150 ms | | 0 px / 125 ms (122–136) | +| FL-28 | Reduced motion (Orbit setting / system), a toast: first frame below its place / its scale | 0 px / 1 | **gap**: 12.4 / 12.5 px, 0.961 | 0 px / 1 | +| FL-28 | … the older toast pushed up: frames moving / a toast leaving: its drop | 1 (at once) / 0 px | **gap**: 12 / 11 frames, 8 px | 1 / 0 px | +| FL-28 | … a toast's fade in / frames of its fade out | short fades | | 117 ms (100–117) / 7–8 | +| FL-28 | Turned on (Orbit setting / system) after the Favorites showed: a new Favorite, the other tiles moving / a removed one, its smallest scale | 1 frame / 1 | **gap**: 11–12 frames / 0.85 | 1 frame / 1 | +| FL-28 | … the grid closing up / the removed tile shown faded | 1 frame / ≤ 150 ms | **gap**: 12–13 frames / 133 ms | 1 frame / 83–100 ms | +| FL-28 | Turned on after a folder was opened: closing it, the content below / the chevron | 1 frame / 1 frame | **gap**: 11 / 9 frames | 1 / 1 | +| FL-28 | … collapsing the pinned section: the content below / the caret | 1 frame / 1 frame | **gap**: 8 / 8–9 frames | 1 / 1 | +| FL-28 | Turned on after they showed: a Favorite / the "+" pressed, its scale | 1 (no nudge) | **gap**: 0.94 / 0.92 | 1 / 1 | +| FL-29 | Space shortcut → slide starts | 1 frame | 1 frame | 1 frame | +| FL-29 | Space shortcut → settled | ≤ 350 ms | **gap**: 409 ms (409–410) | 292 ms (285–299) | +| FL-29 | Frame p99 / max during the slide | ≤ 16.7 / ≤ 33.3 ms | 16.8 / 16.8 ms | 16.8 / 16.8 ms | + +### Notes + +- **FL-01:** interrupted motions turn round from where they are. Before, the hover sidebar slid fully out (270 px) before it came back. + - Each check compares speeds (a step over its frame's time), from the first frame that shows the input handled: the bar that is closing, or the value turning. A frame that comes late can't pass for a jump. Until review M4 the checks compared steps from the frame after main had the input, and the Before column is in that measure. + - The bar fades in fast, so a jump to the end would be no faster than its entrance. Its exit must also fade over 3 frames or more, in up to three rounds (D46). With the exit made a jump, 4 runs of 4 failed. + - After: 20 runs of the four tests at load 3.9–8.4. All passed, the bar in its first round. +- **FL-02:** ⌘S now moves the sidebar in the frame that draws the change: the transition starts a frame along (`--motion-lead`). + - Every hide, before and after, has one frame that takes two intervals. With about 15 frames in a hide, that frame sets both the p99 and the max. + - Not fixed. +- **FL-03:** the hover sidebar is a view of its own. Main sees the pointer at the edge and places the view, then the view draws. + - So its first moving frame comes 29–45 ms after the pointer. One frame would need the card drawn in the shell itself. Not changed. + - It leaves 400 ms after the pointer does (`HIDE_GRACE_MS`), and main removes the view 200 ms later. + - The leave row's Before polled at Playwright's default steps (100, 350 and 850 ms), so 893 ms meant "gone between the polls at 350 and 850 ms". The test now polls every 16 ms. +- **FL-05:** + - A press now selects its row in the frame it lands in, and the old highlight hands over in that same frame. Before, there were 83 ms with no row lit. + - A press in the first frame after a row grows in now selects that row. Before, the press landed on the row's slot (PR #10 review F1). + - A selected row out of sight scrolls in on Orbit's own ease-out (`EASE.instant`, 225 ms), drawn a frame along so its first frame already moves. From more than two screens away, or with reduced motion, it is there at once. Before, Chromium's smooth scroll eased in and took 417–467 ms from further away (review M5). Three runs at load 2.6–4.1, the same each time. +- **FL-07:** the row travels with the standard spring, as FL-07 asks. + - FL-01 times that spring at about 260–300 ms, so FL-07's "about 250 ms" reads 276–286 ms within 0.5 px. + - Known gap: at the very end of a scrolled list, a row that leaves makes the list shorter, and the browser clamps the scroll position. The rows in sight then move up. The test scrolls to a place with room below. +- **FL-08:** the row starts leaving 2 frames after the release, once main has closed the tab (D70). FL-08 asks for the release's own frame, but main decides first: a pinned tab's × resets it (PN-04), and a page may ask to stay (NV-24). + - The pinned tab's reset (PN-04) still acts on press; its circle now fades in like the ×'s. +- **FL-09:** main wakes a sleeping tab's page only once the change has gone out to the shell (`windows.ts`, `fastSwitch`), so the next row is selected in the first frame drawn after ⌘W. Waking the page first takes main about 13 ms, which holds the change back a frame. + - The test closes five tabs a round, each with a sleeping tab below it, and judges the round on their median, in up to three rounds (D46). A key that comes as the shell starts a frame can't show in that frame. No close of the round may take more than 2 frames, the first after a launch included (fix check N3), so a change that slows two closes in five fails. + - With the deferral, 5 runs of 5 passed in their first round at load 4.5–6.2, and 22 of their 25 closes took 1 frame. With 28 busy threads beside it (load 16–32), 10 runs of 10 passed: 7 in the first round, 2 in the second and 1 in the third. + - With the deferral taken out, 5 runs of 5 failed. Every round had a median of 2 frames, 24–27 ms after the key (71 of 75 closes in 2 frames). + - Gap: the first close after a launch took 2 frames in 10 runs of 15. Main sends that first change out 7–12 ms after the key, against 2–3.5 ms for later closes: the key's route, the close and the state update all run as cold code the first time. At `5416e5c`, before the lane took in the keys lane's key router from `main`, that close took 1 frame (11 ms, 7–17). +- **FL-11:** audited. It was already within its targets; the test is new. +- **FL-12:** + - The chevron's tween is 160 ms (`CHEVRON` in `ItemList.tsx`) on the snappy ease. Its last frames turn less than 0.5°, so first-to-last moving frame reads 133 ms. + - The children fade in 12 ms apart, so six of them span four frames. +- **FL-13:** the swipe's settle and the flick are within budget. Both are timed from the last wheel event, including the 90 ms wait that detects the release, and the settle spring starts with the release velocity. + - The rubber band returns with the spring FL-13 gives it (response 0.3 s, ratio 1.0). From a third of the width that spring needs about 390 ms to come within 0.5 px, or 478 ms with the release wait. FL-29's 350 ms is for swipes that land on a Space, so the rubber band keeps FL-13's spring (D70). +- **FL-15:** the release uses the pop spring (0.30 s, damping 0.70), as FL-15 asks; the spec gives it no time. From 0.94 the button is within 0.2 % of full size 193 ms after the release. +- **FL-16:** the first ⌘T after a launch showed the bar in the second frame in two runs of three (14, 22 and 22 ms). Warm opens show it in the first frame. +- **FL-18:** + - The zone shows 3 frames after the move that finds it, for two reasons. A fade from 0 shows only from its second frame, and React renders a pointer move's state in a task of its own, which can miss the next frame. Over seven runs it took 2–4 frames. + - The zone now moves to a new half at once, as FL-18 asks. SV-26's 120 ms ease defers to feel.md. + - The page makes room while the zone shows, so on release the new pane only takes the zone's place. + - Adding a pane, a flip and closing a pane use the standard spring. The panes are within 2 px in 267–300 ms, as the spec asks, but within 0.5 px only at 433–483 ms, after a 1.4 px (0.2 %) overshoot. + - Not built: the "bubble" while a tab hovers another tab's row, and the wobble on drop. +- **FL-19:** the close's tween and the expand's spring now start their clocks when they are called, so their first frame already moves. This is a timing change in the peek lane's `PeekLayer.tsx`, shown by `feel-peek.spec.ts`. + - The open uses a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92). The standard spring FL-19 names takes longer than 320 ms over a card's travel (D70). + - The layer goes when the close's curve ends, as it did before the lane. The lane had made it go once the card and the dim were under 0.1 %; no test showed that was needed, so it came out in fix round 1 (review follow-up 3). + - Six runs at load 15–19, each in up to three rounds (D46), took 173–194 ms at their best; 4 of their 10 rounds took 201–215 ms. That is at the edge of the target: the close starts in the second frame and runs 160 ms. + - Two gaps remain: + - During the open, the overlay draws at 30 fps on this machine while main moves the page view every frame, so p99 is two intervals. A fix belongs in `src/main/peek/`, which is the peek lane's. + - Escape goes through main to the overlay view, so the close starts in the second frame (as with FL-03). +- **FL-20:** a removed Favorite used to fade at full size. It now shrinks to 0.85 where it was. +- **FL-21:** only the rows near the visible band are drawn. A Space shown for the first time draws a stand-in at once (11 ms) and its own list by 161 ms. + - Under load 3–4 the long lists still have frames of two intervals, so p99 is two intervals: 5 such frames per 200-tab fling, and 12 per 500-tab scroll (was 102). + - The longest task during a first showing is 57 ms (was 258 ms). + - Rows that aren't drawn leave no hole for a screen reader: every row says its place in its list and the list's size (`aria-posinset`, `aria-setsize`), and its depth. Arrow keys, Home and End walk every row, drawn or not (review M3). Three runs at load 2.2–3. +- **FL-22:** built as FL-22 says, with three exceptions: + - toasts show at the bottom of the sidebar, not over the page card, so they are hidden with the sidebar; + - plain toasts let clicks through, and only a toast with a button holds on hover; + - they don't use the Space theme (AP-14). +- **FL-23:** audited, not changed. The pill shows at 150 ms, and never for a fast load. +- **FL-25:** the folder search popover now shows in its first frame and closes with the quick fade. Before, it took 2 frames to show and was gone at once. +- **FL-26:** audited, not changed. The recent-tab switcher has no list on screen yet (TB-05 is partial), so there is no overlay to time. + - Holding ⌃, each Tab selects its tab at once and its page shows a few ms later. The row lights in the first frame after main has the key for every Tab but the first of a hold, which took 2 frames at the median (1–3 over nine runs). + - FL-26 and TB-05 switch the page when ⌃ is let go, with the list showing the highlight until then. That needs the list, which is TB-05's to build. +- **FL-27:** audited, not changed. + - Main's blur reaches the shell over IPC, so the dimming starts 1–3 frames later. + - On a window resized a step every frame, X merges steps: 8–10 of 16 widths are drawn. The page view gets the card's width within a frame at the median and in 18 ms at worst (25 ms in one run). Any gap shows the page's own colour. +- **FL-28:** the cross-fade is 120 ms (`--motion-fade`). It is done 122–136 ms after the shortcut, where it took 182 ms before. + - Toasts follow reduced motion, Orbit's switch or the system's (review M1). A toast fades in where it rests and fades out there, and the older one moves up at once. 20 runs (10 per setting) at load 2.9–4.2. Before, a toast rose 12 px from 0.96 and the push glided, and once under the config, motion still jumped the moves in a frame of their own: 12.6 px low in the first frame in 2 runs of 10, and an 8 px drop on leaving in all 10. + - Turned on while Orbit runs, reduced motion now reaches what is already on screen (fix check N1). motion reads `OrbitMotionConfig` once, as an element mounts. So the Favorites tiles, a folder's or the pinned section's box, the chevrons, the "+", the drag ghost and the popovers each ask `useReducedMotion()` and choose their reduced motion from it: no layout animation, no scale on the way in or out, moves and turns at once, and 120 ms fades. A press no longer nudges a tile or the "+" (FL-28: decorative motion is off). + - `feel-reduced` turns the setting on after these show: the Favorites under both settings, a folder and the pinned section under Orbit's, and the presses. 10 runs of the Favorites test (5 per setting) and 5 of each of the others, at load 1.5–2.9. Before, the tiles glided over 11–12 frames, a removed tile shrank to 0.85, a folder closed over 11 frames with its chevron turning over 9, and a press still scaled to 0.94 and 0.92. + - `tests/unit/reduced-motion-guard.test.ts` reads the renderer's code: + - every component that renders motion's components sits under `OrbitMotionConfig` or reads reduced motion; + - every motion element that animates its layout, an entrance, an exit or a gesture, or whose `animate` moves, turns or resizes it, sits in a component that calls `useReducedMotion()`, since the config reaches an element only as it mounts. Rows whose variants ask `reducedMotion()` as they run are the one exception; + - the hook is Orbit's (`motion/reduced.ts`), never motion's own, which follows the OS alone; + - every file that calls motion's `animate()` reads it; + - no stylesheet keys a rule off the media query alone. + + Run on the code before N1's fix, the second check named 11 components in 8 files: the Favorites grid and its tiles, the split tile, the folder's box and row, the space header, the Today block, the drag ghost, both popovers and the downloads flyout. +- **FL-29:** the Space shortcut now settles in 292 ms (was 409 ms). The other budgets are in the rows for FL-02, 03, 05, 09, 10, 13, 16 and 17. + - FL-13's rubber band comes back in 478 ms on FL-13's own spring. FL-29's 350 ms is for swipes that land on a Space (D70). + - ⌘W selects the next tab in 1 frame at the median of five closes. The first close after a launch took 2 frames in 10 runs of 15 (FL-09). + +### Not measured or not built + +- **FL-24** (P2, the Library's first open): not measured in this lane. +- **FL-26's** list (P1, the Ctrl+Tab switcher): not built (TB-05), so its fade and timing can't be measured. The keys it has are in the table. +- **FL-31** (P2, haptics): not built. There are no trackpad haptics on Linux. +- **FL-18's** bubble and wobble (see the notes above). + +## FL-30 checklist + +FL-30's nine points, judged against the spec's numbers and observables. The evidence is in the table above and in the named specs. + +| # | Point | Verdict | Evidence | +|---|---|---|---| +| 1 | Your hand is never ahead of the screen | pass | No frame where the input leads the screen: 0 for a dragged row (FL-10), the swiped pages (FL-13) and the split divider (FL-18). The card keeps its margin in every frame of a resize (FL-27). Specs: `feel-rows`, `feel-spaces`, `feel-split`, `feel-window`. Not checked: the OS scroll direction, since Xvfb sends wheel events only. | +| 2 | Momentum is respected | pass | A flick (23 % of the width) settles 276 ms after its last event, and a slow swipe 412 ms. The settle spring starts with the release velocity. A second shortcut carries the running slide on, with no jump (FL-01). Specs: `feel-spaces`, `feel-motion`. The rubber band comes back in 478 ms on FL-13's own spring; FL-29's 350 ms is for swipes that land on a Space (D70). | +| 3 | Clicks feel instant | fixed, gaps listed | Press → highlight in 8 ms (was 92); ⌘W selects the next tab in 1 frame at the median of five closes (was 3); ⌘S moves in 1 frame (was 2). The command bar shows in 1 frame, and the × and the icon buttons press at once. Specs: `feel-rows`, `feel-sidebar`, `feel-commandbar`, `feel-chrome`. Gaps: the hover sidebar and the Peek's close start in the second frame (separate views), the first ⌘T after a launch showed in the second frame in two runs of three, and the first ⌘W after a launch took 2 frames in 10 runs of 15. | +| 4 | Nothing flashes | partial | No grey frame between two themes (FL-14). Late suggestions move nothing (FL-17). No row on screen is drawn empty (FL-21). Resize gaps show the page's colour (FL-27). There is never a frame with no row selected (FL-05; was 83 ms). Hover fades out from where it is (FL-04). Not measured: hover flicker during a drag, favicons arriving, and the screen's own pixels (the specs read the DOM). | +| 5 | One motion vocabulary | fixed | The tokens live in `src/shared/motion.ts` and `src/renderer/src/motion/motion.css`, and `tests/unit/motion-tokens.test.ts` checks them against each other. The lane moved the components it touched onto them. Timings the spec sets apart keep their own: the FL-23 sweep (1.2 s), the FL-12 chevron (160 ms) and FL-28's cross-fade (120 ms). | +| 6 | Colour moves with space | pass | A swipe from a blue to an orange Space draws 16 colours in between, with no grey frame and titles at 9.8:1 (FL-14, `feel-spaces`). | +| 7 | Native window behaviour | not measured | Linux on Xvfb has no system material, trackpad or haptics. Built, but not measured here: OS window controls, draggable areas, native menus, OS full screen and new windows a step down and right (NV-10). Haptics (FL-31, P2) are not built. | +| 8 | Small, playful touches that never get in the way | fixed | FL-08, FL-15, FL-20 and FL-22 were added or reworked, and FL-23 was audited. Under reduced motion they become fades or nothing. `tests/unit/reduced-motion-guard.test.ts` checks that every motion component sits under the config or reads the setting. It also checks that every element with a layout animation, an entrance, an exit, a press or a moving `animate` asks `useReducedMotion()`, so the setting reaches it when it changes, not only when it mounts. `feel-reduced` checks the toasts frame by frame under both settings, and the Favorites, a folder, the pinned section and the presses with the setting turned on after they show. The action never waits for them: buttons fire on release, the reset on press, shortcuts on the key (`feel-chrome`, `feel-rows`). | +| 9 | Scale doesn't change the feel | partial | 500 tabs and 50 folders scroll with 12 frames over one interval (was 102). A 200-tab Space shown for the first time draws a stand-in in 11 ms and its list in 161 ms (was 231 ms, behind a 258 ms task). With 300 tabs, no row is drawn empty. Spec: `feel-sidebar`. Gap: p99 is still two intervals under load, and a first showing still has a 57 ms task. | diff --git a/docs/feature-status.json b/docs/feature-status.json index 42804f9a..a4f39d26 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -112,7 +112,7 @@ "SV-23": {"built": "partial", "note": "pinning keeps panes/sizes/orientation; drops onto pinned tabs stay pinned; Favorite splits show a combined tile and drag like tiles; resetting every pane and Peek from pinned splits not built"}, "SV-24": {"built": "yes", "note": "panes, order, orientation, sizes and focused pane survive relaunch; a Today split closed or auto-archived as one unit restores from the Archive as one unit, with its layout and sizes"}, "SV-25": {"built": "yes", "note": "fifth pane refused with a toast on every route; no split from an empty window; HTML full screen (core) fills the window, hides the strip and returns to the split"}, - "SV-26": {"built": "partial", "note": "springs for create/close/flip, closing card fades, 1:1 resize, drop indicator fade/slide, frame colour until loaded, split row entrance; sidebar row-merge reflow belongs to the tabs lane's list animation"}, + "SV-26": {"built": "partial", "note": "springs for create/close/flip, closing card fades, 1:1 resize, drop indicator fades in and moves between halves at once (FL-18), frame colour until loaded, split row entrance; sidebar row-merge reflow belongs to the tabs lane's list animation"}, "CB-01": {"built": "yes", "note": "no new-tab page: ⌘T and + New Tab open the bar; a tab exists only once a row is chosen"}, "CB-02": {"built": "yes", "note": "⌘T/Ctrl+T, + New Tab row, macOS File menu; second ⌘T closes"}, "CB-03": {"built": "yes", "note": "⌘L/Ctrl+L/Alt+D: full URL selected, current-page row (Enter reloads), navigates the current tab"}, @@ -147,7 +147,7 @@ "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion"}, "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, "KS-06": {"built": "yes", "note": "⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05)"}, - "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, + "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade; the rubber band keeps the spring FL-13 names (response 0.3 s, ratio 1.0) and comes back within 0.5 px about 480 ms after the last event: FL-29's 350 ms is for swipes that land on a Space (D70)"}, "FL-14": {"built": "yes", "note": "theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade"}, "FL-16": {"built": "yes", "note": "motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window"}, "FL-17": {"built": "yes", "note": "per-keystroke local results, identity-keyed memoized rows, instant highlight; engine suggestions fill free rows without moving the highlight"}, @@ -203,9 +203,9 @@ "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)"}, "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; print, save page, reader mode and the rest come with their NV items"}, "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"}, - "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, + "FL-15": {"built": "yes", "note": "every icon button presses to 0.94 at once and springs back (pop), the Library button too; Back/Forward nudge, Reload's glyph turns once, the + scales to 0.92; restarts on repeat; none when disabled; no spring with reduced motion"}, "FL-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"}, + "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"}, "MD-01": {"built": "partial", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video) 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 with no blank frame; moving between a split's panes doesn't float it; media inside frames (embedded players) isn't seen yet: no automatic PiP, and its controller has only Go to tab and Close, which leaves it playing"}, @@ -246,7 +246,7 @@ "PK-13": {"built": "yes", "note": "no sidebar row; sidebar stays usable; selecting another tab, Space or window dismisses (reopenable)"}, "PK-15": {"built": "yes", "note": "no hover-to-Peek trigger: hovering links or pinned rows never opens a Peek (hover cards are TB-21)"}, "PK-16": {"built": "yes", "note": "Mini Window links never Peek; target=_blank stays in the Mini Window"}, - "FL-19": {"built": "yes", "note": "open spring done in ~280 ms with no overshoot, 18% dim; expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade; drag/swipe dismiss; ⌘Z/⌘⇧T replay the open"}, + "FL-19": {"built": "yes", "note": "open: a spring tuned to FL-19's 280–320 ms (response 0.275 s, ratio 0.92; the standard token FL-19 names takes longer over a card's travel, D70), within 2 px in ~320 ms with no overshoot, 18% dim; expand meets the frame in ~250 ms, then the row enters; split slides to the right half; close 160 ms to 0.96 + fade, the page behind takes clicks again 173–194 ms after Escape (best of up to three rounds, at the edge of 200 ms); drag/swipe dismiss; ⌘Z/⌘⇧T replay the open"}, "FL-24": {"built": "yes", "note": "Library opens with a 220 ms fade and 0.98→1 scale (opacity only with reduced motion) and fades out; sections cross-fade in 90 ms; search answers within 50 ms; restored rows fade and the rows below rise; long lists virtualised; a previewed entry opens and closes with the Peek's motion (FL-19)"}, "MW-01": {"built": "yes", "note": "separate small window, one floating tab, main window untouched"}, "MW-02": {"built": "yes", "note": "open-url / second-instance / argv links open a Mini Window; same URL resurfaces it; routing rules first"}, @@ -330,5 +330,25 @@ "UP-06": {"built": "partial", "note": "after a version change the sidebar shows \"Updated to Orbit X\" with See what's new, which opens that version's GitHub release notes in a tab; ends when opened, dismissed or after 3 days; command bar \"What's New\" and Help → Release Notes. Not built: the release's one-line summary in the banner"}, "UP-07": {"built": "yes", "note": "About Orbit (app menu) opens Settings → About: logo, version, engine, Electron, build date (set at build time), Copy build info, and the licence and open-source credits from a page that comes with the app (D43); Help → Troubleshooting → Copy Build Info and the command-bar action copy version, build date, engine, OS and processor"}, "UP-10": {"built": "yes", "note": "reads Orbit's JSON feed ({version, url, summary, security}) or a GitHub releases API answer (latest, or a list: newest non-draft, non-pre-release); a newer version shows \"New version available\" and opens its page in a tab. No feed is set up yet: the address is empty by default"}, - "UP-12": {"built": "yes", "note": "a release marked [security] in its GitHub name or notes, or security: true in Orbit's feed, shows \"Important security update\" in the banner and Settings"} + "UP-12": {"built": "yes", "note": "a release marked [security] in its GitHub name or notes, or security: true in Orbit's feed, shows \"Important security update\" in the banner and Settings"}, + "FL-01": {"built": "yes", "note": "one token set in shared/motion.ts and motion.css (instant 90 ms, quick 130 ms, pop 240 ms; snappy, standard and pop springs), checked against each other by a unit test; interrupted motions turn round from where they are (Space slide, command bar, hover card, folders, ⌘S), checked as speeds from the first frame that shows the input handled"}, + "FL-04": {"built": "yes", "note": "row hover lights on the instant timing and fades out from where it is when the pointer leaves; every row a sweep enters lights"}, + "FL-05": {"built": "yes", "note": "a press selects the row in its frame, before the page shows (within 50 ms); a press on a row still growing in selects it; a selected row out of sight scrolls into view with a 225 ms ease-out, or at once from more than two screens away or with reduced motion"}, + "FL-06": {"built": "yes", "note": "a new row grows with the snappy spring, its content fading in over the last 60 %; the rows below glide"}, + "FL-07": {"built": "yes", "note": "pinned, unpinned or moved by key or menu: the row travels with the standard spring while the room it leaves and the room it takes close and open with it; to a place out of sight or another Space it collapses out in place and the list does not scroll; ⌘Z plays it back"}, + "FL-08": {"built": "yes", "note": "×, audio and folder search buttons: circle fades in on the instant timing; the glyph presses to 0.9 at once and springs back (pop); the click fires on release and the row starts leaving once main has closed the tab, about 2 frames after the release (D70: main decides first, as a pinned tab's × resets it and a page may ask to stay); the reset acts on press (PN-04)"}, + "FL-09": {"built": "yes", "note": "a closing row fades and slides 12 px while its room collapses with the snappy spring and the rows below rise; the next tab is selected no later than the closing row starts to leave, in the first frame after ⌘W at the median of five closes (main wakes a sleeping page only once the change has gone out); the first close after a launch can take 2 frames; rows out of sight go without motion"}, + "FL-10": {"built": "yes", "note": "a 4 px move lifts the row (1.03, shadow, 95 %) in that frame; the row is drawn under the pointer in every frame; Escape springs it back to its slot"}, + "FL-11": {"built": "yes", "note": "Clear sweeps the rows out one after another (25 ms apart, at most 12 staggered) within 450 ms; the divider and New Tab stay; ⌘Z brings them back in one group"}, + "FL-12": {"built": "yes", "note": "folders and the pinned section: the chevron turns in 160 ms; the box grows with the snappy spring while its rows fade in 12 ms apart (only when the user opens it); closing is quicker; reduced motion fades"}, + "FL-20": {"built": "yes", "note": "a new Favorite pops in from 0.85 with the pop spring while the other tiles re-flow with the snappy spring; a removed one shrinks to 0.85 and fades where it was while the grid closes up"}, + "FL-21": {"built": "partial", "note": "only the rows near the visible band are drawn, and every row tells screen readers its place and its list's size, drawn or not; a Space shown for the first time draws a stand-in at once and its list within ~160 ms; on this machine under load a 200-tab fling and a 500-tab scroll still have a few frames of two intervals (5 and 12), so p99 is two intervals, against none over one (docs/FEEL.md)"}, + "FL-22": {"built": "partial", "note": "rise 12 px from 0.96 with the pop spring, at most three 8 px apart, a new one pushes the others up with the snappy spring, stays 3 s (4 s with a button), a hovered one with a button waits until 1 s after the pointer leaves; fade and 8 px drop in 160 ms; under reduced motion a toast fades in where it rests, at its size, and the older ones move at once. Shown at the bottom of the sidebar (not over the page card, so hidden behind the page when the sidebar is), plain toasts let clicks through, and no Space theme yet (AP-14)"}, + "FL-25": {"built": "yes", "note": "folder search and icon picker: drawn in the first frame, from 0.97 with the snappy timing (150 ms); close with the quick fade; keys go back to the sidebar at once"}, + "FL-28": {"built": "yes", "note": "follows the OS setting, with a setting to force it on (AP-17); springs, slides and scales become short fades or instant changes, toasts included, also for what is already on screen when it turns on; a unit guard checks that every motion component sits under the config or reads the setting, and that every element with a layout animation, an entrance, an exit, a press or a moving animate asks useReducedMotion(), since motion reads the config only as an element mounts; drags still follow the pointer and settle with a short ease"}, + "AP-17": {"built": "partial", "note": "one set of motion tokens for every surface (FL-01): ease-out curves of 90–270 ms and springs of 0.25–0.32 s; buttons and the × press to 0.9 (FL-08); the loading pill at the top centre of the card (FL-23); reduced motion from the OS setting or Orbit's own switch in Appearance, turning slides, flights and springs into short fades (FL-28). No shimmer when the app starts"}, + "FL-29": {"built": "partial", "note": "measured by the feel specs against each budget (docs/FEEL.md); press, ⌘W, ⌘S, Space switch (settles in ~290 ms), command bar and drag budgets met; still missed: p99 at two intervals in some animations under load (⌘S hide, long lists, a Peek opening), the hover sidebar and a Peek's close start in the second frame, and the first ⌘T or ⌘W after a launch can show in the second frame. The rubber band comes back in ~480 ms on FL-13's own spring: the 350 ms settle is for swipes that land on a Space (D70)"}, + "FL-30": {"built": "partial", "note": "the nine points as a checklist in docs/FEEL.md: pass, gap or fixed, each with the test that shows it"}, + "FL-18": {"built": "partial", "note": "a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop"}, + "FL-27": {"built": "yes", "note": "a resized window keeps the card's margin in every frame and the page view gets each width within a frame at the median (18 ms at worst), over the page's own colour; the OS full-screen transition; new windows open a step down and right (NV-10) with the command bar's entrance; an inactive window's sidebar dims over 150 ms and comes back at once on focus; no launch sheen (optional)"} } diff --git a/scripts/capture/record.mjs b/scripts/capture/record.mjs index 17d7d6b0..4a9f4074 100644 --- a/scripts/capture/record.mjs +++ b/scripts/capture/record.mjs @@ -245,6 +245,22 @@ const sealOffline = (app) => return Promise.all([...all].map((s) => s.resolveProxy('https://example.com/'))) }, `http://${OFFLINE_PROXY}`) +/** + * Quits Orbit as the e2e harness does (askQuit in tests/e2e/helpers.ts, PR #16): asked from a task of + * its own, not by the inspector's interrupt; Playwright's own close then only cleans up. + */ +async function quitOrbit(app) { + const exited = new Promise((resolve) => app.once('close', resolve)) + await app + .evaluate(async ({ app: electronApp }) => { + await new Promise((r) => setImmediate(r)) + electronApp.quit() + }) + .catch(() => {}) + await Promise.race([exited, sleep(15_000)]) + await app.close() +} + const fx = await startFixtures() const userData = mkdtempSync(join(tmpdir(), 'orbit-capture-')) const main = resolve('out/main/index.js') @@ -270,9 +286,11 @@ try { if (!shell) await sleep(50) } await shell.getByTestId('sidebar').waitFor() - // Fill the display so the capture shows the whole window. + // Fill the display so the capture shows the whole window. From a task of its own: the inspector runs + // an evaluate by interrupt, and Orbit's resize handler would run part-way through other main-process code. await app.evaluate( - ({ BrowserWindow }, [w, h]) => { + async ({ BrowserWindow }, [w, h]) => { + await new Promise((r) => setImmediate(r)) for (const win of BrowserWindow.getAllWindows()) win.setBounds({ x: 0, y: 0, width: w, height: h }) }, [W, H], @@ -313,7 +331,7 @@ try { saved = true } finally { if (rec) await rec.stop() - await app?.close().catch(() => {}) + if (app) await quitOrbit(app).catch(() => {}) fx.close() rmSync(userData, { recursive: true, force: true }) if (!saved) rmSync(partial, { force: true }) diff --git a/scripts/perf/f5-loading-view-after-first-frame.patch b/scripts/perf/f5-loading-view-after-first-frame.patch new file mode 100644 index 00000000..09ccf0d0 --- /dev/null +++ b/scripts/perf/f5-loading-view-after-first-frame.patch @@ -0,0 +1,47 @@ +F5's cold-start A/B: PERF.md, "2026-10-10 23:20 UTC: cold start after chrome's loading view". + +A: the loading indicator's view is made at `ready-to-show`, as Orbit does. +B: it is made once the shell has drawn its first frame (the `shell:first-frame` mark). + +One build runs both: ORBIT_LOADING_AT=ready gives A, anything else gives B. The patch also marks +`loading:view` when the view is made. From the repository's root: + + git apply scripts/perf/f5-loading-view-after-first-frame.patch && npm run build + ORBIT_LOADING_AT=ready xvfb-run -a -s "-screen 0 1440x900x24" node scripts/perf/run.mjs --cold-only --runs 7 --out perf-results/f5-a1.json + xvfb-run -a -s "-screen 0 1440x900x24" node scripts/perf/run.mjs --cold-only --runs 7 --out perf-results/f5-b1.json + +Three rounds of each, alternating A and B; then `git apply -R` the patch and build again. + +--- a/src/main/nav/loading.ts ++++ b/src/main/nav/loading.ts +@@ -104,6 +104,7 @@ + if (live(s.view)) return s.view + s.ready = false + const view = windows.createUiView(windowId, 'loading') ++ ctx.perf.mark('loading:view') + s.view = view + viewContents(view)?.once('did-finish-load', () => { + if (s.view !== view) return +@@ -203,12 +204,19 @@ + + // Each window's view is made as the window shows, out of the window, so a load in its first second + // finds it ready and shows the indicator after the usual 150 ms (fix check A, F1 and round 2, F3). +- windows.onWindowCreated((windowId, win) => { +- win.once('ready-to-show', () => { ++ if (process.env.ORBIT_LOADING_AT === 'ready') ++ windows.onWindowCreated((windowId, win) => { ++ win.once('ready-to-show', () => { ++ const s = stateOf(windowId) ++ if (s) viewFor(windowId, s) ++ }) ++ }) ++ else ++ ctx.events.on('perf.mark', ({ name }, { windowId }) => { ++ if (name !== 'shell:first-frame' || !windowId) return + const s = stateOf(windowId) + if (s) viewFor(windowId, s) + }) +- }) + + // Another tab shown (tab switch, Space switch, split focus), or the page card moved. + store.subscribe((next, prev) => { diff --git a/scripts/perf/run.mjs b/scripts/perf/run.mjs index aafe1726..5a24d43b 100644 --- a/scripts/perf/run.mjs +++ b/scripts/perf/run.mjs @@ -7,6 +7,7 @@ * * Linux: xvfb-run -a -s "-screen 0 1440x900x24" node scripts/perf/run.mjs [--runs 5] [--out perf-results/latest.json] * [--port 3350] (the fixture server's first port; it takes the next free one up to 3399) + * [--cold-only] (Orbit's cold start alone: no interactive scenarios, no Chromium baseline) */ import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -22,7 +23,8 @@ const opt = (name, fallback) => { } const RUNS = Number(opt('runs', '5')) const OUT = resolve(opt('out', 'perf-results/latest.json')) -const SKIP_CHROME = args.includes('--no-chrome') +const COLD_ONLY = args.includes('--cold-only') +const SKIP_CHROME = COLD_ONLY || args.includes('--no-chrome') const FIRST_PORT = Number(opt('port', '3350')) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const median = (xs) => { @@ -102,6 +104,22 @@ async function launchOrbit(userData) { if (!shell) throw new Error('shell window not found') return { app, shell } } +/** + * Quits Orbit as the e2e harness does (askQuit in tests/e2e/helpers.ts, PR #16): asked from a task of + * its own, since Playwright's `app.close()` asks through the inspector, which can run the quit handlers + * part-way through other main-process code. Playwright's own close then only cleans up. + */ +async function quitOrbit(app) { + const exited = new Promise((resolve) => app.once('close', resolve)) + await app + .evaluate(async ({ app: electronApp }) => { + await new Promise((r) => setImmediate(r)) + electronApp.quit() + }) + .catch(() => {}) + await Promise.race([exited, sleep(15_000)]) + await app.close() +} const marks = (app) => app.evaluate(() => globalThis.__orbit.marks()) const orbitRun = (app, name, a) => app.evaluate((_e, [n, x]) => globalThis.__orbit.run(n, x, globalThis.__orbit.windowIds()[0]), [name, a]) @@ -141,13 +159,13 @@ async function orbitColdStart(fx) { const tabId = await orbitRun(app, 'tabs.create', { url: fx.url(i), activate: true }) await waitTabsLoaded(app, [tabId]) await app.evaluate(() => globalThis.__orbit.flush()) - await app.close() + await quitOrbit(app) log('relaunch with restored tab') const w0 = Date.now() ;({ app } = await launchOrbit(dir)) restoredPaint.push(await waitMark(app, `tab:${tabId}:loaded`)) restoredWall.push(Date.now() - w0) - await app.close() + await quitOrbit(app) rmSync(dir, { recursive: true, force: true }) } return { @@ -423,7 +441,7 @@ async function orbitInteractive(fx) { samples: bigTimes, } - await app.close() + await quitOrbit(app) rmSync(dir, { recursive: true, force: true }) return result } @@ -471,7 +489,7 @@ async function chromeBaseline(fx) { const fx = await startFixtures() const report = { date: new Date().toISOString(), platform: process.platform, runs: RUNS } try { - report.orbit = { coldStart: await orbitColdStart(fx), ...(await orbitInteractive(fx)) } + report.orbit = { coldStart: await orbitColdStart(fx), ...(COLD_ONLY ? {} : await orbitInteractive(fx)) } if (!SKIP_CHROME) { try { report.chromium = await chromeBaseline(fx) diff --git a/src/main/features.ts b/src/main/features.ts index dcb8d140..c8082132 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -11,6 +11,7 @@ import { registerImport } from './import' import { registerKeys } from './keys' import { registerMedia } from './media' import { registerMiniWindow } from './miniwindow' +import { registerMotion } from './motion' import { registerNav } from './nav' import { registerPeek } from './peek' import { registerSettings } from './settings' @@ -25,6 +26,7 @@ export function registerFeatures(ctx: MainContext): void { // First: the user's key bindings, so whatever reads a shortcut as it registers reads theirs (KS-01). registerKeys(ctx) registerCore(ctx) + registerMotion(ctx) const { ops } = registerTabs(ctx) registerNav(ctx, ops) // Before Peek and Mini Windows: a DevTools key held down is ignored before they act on it. diff --git a/src/main/media/player.ts b/src/main/media/player.ts index 77f0632a..4a4e207c 100644 --- a/src/main/media/player.ts +++ b/src/main/media/player.ts @@ -18,7 +18,6 @@ import { Menu, type MenuItemConstructorOptions, screen, - systemPreferences, type WebContents, WebContentsView, } from 'electron' @@ -47,6 +46,7 @@ import { import type { Rect } from '../../shared/rpc' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { reducedMotion } from '../motion' import { displayedTabs } from '../tabs/model' import { loadRenderer, rendererEntry, uiWebPreferences } from '../windows' @@ -281,7 +281,7 @@ export class PipPlayer { } } } - if (from && (from.width < 8 || from.height < 8 || !richMotion())) from = undefined + if (from && (from.width < 8 || from.height < 8 || !!reducedMotion(this.ctx))) from = undefined const start = from ?? target const ms = ENTER_MS @@ -385,7 +385,7 @@ export class PipPlayer { release() void this.showStill(null) } - const fade = isMac && richMotion() && (why === 'close' || why === 'off' || why === 'ended') + const fade = isMac && !reducedMotion(this.ctx) && (why === 'close' || why === 'off' || why === 'ended') const back = this.backing?.tabId === s.tabId ? this.backing : undefined this.backing = undefined if (win && !win.isDestroyed() && win.isVisible() && why === 'returned' && back?.to && this.still) { @@ -447,7 +447,14 @@ export class PipPlayer { /** The video's place in the page on screen, if the motion back there can be seen (MD-16). */ private homeOnScreen(s: Session, windowId: WindowId): Rect | undefined { const source = this.ctx.windows.get(windowId) - if (!s.home || !richMotion() || !source || source.isDestroyed() || !source.isVisible() || source.isMinimized()) + if ( + !s.home || + reducedMotion(this.ctx) || + !source || + source.isDestroyed() || + !source.isVisible() || + source.isMinimized() + ) return undefined const content = source.getContentBounds() const to = { ...s.home, x: content.x + s.home.x, y: content.y + s.home.y } @@ -642,7 +649,7 @@ export class PipPlayer { if (p?.corner === corner && p.size === size) return d.settings.pipPlacement = { corner, size } }) - const rich = richMotion() + const rich = !reducedMotion(this.ctx) this.lastSettle = { from: now, to: target, ms: rich ? null : RELEASE_MS } if (rich) this.animateSpring(now, target, velocity) else this.animateTimed(now, target, RELEASE_MS, undefined, EASE_OUT_CURVE) @@ -894,16 +901,3 @@ function velocityOf(samples: { x: number; y: number; t: number }[]): { x: number const dt = (last.t - first.t) / 1000 return { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt } } - -/** - * No reduced motion asked for (FL-28): macOS Reduce Motion, Windows animation effects, the GTK - * animation setting, or Chromium's force switches. The same signal as the pages' - * prefers-reduced-motion (`shouldRenderRichAnimation` ignores the switches). - */ -function richMotion(): boolean { - try { - return !systemPreferences.getAnimationSettings().prefersReducedMotion - } catch { - return true - } -} diff --git a/src/main/miniwindow/index.ts b/src/main/miniwindow/index.ts index 15f70c75..24216aa4 100644 --- a/src/main/miniwindow/index.ts +++ b/src/main/miniwindow/index.ts @@ -1,13 +1,4 @@ -import { - app, - BrowserWindow, - globalShortcut, - Menu, - nativeTheme, - screen, - systemPreferences, - type WebContents, -} from 'electron' +import { app, BrowserWindow, globalShortcut, Menu, nativeTheme, screen, type WebContents } from 'electron' import { toElectronAccelerator } from '../../shared/actions' import { createWindowState } from '../../shared/defaults' import { newId } from '../../shared/ids' @@ -19,6 +10,7 @@ import { NEUTRAL_THEME, paletteFor } from '../../shared/theme' import { insertItem, removeItem, sectionOf } from '../../shared/tree' import { engineForWindow, parseInput, siteOf } from '../../shared/url' import type { MainContext } from '../context' +import { reducedMotion } from '../motion' import { archiveFloating, floatingTab, keepFloatingPagesOutOfTabClose } from '../peek/floating' import { runPageAction } from '../peek/page-actions' import type { CallContext } from '../registry' @@ -127,14 +119,6 @@ function raise(win: BrowserWindow | undefined): void { win.focus() } -function reducedMotion(): boolean { - try { - return systemPreferences.getAnimationSettings().prefersReducedMotion - } catch { - return false - } -} - export function registerMiniWindow(ctx: MainContext): void { const { store, commands, actions, windows, tabs } = ctx const minis = new Map() @@ -274,7 +258,7 @@ export function registerMiniWindow(ctx: MainContext): void { } const fadeIn = (win: BrowserWindow) => { - if (!canFade || reducedMotion()) { + if (!canFade || reducedMotion(ctx)) { win.show() return } @@ -297,7 +281,7 @@ export function registerMiniWindow(ctx: MainContext): void { const closeWindow = (mini: Mini, opts: { shrink?: boolean } = {}) => { const win = mini.win if (win.isDestroyed()) return - if (!canFade || reducedMotion() || !win.isVisible()) { + if (!canFade || reducedMotion(ctx) || !win.isVisible()) { win.close() return } diff --git a/src/main/motion.ts b/src/main/motion.ts new file mode 100644 index 00000000..ad9c0232 --- /dev/null +++ b/src/main/motion.ts @@ -0,0 +1,28 @@ +import { systemPreferences } from 'electron' +import type { MainContext } from './context' + +/** + * Reduced motion (FL-28, AP-17) as main sees it: on when the OS asks for it or Orbit's own setting + * forces it. Electron reads the OS setting per platform: Reduce Motion on macOS, animation effects + * off on Windows, the GTK animation setting on Linux (and Chromium's force switches). Main's own + * motion (Mini Window fades, the floating player's flights) asks this each time it would animate; + * renderers follow the same two signals themselves (src/renderer/src/motion/reduced.ts). + */ +export function reducedMotion(ctx: Pick): boolean { + if (ctx.store.state.settings.reduceMotion) return true + try { + return systemPreferences.getAnimationSettings().prefersReducedMotion + } catch { + return false + } +} + +/** The command bar's "Toggle Reduce Motion" (FL-28): Orbit's setting, on or off. */ +export function registerMotion(ctx: MainContext): void { + const { actions, store } = ctx + actions.register('app.toggleReduceMotion', () => + store.update((d) => { + d.settings.reduceMotion = !d.settings.reduceMotion + }), + ) +} diff --git a/src/main/persist.ts b/src/main/persist.ts index d8c4ba4c..75b2e4d6 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -124,6 +124,8 @@ export function normalizeState(input: Partial): OrbitState { .map((s) => ({ id: String(s.id || s.keyword), name: String(s.name || s.keyword), keyword: s.keyword, url: s.url })) if (typeof state.settings.searchSuggestions !== 'boolean') state.settings.searchSuggestions = false if (typeof state.settings.searchTopResult !== 'boolean') state.settings.searchTopResult = false + // Reduced motion forced on (FL-28): off unless set. + if (typeof state.settings.reduceMotion !== 'boolean') state.settings.reduceMotion = false // The Library section used last (AR-02): a section id, else the Archive. const section = state.settings.librarySection if (!(typeof section === 'string' && /^[\w.-]{1,64}$/.test(section))) state.settings.librarySection = 'archive' diff --git a/src/main/settings/validate.ts b/src/main/settings/validate.ts index 690a74ee..8c5a0b26 100644 --- a/src/main/settings/validate.ts +++ b/src/main/settings/validate.ts @@ -29,6 +29,7 @@ const CHECKS: { [K in keyof Required]: Check } = { hardwareAcceleration: bool, secureDns: (v) => (SECURE_DNS_CHOICES as readonly unknown[]).includes(v), updateChecks: bool, + reduceMotion: bool, } /** diff --git a/src/main/test-hooks.ts b/src/main/test-hooks.ts index 1532b428..21e16400 100644 --- a/src/main/test-hooks.ts +++ b/src/main/test-hooks.ts @@ -17,7 +17,10 @@ export function exposeTestHooks(ctx: MainContext, persistence: Persistence): voi run: (name: CommandName, args: unknown, windowId?: string) => later(() => ctx.commands.run(name, args as never, { windowId })), action: (id: string, windowId?: string) => later(() => ctx.actions.run(id, { windowId })), - flush: () => persistence.flush(), + /** Writes the state file in a task of its own: the write clears its timer, never to be done by interrupt. */ + flush: () => later(() => persistence.flush()), + /** Writes it in the calling task, for a caller already in a task of its own (the e2e evaluate) that checks the state first. */ + flushNow: () => persistence.flush(), marks: () => ctx.perf.marks, windowIds: () => ctx.windows.ids(), liveTabs: () => ctx.tabs.liveTabIds(), diff --git a/src/main/windows.ts b/src/main/windows.ts index 9c77dab1..3b0461ed 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1314,6 +1314,19 @@ export class WindowManager { const panes = tabId && frame ? fastSwitchPanes(state, prev, tabId, before, frame) : null if (panes) { rec.minPaneSeq = this.ctx.store.seq + 1 + if (panes.some((p) => !this.liveContents(p.tabId))) { + // A sleeping tab's page takes longer than a frame to wake: it wakes just after this change + // has gone out, so the sidebar draws the new selection first (FL-29: ⌘W → next tab selected + // in a frame; the card shows the page's background meanwhile). + const shown = rec.panes + setImmediate(() => { + if (rec.win.isDestroyed() || rec.panes !== shown || activeTabOf(this.ctx.store.state, rec.id) !== tabId) + return + this.applyPanes(rec.id, panes, rec.radius) + if (tabId && !rec.overlayShown) this.liveContents(tabId)?.focus() + }) + return + } this.applyPanes(rec.id, panes, rec.radius) } else if (!tabId) { rec.minPaneSeq = this.ctx.store.seq + 1 diff --git a/src/renderer/src/downloads/DownloadsFlyout.tsx b/src/renderer/src/downloads/DownloadsFlyout.tsx index 446acade..8a065d1f 100644 --- a/src/renderer/src/downloads/DownloadsFlyout.tsx +++ b/src/renderer/src/downloads/DownloadsFlyout.tsx @@ -3,7 +3,7 @@ import { m, useIsPresent } from 'motion/react' import { type KeyboardEvent, memo, type ReactNode, useEffect, useRef, useState } from 'react' import type { DownloadEntry } from '../../../shared/commands/downloads' import { invoke, isMac } from '../api' -import { SNAPPY } from '../shell/sidebar/motion-tokens' +import { POPOVER, SNAPPY, useReducedMotion } from '../motion' import { useOrbit, useWindow } from '../store' import { usePalette } from '../theme' import { resumeOrRetry, useDownloadsList, useLiveEntry } from './data' @@ -173,6 +173,8 @@ export function DownloadsFlyout() { const ref = useRef(null) // One going away (Esc, then Ctrl+J at once) fades out under the new one: it takes no clicks. const present = useIsPresent() + // With reduced motion it only fades, also when the setting changes while it is open (FL-28). + const reduced = useReducedMotion() useEffect(() => ref.current?.focus(), []) const list = entries ?? [] @@ -209,9 +211,9 @@ export function DownloadsFlyout() { aria-hidden={present ? undefined : true} tabIndex={-1} style={{ left, pointerEvents: present ? undefined : 'none' }} - initial={{ opacity: 0, y: -6, scale: 0.98 }} + initial={reduced ? { opacity: 0, y: 0, scale: 1 } : { opacity: 0, y: -6, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1, transition: SNAPPY }} - exit={{ opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }} + exit={{ opacity: 0, y: reduced ? 0 : -4, scale: reduced ? 1 : 0.98, transition: POPOVER.out }} onKeyDown={onKeyDown} >
diff --git a/src/renderer/src/downloads/DownloadsTray.tsx b/src/renderer/src/downloads/DownloadsTray.tsx index 9d12f3b3..3320dafa 100644 --- a/src/renderer/src/downloads/DownloadsTray.tsx +++ b/src/renderer/src/downloads/DownloadsTray.tsx @@ -1,18 +1,18 @@ import { ArrowDownToLine, Check, CircleAlert, Pause, Play, RotateCw, ShieldAlert, X } from 'lucide-react' -import { AnimatePresence, motion, useReducedMotion } from 'motion/react' +import { AnimatePresence, motion } from 'motion/react' import { memo, type ReactNode, useEffect, useRef, useState } from 'react' import type { DownloadStarted } from '../../../shared/commands/downloads' import { isPrivateWindow } from '../../../shared/private' import type { Download, OrbitState, ProfileId, WindowState } from '../../../shared/state' import { invoke } from '../api' -import { QUICK_EASE, SNAPPY } from '../shell/sidebar/motion-tokens' +import { SNAPPY, tween, useReducedMotion } from '../motion' import { useOrbit, useOrbitShallow } from '../store' import { dragOut } from './file-ui' import { flyToLibrary } from './flight' import { formatBytes, formatProgress, formatTimeLeft, percentDone, secondsLeft, smoothSpeed, splitName } from './format' import './downloads.css' -const FADE = { duration: 0.12, ease: QUICK_EASE } +const FADE = tween('quick') /** Hovering this long opens the list of downloads; leaving it this long closes it. */ const OPEN_DELAY_MS = 150 const CLOSE_DELAY_MS = 250 diff --git a/src/renderer/src/downloads/downloads-library.css b/src/renderer/src/downloads/downloads-library.css index 27a1758f..827dfa95 100644 --- a/src/renderer/src/downloads/downloads-library.css +++ b/src/renderer/src/downloads/downloads-library.css @@ -507,18 +507,16 @@ color: var(--dl-card-muted); } -@media (prefers-reduced-motion: reduce) { - .dl-lib-row__fill, - .dl-media__tile, - .dl-media__name { - transition: none; - } - .dl-media__tile:hover { - transform: none; - } - .dl-lib-row__bar.is-indeterminate .dl-lib-row__fill { - animation: none; - width: 100%; - opacity: 0.5; - } +.reduce-motion .dl-lib-row__fill, +.reduce-motion .dl-media__tile, +.reduce-motion .dl-media__name { + transition: none; +} +.reduce-motion .dl-media__tile:hover { + transform: none; +} +.reduce-motion .dl-lib-row__bar.is-indeterminate .dl-lib-row__fill { + animation: none; + width: 100%; + opacity: 0.5; } diff --git a/src/renderer/src/downloads/downloads.css b/src/renderer/src/downloads/downloads.css index 5b8ec785..02802aba 100644 --- a/src/renderer/src/downloads/downloads.css +++ b/src/renderer/src/downloads/downloads.css @@ -214,19 +214,17 @@ button.dl-row__main:hover { will-change: transform, opacity; } -@media (prefers-reduced-motion: reduce) { - .dl-row__btn, - .dl-row__fill { - transition: none; - } - .dl-row__btn:active { - transform: none; - } - .dl-row__bar.is-indeterminate .dl-row__fill { - animation: none; - width: 100%; - opacity: 0.5; - } +.reduce-motion .dl-row__btn, +.reduce-motion .dl-row__fill { + transition: none; +} +.reduce-motion .dl-row__btn:active { + transform: none; +} +.reduce-motion .dl-row__bar.is-indeterminate .dl-row__fill { + animation: none; + width: 100%; + opacity: 0.5; } /* DL-12: a download held back as possibly harmful waits for Keep or Discard. */ diff --git a/src/renderer/src/downloads/flight.ts b/src/renderer/src/downloads/flight.ts index 350a0c04..654103ad 100644 --- a/src/renderer/src/downloads/flight.ts +++ b/src/renderer/src/downloads/flight.ts @@ -1,4 +1,4 @@ -import { reducedMotion } from '../shell/sidebar/motion-tokens' +import { reducedMotion } from '../motion' /** The flight's length (DL-02: about 400–600 ms, ease-out). */ const FLIGHT_MS = 520 diff --git a/src/renderer/src/import/import.css b/src/renderer/src/import/import.css index ae7cf469..e2f4232b 100644 --- a/src/renderer/src/import/import.css +++ b/src/renderer/src/import/import.css @@ -570,10 +570,9 @@ html[data-platform="darwin"] .im .im-flow__body { } } -@media (prefers-reduced-motion: reduce) { - .im-state--running { - animation: none; - } +/* Reduced motion (FL-28): the OS setting or Orbit's own, as the root's class (motion/reduced.ts). */ +.reduce-motion .im-state--running { + animation: none; } .im-skipped h2 { diff --git a/src/renderer/src/keys/sheet.css b/src/renderer/src/keys/sheet.css index fc72990e..5ef88920 100644 --- a/src/renderer/src/keys/sheet.css +++ b/src/renderer/src/keys/sheet.css @@ -59,10 +59,9 @@ } } -@media (prefers-reduced-motion: reduce) { - .ks-sheet { - animation: none; - } +/* Reduced motion (FL-28): the OS setting or Orbit's own, as the root's class (motion/reduced.ts). */ +.reduce-motion .ks-sheet { + animation: none; } .ks-sheet__head { diff --git a/src/renderer/src/library/library.css b/src/renderer/src/library/library.css index 74aed5ee..0723c0a8 100644 --- a/src/renderer/src/library/library.css +++ b/src/renderer/src/library/library.css @@ -168,7 +168,7 @@ html[data-view="library"] body { 0 8px 24px rgba(0, 0, 0, 0.16), 0 0 0 0.5px rgba(0, 0, 0, 0.12); pointer-events: auto; - animation: library-toast-in 230ms cubic-bezier(0.3, 1.25, 0.5, 1); + animation: library-toast-in 230ms var(--ease-pop); } .library-toast.is-clickable { @@ -618,16 +618,14 @@ html[data-view="library"] body { font-weight: 600; } -@media (prefers-reduced-motion: reduce) { - .archive__scroller.is-animating .archive-day, - .archive__scroller.is-animating .archive-row, - .archive-row.is-leaving { - transition: opacity 120ms linear; - } - .library-toast, - .library__content { - animation: none; - } +.reduce-motion .archive__scroller.is-animating .archive-day, +.reduce-motion .archive__scroller.is-animating .archive-row, +.reduce-motion .archive-row.is-leaving { + transition: opacity 120ms linear; +} +.reduce-motion .library-toast, +.reduce-motion .library__content { + animation: none; } /* ---- Questions over the Library (AR-07 Clear Archive, AR-11 Restore Data) ---- */ @@ -725,11 +723,9 @@ html[data-view="library"] body { } } -@media (prefers-reduced-motion: reduce) { - .library-dialog__backdrop, - .library-dialog { - animation: none; - } +.reduce-motion .library-dialog__backdrop, +.reduce-motion .library-dialog { + animation: none; } /* ---- History (AR-10): the Archive's list, without the Space column; × removes on hover ---- */ diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index d4be3a36..c331586b 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,12 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { perfMark } from './api' +import { installReducedMotion } from './motion/reduced' import { bootSettingsLaneView, isSettingsLaneView } from './settings/boot' import { installShortcutFallback } from './shortcuts' import { initStore } from './store' +import './motion/motion.css' import './styles/global.css' async function boot(): Promise { + // Every view follows reduced motion (the OS setting or Orbit's, FL-28) from its first frame. + installReducedMotion() // Settings (settings lane): a window of its own, without the browser shell's code. if (isSettingsLaneView(window.orbit.view)) return bootSettingsLaneView() const view = window.orbit.view diff --git a/src/renderer/src/motion/MotionConfig.tsx b/src/renderer/src/motion/MotionConfig.tsx new file mode 100644 index 00000000..e1e5a290 --- /dev/null +++ b/src/renderer/src/motion/MotionConfig.tsx @@ -0,0 +1,13 @@ +import { MotionConfig } from 'motion/react' +import type { ReactNode } from 'react' +import { useReducedMotion } from './reduced' + +/** + * motion's components under Orbit's reduced-motion rule (FL-28): the OS setting or Orbit's own + * (`settings.reduceMotion`). With it, motion skips transform and layout animations (they land at + * once) and keeps fades and colour changes. motion's own "user" mode reads only the OS setting. + */ +export function OrbitMotionConfig({ children }: { children: ReactNode }) { + const reduced = useReducedMotion() + return {children} +} diff --git a/src/renderer/src/motion/index.ts b/src/renderer/src/motion/index.ts new file mode 100644 index 00000000..22610174 --- /dev/null +++ b/src/renderer/src/motion/index.ts @@ -0,0 +1,3 @@ +export * from './MotionConfig' +export * from './reduced' +export * from './tokens' diff --git a/src/renderer/src/motion/motion.css b/src/renderer/src/motion/motion.css new file mode 100644 index 00000000..9808e0b0 --- /dev/null +++ b/src/renderer/src/motion/motion.css @@ -0,0 +1,32 @@ +/* + * FL-01's motion tokens as custom properties, the same values as src/shared/motion.ts + * (motion-tokens.test.ts keeps them in step). Exits take about 0.7× their entrance: + * calc(var(--motion-snappy) * var(--motion-exit)). A surface whose spec gives its own number keeps + * that duration at its rule, on a token's curve. + * + * Reduced motion (FL-28): the renderer puts `reduce-motion` on when the OS asks for it or + * Orbit's own setting forces it; rules under `.reduce-motion` follow both. + */ +:root { + --motion-instant: 90ms; + --motion-quick: 130ms; + --motion-snappy: 200ms; + --motion-standard: 270ms; + --motion-pop: 240ms; + --motion-gentle: 250ms; + --motion-exit: 0.7; + --ease-instant: cubic-bezier(0, 0, 0.58, 1); + --ease-quick: cubic-bezier(0.2, 0, 0, 1); + --ease-snappy: cubic-bezier(0.2, 0.8, 0.2, 1); + --ease-standard: cubic-bezier(0.2, 0.9, 0.1, 1); + --ease-pop: cubic-bezier(0.3, 1.35, 0.55, 1); + --ease-gentle: cubic-bezier(0.42, 0, 0.58, 1); + /* Reduced motion's cross-fade (FL-28: about 100–150 ms). */ + --motion-fade: 120ms; + /* + * A transition's first frame shows where it starts, so a response would only move in the frame + * after the one that draws the change. As a delay it starts a 60 Hz frame along (ONE_FRAME_S), + * so the first frame already shows its first step (FL-29: "starts moving: 1 frame"). + */ + --motion-lead: calc(-1000ms / 60); +} diff --git a/src/renderer/src/motion/reduced.ts b/src/renderer/src/motion/reduced.ts new file mode 100644 index 00000000..f44f2260 --- /dev/null +++ b/src/renderer/src/motion/reduced.ts @@ -0,0 +1,60 @@ +import { useSyncExternalStore } from 'react' +import { useMirror } from '../store' + +/** + * Reduced motion (FL-28, AP-17): on when the OS asks for it (Reduce Motion on macOS, animation + * effects off on Windows, the GTK/desktop animation setting on Linux; pages see it as + * `prefers-reduced-motion: reduce`) or when Orbit's own setting forces it (`settings.reduceMotion`). + * + * Every renderer follows both from start-up (`installReducedMotion`, called by main.tsx): scripts + * ask `reducedMotion()` or `useReducedMotion()`, and CSS keys its reduced rules off the + * `reduce-motion` class this puts on the root element, never off the media query alone. + */ +const query = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)') : undefined +let forced = false +let current = !!query?.matches +const listeners = new Set<() => void>() + +/** Reduced motion is on now. */ +export function reducedMotion(): boolean { + return forced || !!query?.matches +} + +/** Calls `fn` whenever reduced motion turns on or off. Returns the unsubscribe. */ +export function onReducedMotion(fn: () => void): () => void { + listeners.add(fn) + return () => { + listeners.delete(fn) + } +} + +/** Reduced motion, re-rendering when it changes (the OS setting or Orbit's). */ +export function useReducedMotion(): boolean { + return useSyncExternalStore(onReducedMotion, reducedMotion) +} + +function refresh(): void { + const now = reducedMotion() + document.documentElement.classList.toggle('reduce-motion', now) + if (now === current) return + current = now + for (const fn of listeners) fn() +} + +let installed = false + +/** Follows the OS setting and `settings.reduceMotion` in this renderer. Idempotent. */ +export function installReducedMotion(): void { + if (installed) return + installed = true + query?.addEventListener('change', refresh) + const read = () => !!useMirror.getState().state?.settings?.reduceMotion + forced = read() + useMirror.subscribe(() => { + const next = read() + if (next === forced) return + forced = next + refresh() + }) + refresh() +} diff --git a/src/renderer/src/motion/tokens.ts b/src/renderer/src/motion/tokens.ts new file mode 100644 index 00000000..56a52802 --- /dev/null +++ b/src/renderer/src/motion/tokens.ts @@ -0,0 +1,2 @@ +/** The motion tokens (FL-01), shared with main: see `src/shared/motion.ts`. */ +export * from '../../../shared/motion' diff --git a/src/renderer/src/onboarding/onboarding.css b/src/renderer/src/onboarding/onboarding.css index e62120a9..7bf0910f 100644 --- a/src/renderer/src/onboarding/onboarding.css +++ b/src/renderer/src/onboarding/onboarding.css @@ -635,22 +635,19 @@ } } -@media (prefers-reduced-motion: reduce) { - .ob-step, - .ob-logo, - .ob-welcome .ob-title, - .ob-welcome .ob-lead, - .ob-welcome .ob-note, - .ob-welcome .ob-actions { - animation: none; - } - - .ob-progress__dot, - .ob-button, - .ob-app, - .ob-app__check { - transition: none; - } +.reduce-motion .ob-step, +.reduce-motion .ob-logo, +.reduce-motion .ob-welcome .ob-title, +.reduce-motion .ob-welcome .ob-lead, +.reduce-motion .ob-welcome .ob-note, +.reduce-motion .ob-welcome .ob-actions { + animation: none; +} +.reduce-motion .ob-progress__dot, +.reduce-motion .ob-button, +.reduce-motion .ob-app, +.reduce-motion .ob-app__check { + transition: none; } html[data-theme-fade] .ob, diff --git a/src/renderer/src/overlay/CommandBar.tsx b/src/renderer/src/overlay/CommandBar.tsx index b6e590fe..e01daae9 100644 --- a/src/renderer/src/overlay/CommandBar.tsx +++ b/src/renderer/src/overlay/CommandBar.tsx @@ -9,7 +9,6 @@ import { useLayoutEffect, useRef, useState, - useSyncExternalStore, } from 'react' import { flushSync } from 'react-dom' import { @@ -22,6 +21,7 @@ import { } from '../../../shared/commands/commandbar' import type { CommandBarMode } from '../../../shared/state' import { invoke, isMac, perfMark } from '../api' +import { EASE, EXIT_FACTOR, ONE_FRAME_S, useReducedMotion } from '../motion' import { activeTabId } from '../selectors' import { getOrbitState, useOrbit } from '../store' import { Favicon } from '../ui/Favicon' @@ -84,30 +84,24 @@ function useMaxRows(): number { return rows } +/** + * FL-16's own timing, between the quick and snappy tokens: in over 150 ms on the quick curve, with + * no overshoot; out in 0.7 × that (105 ms), and in 80 ms once a result is chosen. The entrance + * starts one frame in: the key was pressed a frame before the bar could mount, and a fade from 0 + * would leave the bar's first frame empty (FL-29: visible in the frame after the key). With reduced + * motion the same timing only fades (FL-28). + */ +const BAR_MS = 150 +const BAR_IN = { duration: BAR_MS / 1000, ease: EASE.quick, delay: -ONE_FRAME_S } +const BAR_OUT = { duration: (BAR_MS * EXIT_FACTOR) / 1000, ease: EASE.quick } +const BAR_CHOSEN = { duration: 0.08, ease: EASE.quick } + /** * The command bar (CB-01…CB-23): one field and one ranked result list. Ranking * runs in the main process per keystroke; this component keeps the field * responsive (native echo, inline completion re-applied synchronously) and only * swaps rows whose content changed. */ -const REDUCED_MOTION = '(prefers-reduced-motion: reduce)' - -/** - * The OS "reduce motion" setting as it is at render time. motion's own hook takes a change only - * from the media query's change event, which arrives at the next rendering step, so a bar opened - * right after the setting changed could still scale in (FL-28). - */ -function useReducedMotionNow(): boolean { - return useSyncExternalStore( - (onChange) => { - const query = matchMedia(REDUCED_MOTION) - query.addEventListener('change', onChange) - return () => query.removeEventListener('change', onChange) - }, - () => matchMedia(REDUCED_MOTION).matches, - ) -} - export function CommandBar({ mode, initialQuery, @@ -127,7 +121,7 @@ export function CommandBar({ const maxRows = useMaxRows() const present = useIsPresent() // FL-28: with reduced motion the bar only fades (no scale or drop, not even for one frame). - const reduced = useReducedMotionNow() + const reduced = useReducedMotion() const spaceId = useOrbit((s) => s.windows[window.orbit.windowId]?.spaceId) const suggestOn = useOrbit((s) => !!s.settings.searchSuggestions) const currentFavicon = useOrbit((s) => { @@ -486,8 +480,8 @@ export function CommandBar({ className="cmdbar-scrim" aria-hidden initial={{ opacity: 0 }} - animate={{ opacity: 1, transition: { duration: 0.14 } }} - exit={{ opacity: 0, transition: { duration: 0.1 } }} + animate={{ opacity: 1, transition: BAR_IN }} + exit={{ opacity: 0, transition: BAR_OUT }} />
diff --git a/src/renderer/src/overlay/OverlayApp.tsx b/src/renderer/src/overlay/OverlayApp.tsx index 5fc11de2..5a4f4484 100644 --- a/src/renderer/src/overlay/OverlayApp.tsx +++ b/src/renderer/src/overlay/OverlayApp.tsx @@ -1,10 +1,11 @@ -import { AnimatePresence, domAnimation, LazyMotion, MotionConfig } from 'motion/react' +import { AnimatePresence, domAnimation, LazyMotion } from 'motion/react' import { type CSSProperties, useEffect, useState } from 'react' import { DOWNLOADS_FLYOUT_PANEL } from '../../../shared/commands/downloads' import { SHORTCUT_SHEET } from '../../../shared/commands/keys' import { invoke } from '../api' import { DownloadsFlyout } from '../downloads/DownloadsFlyout' import { ShortcutSheet } from '../keys/ShortcutSheet' +import { OrbitMotionConfig } from '../motion' import { isSpacePanel, SpacePanels } from '../shell/spaces/SpacePanels' import { useWindow } from '../store' import { paletteVars, usePalette } from '../theme' @@ -30,7 +31,7 @@ export function OverlayApp() { }, []) return ( - +
-
+
) } diff --git a/src/renderer/src/overlay/PeekLayer.tsx b/src/renderer/src/overlay/PeekLayer.tsx index 365df8b8..637d6898 100644 --- a/src/renderer/src/overlay/PeekLayer.tsx +++ b/src/renderer/src/overlay/PeekLayer.tsx @@ -24,13 +24,14 @@ import { import type { OverlayState } from '../../../shared/state' import { invoke, perfMark } from '../api' import { useKeymap } from '../keys/live' +import { REDUCED, reducedMotion } from '../motion' import './peek.css' type PeekOverlay = Extract type Phase = NonNullable | 'open' -/** Reduced motion: the card fades in and out in place. */ -const FADE_MS = 130 +/** Reduced motion: the card fades in and out in place (FL-28). */ +const FADE_MS = REDUCED.fadeMs const BUTTON = 28 const BUTTON_GAP = 8 /** The grab handle above the card (PK-06): its hit area, centred over the card's top edge. */ @@ -39,8 +40,6 @@ const HANDLE_HIT_H = 30 /** A trackpad swipe has ended when no wheel event came for this long. */ const WHEEL_END_MS = 120 -const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)') - /** One drawn frame of the card: where it is, its corners, and how visible the chrome and dim are. */ interface Frame { rect: Box @@ -141,7 +140,7 @@ class PeekAnimator { this.stop() const a = this.area() const card = peekCard(a) - const reduced = reduceMotion.matches + const reduced = reducedMotion() const settled = this.settler() if (phase === 'open') { @@ -220,7 +219,7 @@ class PeekAnimator { page: true, } const settled = this.settler() - if (reduceMotion.matches) { + if (reducedMotion()) { this.tween( FADE_MS, (t) => this.draw({ rect: start.rect, radius: start.radius, dim: start.dim * (1 - t), chrome: 0, page: false }), @@ -318,7 +317,7 @@ class PeekAnimator { page: true, }) } - if (reduceMotion.matches) { + if (reducedMotion()) { this.tween(FADE_MS, step) return } @@ -355,7 +354,10 @@ class PeekAnimator { d.wheelTimer = window.setTimeout(() => this.drag === d && this.endDrag(t), WHEEL_END_MS) } - /** Runs a unit spring (0 → 1) frame by frame; `done` fires once it rests. */ + /** + * Runs a unit spring (0 → 1) frame by frame; `done` fires once it rests. Its clock starts now, so + * the first frame after the change already moves (FL-29), as a new phase must. + */ private spring( cfg: Spring, step: (p: number, elapsed: number) => void, @@ -364,16 +366,16 @@ class PeekAnimator { ): void { let x = 0 let v = initialVelocity - let last = 0 - let first = 0 + const first = performance.now() + let last = first const tick = (now: number) => { - if (!first) first = last = now + // A frame's time can be a little before the call that started the spring. const next = advanceSpring(cfg, x, v, (now - last) / 1000) x = next.x v = next.v - last = now + last = Math.max(last, now) const resting = springResting(x, v) - step(resting ? 1 : x, now - first) + step(resting ? 1 : x, Math.max(0, now - first)) if (resting) { this.raf = 0 done?.() @@ -382,12 +384,11 @@ class PeekAnimator { this.raf = requestAnimationFrame(tick) } - /** A timed ease-out (exits are quicker than entrances, FL-01). */ + /** A timed ease-out (exits are quicker than entrances, FL-01). Its clock starts now, as the spring's does. */ private tween(ms: number, step: (e: number) => void, done?: () => void): void { - let first = 0 + const first = performance.now() const tick = (now: number) => { - if (!first) first = now - const t = Math.min(1, (now - first) / ms) + const t = Math.min(1, Math.max(0, now - first) / ms) step(1 - (1 - t) ** 3) if (t >= 1) { this.raf = 0 diff --git a/src/renderer/src/overlay/find/find.css b/src/renderer/src/overlay/find/find.css index 566b353e..3589222e 100644 --- a/src/renderer/src/overlay/find/find.css +++ b/src/renderer/src/overlay/find/find.css @@ -161,9 +161,7 @@ html[data-view="tools"] body { background: var(--tools-line); } -@media (prefers-reduced-motion: reduce) { - .find-bar, - .zoom-pill { - animation-name: none; - } +.reduce-motion .find-bar, +.reduce-motion .zoom-pill { + animation-name: none; } diff --git a/src/renderer/src/overlay/peek.css b/src/renderer/src/overlay/peek.css index ff52a976..e8cca006 100644 --- a/src/renderer/src/overlay/peek.css +++ b/src/renderer/src/overlay/peek.css @@ -133,8 +133,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .peek__button { - transition: none; - } +.reduce-motion .peek__button { + transition: none; } diff --git a/src/renderer/src/overlay/site/site.css b/src/renderer/src/overlay/site/site.css index 2ca4d7e3..7dddf86e 100644 --- a/src/renderer/src/overlay/site/site.css +++ b/src/renderer/src/overlay/site/site.css @@ -270,19 +270,17 @@ border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); - transition: transform 180ms cubic-bezier(0.3, 1.3, 0.6, 1); + transition: transform 180ms var(--ease-pop); } .site-switch.is-on .site-switch__knob { transform: translateX(12px); } -@media (prefers-reduced-motion: reduce) { - .site-panel { - animation: none; - } - .site-switch, - .site-switch__knob { - transition: none; - } +.reduce-motion .site-panel { + animation: none; +} +.reduce-motion .site-switch, +.reduce-motion .site-switch__knob { + transition: none; } diff --git a/src/renderer/src/pip/pip.css b/src/renderer/src/pip/pip.css index e651d6ac..bb233d90 100644 --- a/src/renderer/src/pip/pip.css +++ b/src/renderer/src/pip/pip.css @@ -26,7 +26,7 @@ html[data-view="pip"] body { .pip__middle, .pip__bottom { opacity: 0; - transition: opacity 140ms cubic-bezier(0.2, 0, 0, 1); + transition: opacity 140ms var(--ease-quick); } .pip.is-hover .pip__shade, @@ -262,10 +262,8 @@ html[data-still] .pip { cursor: nwse-resize; } -@media (prefers-reduced-motion: reduce) { - .pip__btn, - .pip__progress::before, - .pip__progress-fill { - transition: none; - } +.reduce-motion .pip__btn, +.reduce-motion .pip__progress::before, +.reduce-motion .pip__progress-fill { + transition: none; } diff --git a/src/renderer/src/settings/sections/appearance.settings.tsx b/src/renderer/src/settings/sections/appearance.settings.tsx index af53157e..dad5ac4e 100644 --- a/src/renderer/src/settings/sections/appearance.settings.tsx +++ b/src/renderer/src/settings/sections/appearance.settings.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import type { ColorMode } from '../../../../shared/state' import { invoke } from '../../api' import { useOrbit } from '../../store' -import { Segmented, SettingGroup, SettingRow } from '../controls' +import { Segmented, SettingGroup, SettingRow, Switch } from '../controls' import type { SettingsSection } from '../registry' const MODES: { value: ColorMode; label: string; icon: ReactNode }[] = [ @@ -14,22 +14,40 @@ const MODES: { value: ColorMode; label: string; icon: ReactNode }[] = [ function AppearancePage() { const appearance = useOrbit((s) => s.settings.appearance) + const reduceMotion = useOrbit((s) => !!s.settings.reduceMotion) return ( - - - + + void invoke('settings.update', { appearance: v })} - /> - - + description="Light, dark, or the system's setting, for every window and Space. Each Space's colours have a light and a dark version. If pages stay dark in Light mode, a dark-mode extension may be forcing them." + > + void invoke('settings.update', { appearance: v })} + /> + + + {/* FL-28, AP-17: off follows the system's reduce-motion setting; on forces it. */} + + + void invoke('settings.update', { reduceMotion: v })} + /> + + + ) } @@ -39,5 +57,5 @@ export default { icon: Palette, order: 30, component: AppearancePage, - keywords: ['theme', 'dark mode', 'light mode', 'colour', 'color', 'system'], + keywords: ['theme', 'dark mode', 'light mode', 'colour', 'color', 'system', 'reduce motion', 'animations'], } satisfies SettingsSection diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css index 0b6758a1..8c877c6d 100644 --- a/src/renderer/src/settings/settings.css +++ b/src/renderer/src/settings/settings.css @@ -531,11 +531,9 @@ html[data-platform="darwin"] .settings__page { outline: none; } -@media (prefers-reduced-motion: reduce) { - .settings *, - html[data-theme-fade] .settings * { - transition: none !important; - } +.reduce-motion .settings *, +html.reduce-motion[data-theme-fade] .settings * { + transition: none !important; } /* Visually hidden, read by screen readers. */ @@ -735,10 +733,8 @@ html[data-platform="darwin"] .settings__page { } } -@media (prefers-reduced-motion: reduce) { - .st-dialog[open] { - animation: none; - } +.reduce-motion .st-dialog[open] { + animation: none; } .st-dialog__title { @@ -898,10 +894,8 @@ html[data-platform="darwin"] .settings__page { } } -@media (prefers-reduced-motion: reduce) { - .st-spin { - animation: none; - } +.reduce-motion .st-spin { + animation: none; } /* About (ST-17, UP-07): Orbit's logo over the version. */ diff --git a/src/renderer/src/settings/update-banner.css b/src/renderer/src/settings/update-banner.css index d98ebe73..9f4f7f62 100644 --- a/src/renderer/src/settings/update-banner.css +++ b/src/renderer/src/settings/update-banner.css @@ -149,12 +149,9 @@ } } -@media (prefers-reduced-motion: reduce) { - .upd { - animation: none; - } - - .upd__more { - transition: none; - } +.reduce-motion .upd { + animation: none; +} +.reduce-motion .upd__more { + transition: none; } diff --git a/src/renderer/src/shell/ShellApp.tsx b/src/renderer/src/shell/ShellApp.tsx index ae4ffe1b..172daeec 100644 --- a/src/renderer/src/shell/ShellApp.tsx +++ b/src/renderer/src/shell/ShellApp.tsx @@ -1,4 +1,5 @@ import type { CSSProperties } from 'react' +import { OrbitMotionConfig } from '../motion' import { useWindow } from '../store' import { useShellTheme } from '../theme' import { ContentArea } from './ContentArea' @@ -27,7 +28,10 @@ export function ShellApp() { {hidden && win.kind !== 'mini' && } - + {/* Outside the sidebar's lists: the toasts' motion follows reduced motion here (FL-28). */} + + +
) } diff --git a/src/renderer/src/shell/Toasts.tsx b/src/renderer/src/shell/Toasts.tsx index eda21552..cc696665 100644 --- a/src/renderer/src/shell/Toasts.tsx +++ b/src/renderer/src/shell/Toasts.tsx @@ -1,6 +1,8 @@ -import { useEffect, useState } from 'react' +import { AnimatePresence, motion } from 'motion/react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { CommandName } from '../../../shared/rpc' import { invoke } from '../api' +import { EASE, POP, SNAPPY, tween, useReducedMotion } from '../motion' interface Toast { id: number @@ -11,41 +13,110 @@ interface Toast { let nextId = 1 +/** FL-22: a toast stays about 3–4 s; one with a button stays long enough to use it. */ +const STAY_MS = 3000 +const STAY_WITH_ACTION_MS = 4000 +/** FL-22: after the pointer leaves a hovered toast, it stays about 1 s more. */ +const AFTER_HOVER_MS = 1000 +/** FL-22: at most three stack. */ +const MAX_TOASTS = 3 + +/** + * FL-22's motion: in, a toast rises about 12 px, fades in and grows from 0.96 with the pop spring; + * out, it fades and drops about 8 px (about 160 ms). A new toast pushes the older ones up with the + * snappy spring. + * + * With reduced motion (FL-28) a toast only fades, in and out, where it rests, and the push is at + * once. Its start and end hold no move at all: motion jumps a reduced move to its end in a frame of + * its own, so a toast could show 12 px low in its first frame, and drop 8 px as it starts to leave. + * ShellApp also renders the toasts under OrbitMotionConfig. + */ +const ENTER = { opacity: 0, y: 12, scale: 0.96 } +const SHOWN = { opacity: 1, y: 0, scale: 1, transition: { ...POP, opacity: tween('quick') } } +const LEAVE = { opacity: 0, y: 8, scale: 1, transition: { duration: 0.16, ease: EASE.quick } } +const FADE_IN = { opacity: 0, y: 0, scale: 1 } +const FADE_OUT = { opacity: 0, y: 0, scale: 1, transition: { duration: 0.16, ease: EASE.quick } } + /** Short confirmations from the main process (e.g. "Copied URL"), shown at the bottom of the sidebar. */ export function Toasts() { const [toasts, setToasts] = useState([]) + const timers = useRef(new Map>()) + const hovered = useRef(null) + const reduced = useReducedMotion() + const dismiss = useCallback((id: number) => { + clearTimeout(timers.current.get(id)) + timers.current.delete(id) + setToasts((list) => list.filter((t) => t.id !== id)) + }, []) + // A hovered toast waits: it leaves once the pointer has left it (FL-22). + const leaveAfter = useCallback( + (id: number, ms: number) => { + clearTimeout(timers.current.get(id)) + timers.current.set( + id, + setTimeout(() => { + if (hovered.current !== id) dismiss(id) + }, ms), + ) + }, + [dismiss], + ) useEffect( () => window.orbit.on('toast', (payload) => { const { message, action } = payload as { message: string; action?: Toast['action'] } const id = nextId++ - setToasts((list) => [...list.slice(-2), { id, message, ...(action ? { action } : {}) }]) - // One with a button stays long enough to use it (FL-22: about 3–4 s). - setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), action ? 4000 : 2200) + setToasts((list) => [...list.slice(-(MAX_TOASTS - 1)), { id, message, ...(action ? { action } : {}) }]) + leaveAfter(id, action ? STAY_WITH_ACTION_MS : STAY_MS) }), - [], + [leaveAfter], ) + useEffect(() => { + const all = timers.current + return () => { + for (const timer of all.values()) clearTimeout(timer) + } + }, []) return (
- {toasts.map((t) => ( -
- {t.message} - {t.action && ( - - )} -
- ))} + + {toasts.map((t) => ( + { + hovered.current = t.id + }} + onPointerLeave={() => { + if (hovered.current === t.id) hovered.current = null + leaveAfter(t.id, AFTER_HOVER_MS) + }} + > + {t.message} + {t.action && ( + + )} + + ))} +
) } diff --git a/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx b/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx index f61ea5a6..88c1c827 100644 --- a/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx +++ b/src/renderer/src/shell/chrome/FloatingSidebarApp.tsx @@ -1,10 +1,25 @@ -import { type CSSProperties, useEffect } from 'react' +import { animate, type MotionValue, motion, useMotionValue } from 'motion/react' +import { type CSSProperties, useEffect, useLayoutEffect } from 'react' import { invoke } from '../../api' +import { EASE, ONE_FRAME_S, SNAPPY, SNAPPY_EXIT, tween, useReducedMotion } from '../../motion' import { useWindow } from '../../store' import { useShellTheme } from '../../theme' import { Sidebar } from '../sidebar/Sidebar' import './chrome.css' +/** The window's left edge band that brings the card (FL-03: 4–8 px), as wide as the shell's edge zone. */ +const EDGE_PX = 6 +/** The card's margin from the window's edges with the sidebar hidden (`--gutter-hidden`). */ +const GUTTER = 6 +/** Room for the card's shadow: the card slides this much further than its own width plus margin. */ +const SHADOW_ROOM = 16 +/** + * FL-03: from rest, in with the snappy timing and no overshoot, already a frame along in the first + * frame so it moves at once; out with the quick curve in about 160 ms (FL-03's own 150–200 ms). + */ +const SLIDE_IN = tween('snappy', { delay: -ONE_FRAME_S }) +const SLIDE_OUT = { duration: 0.16, ease: EASE.quick } + /** * The hidden sidebar's floating card (SB-12, FL-03). Pages are native views above the shell, so the * card lives in a transparent view of its own at the window's left edge, which main places while the @@ -14,10 +29,13 @@ import './chrome.css' export function FloatingSidebarApp() { const win = useWindow() const theme = useShellTheme(win?.spaceId) - usePointerReports(!!win?.sidebar.peeking) - if (!win) return null + const peeking = !!win?.sidebar.peeking + usePointerReports(peeking) // Docking from here (⌘S): the card stays put while the docked sidebar slides in beneath it. - const out = win.sidebar.peeking || win.sidebar.visible + const out = !!win && (win.sidebar.peeking || win.sidebar.visible) + useEdgeWhileLeaving(!!win && !out) + const x = useSlide(out, win ? -(win.sidebar.width + GUTTER + SHADOW_ROOM) : undefined) + if (!win) return null const style = { '--sidebar-w': `${win.sidebar.width}px` } as CSSProperties return (
-
+
@@ -42,11 +60,61 @@ export function FloatingSidebarApp() {
-
+
) } +/** + * The card's slide: to 0 while it is out, to `hiddenX` (off the window's left edge) while it is not. + * From rest it takes FL-03's timing curves. Turned round mid-way (the pointer back at the edge as it + * leaves, or gone as it arrives), it takes the snappy spring, or its exit, from where the card is and + * how fast it is going (FL-01: interruptions carry position and velocity). Under reduced motion it + * moves at once (FL-28). + */ +function useSlide(out: boolean, hiddenX: number | undefined): MotionValue { + const reduced = useReducedMotion() + // Far off the edge until the window's state arrives. + const x = useMotionValue(-10_000) + // Before the frame is drawn, so the card moves in the first frame after the state arrives. + useLayoutEffect(() => { + if (hiddenX === undefined) return + const moving = x.isAnimating() + // The first state: the card starts just off the edge (and slides in if it is out already). + if (!moving && x.get() < hiddenX) x.jump(hiddenX) + const target = out ? 0 : hiddenX + if (!moving && x.get() === target) return + if (reduced) { + x.jump(target) + return + } + animate(x, target, moving ? (out ? SNAPPY : SNAPPY_EXIT) : out ? SLIDE_IN : SLIDE_OUT) + }, [out, hiddenX, reduced, x]) + useEffect(() => () => x.stop(), [x]) + return x +} + +/** + * While the card slides away its view still covers the window's left edge, so the shell's edge zone + * can't hear a pointer coming back to it. The card listens there instead and brings itself back, + * turning round from where it is (FL-03: re-entering the edge while it slides out reverses it). + */ +function useEdgeWhileLeaving(leaving: boolean) { + useEffect(() => { + if (!leaving) return + let last = 0 + const onMove = (e: MouseEvent) => { + if (e.buttons || e.clientX >= EDGE_PX) return + const now = performance.now() + if (now - last < 100) return + last = now + void invoke('sidebar.reveal', {}) + } + document.addEventListener('mousemove', onMove, { passive: true }) + return () => document.removeEventListener('mousemove', onMove) + }, [leaving]) +} + /** How often a pointer moving over the card says so again (well inside main's grace delay). */ const REASSERT_MS = 250 diff --git a/src/renderer/src/shell/chrome/chrome.css b/src/renderer/src/shell/chrome/chrome.css index bfcdf187..9de5238a 100644 --- a/src/renderer/src/shell/chrome/chrome.css +++ b/src/renderer/src/shell/chrome/chrome.css @@ -9,7 +9,7 @@ transition: background var(--dur-fast) ease, color var(--dur-fast) ease, - transform 240ms cubic-bezier(0.3, 1.3, 0.6, 1); + transform 240ms var(--ease-pop); } .topbar .icon-button:active:not(:disabled) { @@ -82,7 +82,7 @@ background var(--dur-fast) ease, color var(--dur-fast) ease, opacity var(--dur-fast) ease, - transform 240ms cubic-bezier(0.3, 1.3, 0.6, 1); + transform 240ms var(--ease-pop); } .address__button:hover { @@ -109,7 +109,7 @@ /* Pop-ups the page tried to open without a click (NV-09): shown until the next page. */ .address__blocked { color: var(--text); - animation: address-blocked-in 220ms cubic-bezier(0.3, 1.3, 0.6, 1); + animation: address-blocked-in 220ms var(--ease-pop); } @keyframes address-blocked-in { @@ -119,10 +119,8 @@ } } -@media (prefers-reduced-motion: reduce) { - .address__blocked { - animation: none; - } +.reduce-motion .address__blocked { + animation: none; } /* Insecure pages: a clear warning before the host (no padlock on https). */ @@ -178,18 +176,10 @@ html[data-view="sidebar"] body { bottom: var(--gutter-hidden); left: var(--gutter-hidden); width: var(--sidebar-w); - /* Out: the quick timing (FL-01, FL-03). */ - transform: translateX(calc(-100% - var(--gutter-hidden) - 16px)); - transition: transform 160ms cubic-bezier(0.2, 0, 0, 1); + /* FloatingSidebarApp slides it (FL-03): off to the left by its width, the gutter and 16 px. */ will-change: transform; } -.float-sidebar.is-out .float-sidebar__slide { - transform: none; - /* In: the snappy timing, no overshoot (structure doesn't bounce). */ - transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - /* The shadow fades in with the slide. */ .float-sidebar__shadow { position: absolute; @@ -199,12 +189,12 @@ html[data-view="sidebar"] body { 0 0 0 0.5px rgba(0, 0, 0, 0.14), 0 6px 14px -2px rgba(0, 0, 0, 0.22); opacity: 0; - transition: opacity 160ms cubic-bezier(0.2, 0, 0, 1); + transition: opacity 160ms var(--ease-quick); } .float-sidebar.is-out .float-sidebar__shadow { opacity: 1; - transition: opacity 200ms cubic-bezier(0.2, 0.8, 0.2, 1); + transition: opacity 200ms var(--ease-snappy); } .float-sidebar__card { @@ -220,13 +210,9 @@ html[data-view="sidebar"] body { height: 100%; } -@media (prefers-reduced-motion: reduce) { - .float-sidebar__slide, - .float-sidebar__shadow, - .float-sidebar.is-out .float-sidebar__slide, - .float-sidebar.is-out .float-sidebar__shadow { - transition: none; - } +.reduce-motion .float-sidebar__shadow, +.reduce-motion .float-sidebar.is-out .float-sidebar__shadow { + transition: none; } /* ---------- Private window (SB-25, AP-05) ---------- */ diff --git a/src/renderer/src/shell/chrome/link-pill.css b/src/renderer/src/shell/chrome/link-pill.css index cf3db5aa..d64ab33e 100644 --- a/src/renderer/src/shell/chrome/link-pill.css +++ b/src/renderer/src/shell/chrome/link-pill.css @@ -50,11 +50,9 @@ html[data-view="link"] body { } } -@media (prefers-reduced-motion: reduce) { - .link-pill { - animation: none; - } - .link-pill-view.is-leaving .link-pill { - transition: none; - } +.reduce-motion .link-pill { + animation: none; +} +.reduce-motion .link-pill-view.is-leaving .link-pill { + transition: none; } diff --git a/src/renderer/src/shell/chrome/loading.css b/src/renderer/src/shell/chrome/loading.css index c7334de4..373caaf9 100644 --- a/src/renderer/src/shell/chrome/loading.css +++ b/src/renderer/src/shell/chrome/loading.css @@ -56,18 +56,15 @@ html[data-view="loading"] body { } /* Reduced motion: no sweep, a slow pulse of the whole pill (FL-28). */ -@media (prefers-reduced-motion: reduce) { - .loading-view__sweep { - width: 100%; - animation: loading-pulse 1.2s ease-in-out infinite alternate; +.reduce-motion .loading-view__sweep { + width: 100%; + animation: loading-pulse 1.2s ease-in-out infinite alternate; +} +@keyframes loading-pulse { + from { + opacity: 0.35; } - - @keyframes loading-pulse { - from { - opacity: 0.35; - } - to { - opacity: 1; - } + to { + opacity: 1; } } diff --git a/src/renderer/src/shell/chrome/press.ts b/src/renderer/src/shell/chrome/press.ts index 3ccc6d06..9663498a 100644 --- a/src/renderer/src/shell/chrome/press.ts +++ b/src/renderer/src/shell/chrome/press.ts @@ -1,16 +1,16 @@ -import { prefersReducedMotion } from '../../theme' +import { cubicBezier, EASE, reducedMotion } from '../../motion' -/** "snappy" curve from FL-01 (CSS fallback). */ -const SNAPPY_EASE = 'cubic-bezier(0.2, 0.8, 0.2, 1)' +const SNAPPY_EASE = cubicBezier(EASE.snappy) /** * A button glyph's small press animation (FL-15): Back nudges about 3 px left, Forward right - * (180 ms); Reload's glyph turns once (260 ms). Runs alongside the action, never before it, and a - * repeated press restarts it instead of queueing. Decorative, so off with reduced motion. + * (180 ms); Reload's glyph turns once (260 ms). FL-15's own timings, on the snappy curve. Runs + * alongside the action, never before it, and a repeated press restarts it instead of queueing. + * Decorative, so off with reduced motion. */ export function playPress(button: HTMLElement | null, kind: 'back' | 'forward' | 'reload'): void { const glyph = button?.querySelector('svg') - if (!glyph || prefersReducedMotion()) return + if (!glyph || reducedMotion()) return for (const running of glyph.getAnimations()) running.cancel() if (kind === 'reload') { glyph.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { diff --git a/src/renderer/src/shell/chrome/prompt.css b/src/renderer/src/shell/chrome/prompt.css index 3863a709..49f049a8 100644 --- a/src/renderer/src/shell/chrome/prompt.css +++ b/src/renderer/src/shell/chrome/prompt.css @@ -132,7 +132,7 @@ overflow-wrap: anywhere; transition: background var(--dur-fast, 120ms) ease, - transform 240ms cubic-bezier(0.3, 1.3, 0.6, 1); + transform 240ms var(--ease-pop); } .perm-prompt__actions:not(.is-stacked) .perm-prompt__choice { @@ -244,11 +244,9 @@ html[data-view="prompt"] body { transform-origin: 0 0; } -@media (prefers-reduced-motion: reduce) { - .perm-prompt { - animation: none; - } - .perm-prompt__choice { - transition: none; - } +.reduce-motion .perm-prompt { + animation: none; +} +.reduce-motion .perm-prompt__choice { + transition: none; } diff --git a/src/renderer/src/shell/media/MediaTray.tsx b/src/renderer/src/shell/media/MediaTray.tsx index b2bb7ea4..3cb9276b 100644 --- a/src/renderer/src/shell/media/MediaTray.tsx +++ b/src/renderer/src/shell/media/MediaTray.tsx @@ -1,5 +1,5 @@ import { Mic, MicOff, Pause, PictureInPicture2, Play, SkipBack, SkipForward, Video, VideoOff, X } from 'lucide-react' -import { AnimatePresence, motion, useReducedMotion } from 'motion/react' +import { AnimatePresence, motion } from 'motion/react' import { type CSSProperties, memo, @@ -13,9 +13,9 @@ import { import { inCall, mediaSubtitle, mediaTitle, trayTabIds } from '../../../../shared/media' import type { TabId, WindowId } from '../../../../shared/state' import { invoke } from '../../api' +import { SNAPPY, tween, useReducedMotion } from '../../motion' import { useOrbit, useOrbitShallow } from '../../store' import { Favicon } from '../../ui/Favicon' -import { QUICK_EASE, SNAPPY } from '../sidebar/motion-tokens' import './media.css' /** @@ -24,7 +24,7 @@ import './media.css' */ const toPlayer = new Set() -const FADE = { duration: 0.12, ease: QUICK_EASE } +const FADE = tween('quick') /** * The sidebar media tray (MD-09, MD-10, MD-14; sidebar.md SB-10): one controller per tab playing diff --git a/src/renderer/src/shell/media/media.css b/src/renderer/src/shell/media/media.css index b343f60e..7b3618c7 100644 --- a/src/renderer/src/shell/media/media.css +++ b/src/renderer/src/shell/media/media.css @@ -150,20 +150,18 @@ box-shadow: 0 0 0 1.5px #e5484d; } -@media (prefers-reduced-motion: reduce) { - .media-ctl__title.is-scrolling .media-ctl__title-inner { - animation: none; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: top; - } - .media-ctl__btn { - transition: none; - } - .media-ctl__btn:active { - transform: none; - } +.reduce-motion .media-ctl__title.is-scrolling .media-ctl__title-inner { + animation: none; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} +.reduce-motion .media-ctl__btn { + transition: none; +} +.reduce-motion .media-ctl__btn:active { + transform: none; } /* Camera / microphone in use: a pulsing red mark on the tab row (TB-15, MD-11). */ @@ -190,8 +188,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .capture-indicator { - animation: none; - } +.reduce-motion .capture-indicator { + animation: none; } diff --git a/src/renderer/src/shell/sidebar/DragGhost.tsx b/src/renderer/src/shell/sidebar/DragGhost.tsx index 3cf08a48..27ff4191 100644 --- a/src/renderer/src/shell/sidebar/DragGhost.tsx +++ b/src/renderer/src/shell/sidebar/DragGhost.tsx @@ -1,7 +1,9 @@ import { Folder } from 'lucide-react' import { motion } from 'motion/react' +import { createPortal } from 'react-dom' import { isFolderId, isTabId } from '../../../../shared/ids' import { rowTitle } from '../../../../shared/tabs' +import { AT_ONCE, tween, useReducedMotion } from '../../motion' import { useOrbit } from '../../store' import { Favicon } from '../../ui/Favicon' import { ghostOpacity, ghostScale, ghostX, ghostY } from './drag' @@ -12,6 +14,11 @@ const SHADOW_STACK = '0px 6px 16px rgba(0, 0, 0, 0.22), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.08), 3px 4px 0px -1px rgba(255, 255, 255, 0.7), 6px 8px 0px -2px rgba(255, 255, 255, 0.5)' const SHADOW_REST = '0px 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.06)' +/** FL-10: the row ⇄ tile morph takes about 200 ms (snappy); the shadow fades over about 150 ms. */ +const MORPH = { ...tween('snappy'), boxShadow: { duration: 0.15 } } +/** With reduced motion (FL-28) the ghost's size changes at once. */ +const MORPH_REDUCED = { ...MORPH, width: AT_ONCE, height: AT_ONCE } + /** * The lifted copy of the dragged row or tile that follows the pointer (TB-08, FL-10): * a slight scale-up and shadow, morphing into a tile over the Favorites grid. @@ -21,11 +28,18 @@ export function DragGhost() { const overFavorites = useDrag((s) => s.favIndex !== null) const outside = useDrag((s) => s.outside) const phase = useDrag((s) => s.phase) + // Reduced motion as it is now, also when it turns on during a drag (FL-28). + const reduced = useReducedMotion() if (!ghost) return null const first = ghost.ids[0] as string const asTile = ghost.source === 'favorites' || overFavorites const settling = phase === 'landing' || phase === 'cancelling' - return ( + // Drawn in the shell's root, which carries the Space theme, rather than here in the Space strip: + // the strip's track is a containing block for fixed elements (it is composited for swipes), so a + // ghost in it sat lower than the pointer by the strip's offset and slid with the Space pages + // (FL-10: the row stays exactly under the pointer, also while the Spaces slide). + const host = document.querySelector('.shell') ?? document.body + return createPortal( 1 ? ' drag-ghost--stack' : ''}${settling ? ' is-settling' : ''}`} data-testid="drag-ghost" @@ -39,11 +53,12 @@ export function DragGhost() { // The lifted shadow eases off while the row settles (FL-10). boxShadow: settling ? SHADOW_REST : ghost.ids.length > 1 ? SHADOW_STACK : SHADOW_LIFT, }} - transition={{ duration: 0.2, ease: [0.2, 0.8, 0.2, 1], boxShadow: { duration: 0.15 } }} + transition={reduced ? MORPH_REDUCED : MORPH} > {ghost.ids.length > 1 && {ghost.ids.length}} - + , + host, ) } diff --git a/src/renderer/src/shell/sidebar/Favorites.tsx b/src/renderer/src/shell/sidebar/Favorites.tsx index 58aa7ac8..aa1ffcfc 100644 --- a/src/renderer/src/shell/sidebar/Favorites.tsx +++ b/src/renderer/src/shell/sidebar/Favorites.tsx @@ -1,15 +1,15 @@ import { AnimatePresence, LayoutGroup, motion } from 'motion/react' -import { forwardRef, memo, useMemo, useState } from 'react' +import { forwardRef, memo, type Ref, useMemo, useState } from 'react' import { MAX_FAVORITES } from '../../../../shared/defaults' import { isSplitId, isTabId } from '../../../../shared/ids' import { isAwayFromPinned, tabTitle } from '../../../../shared/tabs' import { invoke, isMac } from '../../api' +import { AT_ONCE, POP, REDUCED_FADE, SNAPPY, useReducedMotion } from '../../motion' import { activeTabId } from '../../selectors' import { useOrbit } from '../../store' import { Favicon } from '../../ui/Favicon' import { SplitFavoriteTile } from '../split/SplitFavoriteTile' import { pressItem } from './drag' -import { POP, SNAPPY } from './motion-tokens' import { clearSelection, useDrag } from './ui-state' /** Stable empty list (a fresh [] per call would re-render forever). */ @@ -20,6 +20,14 @@ const MAX_COLUMNS = 4 const TILE_SPRING = SNAPPY +/** + * With reduced motion (FL-28) a tile only fades, in and out, where it rests and at its size; the + * grid re-flows at once and a press doesn't nudge it. Each tile and the grid ask + * `useReducedMotion()`, as motion reads OrbitMotionConfig only as an element mounts and the tiles stay + * on screen while the setting changes. + */ +const TILE_FADE = { opacity: 0, scale: 1 } + /** * The Favorites grid at the top of the sidebar (FV-01, FV-02), shared by every Space of * a profile. Tiles fill the width (up to 4 per row, fewer stretch), wrap beyond that, and @@ -30,6 +38,7 @@ export const Favorites = memo( const ids = useOrbit((s) => s.profiles[profileId]?.favorites ?? NO_ITEMS) const favIndex = useDrag((s) => s.favIndex) const hidden = useDrag((s) => s.hidden) + const reduced = useReducedMotion() // Tabs and split views (a Favorite split shows its panes' icons in one tile, SV-23). const favorites = useMemo(() => ids.filter((id) => isTabId(id) || isSplitId(id)), [ids]) const visible = favorites.filter((id) => !(hidden[id] && favIndex !== null)) @@ -56,12 +65,12 @@ export const Favorites = memo( ) : ( {visible.length === 0 && Drop to add to Favorites} @@ -74,23 +83,33 @@ export const Favorites = memo( }), ) -const FavoriteTile = memo(function FavoriteTile({ tabId }: { tabId: string }) { +/** + * `ref` reaches the tile's element so the grid's AnimatePresence can take a leaving tile out of the + * flow at its size (popLayout): it shrinks and fades where it was while the grid closes up (FL-20). + */ +const FavoriteTile = memo(function FavoriteTile({ tabId, ref }: { tabId: string; ref?: Ref }) { const tab = useOrbit((s) => s.tabs[tabId]) const active = useOrbit((s) => activeTabId(s) === tabId) const hidden = useDrag((s) => !!s.hidden[tabId]) // Tiles that arrive by drag appear at once under the ghost; others pop in (FL-20). const [instant] = useState(() => hidden) + const reduced = useReducedMotion() if (!tab) return null const away = isAwayFromPinned(tab) const title = tabTitle(tab) return (