From d46bdf3d77ad029b23dbbf9b11158af5efbd528b Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:31:14 +0000 Subject: [PATCH 1/5] fix(sidebar): a background tab's row glows once, as it first appears (FL-06, FL-05) The row kept `is-new-background` from its first render, and `row-glow` played on that class while the row was not selected. So the glow replayed each time the selection left the row, and whenever a reorder put the row's element back in the document; rows made in the background at launch kept the class for good. The glow is now played with the Web Animations API, once, as the row first mounts in this window: a row that remounts (another section, a folder opening, back near the visible band) is not a first appearance. A tab made selected never glows, a row selected while it glows shows its highlight in the same frame, and with reduced motion (Orbit's switch or the system) no row glows. The tint stays in CSS (`--row-glow`). feel-fix-2.spec.ts records every row's running animations each frame (frames.ts gains an `animations` option). Without the fix, the new row glowed 4 times, rows made before it 1-2 times more each, a row moved to Pinned as it glowed twice, and with reduced motion the glow played. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/renderer/src/shell/sidebar/ItemList.tsx | 53 +++++- src/renderer/src/styles/global.css | 16 +- tests/e2e/feel-fix-2.spec.ts | 191 ++++++++++++++++++++ tests/e2e/frames.ts | 23 +++ 6 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/feel-fix-2.spec.ts diff --git a/FEATURES.md b/FEATURES.md index 29f779fc..bde1d4bf 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -378,7 +378,7 @@ Spec: `spec/feel.md` | 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 | ✅ | ✅ 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-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; a background tab's row glows once (420 ms) as it first appears, and never again when it is selected, left, dragged past or moved (Web Animations, not a CSS animation that restarts); a tab made selected never glows, and none does with reduced motion | | 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; a tab whose page the engine lets go first (one that may warn, NV-24) hands over at once too, and shows again only if its page warns (D77 (4)); rows out of sight go without motion | diff --git a/docs/feature-status.json b/docs/feature-status.json index 467488ac..035cc9e5 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -348,7 +348,7 @@ "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-06": {"built": "yes", "note": "a new row grows with the snappy spring, its content fading in over the last 60 %; the rows below glide; a background tab's row glows once (420 ms) as it first appears, and never again when it is selected, left, dragged past or moved (Web Animations, not a CSS animation that restarts); a tab made selected never glows, and none does with reduced motion"}, "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; a tab whose page the engine lets go first (one that may warn, NV-24) hands over at once too, and shows again only if its page warns (D77 (4)); rows out of sight go without motion"}, diff --git a/src/renderer/src/shell/sidebar/ItemList.tsx b/src/renderer/src/shell/sidebar/ItemList.tsx index 73b369c0..008e6cc8 100644 --- a/src/renderer/src/shell/sidebar/ItemList.tsx +++ b/src/renderer/src/shell/sidebar/ItemList.tsx @@ -20,6 +20,7 @@ import { splitOfTab, tabsInItem, visibleTabOrder } from '../../../../shared/tree import { invoke, isMac } from '../../api' import { AT_ONCE, + cubicBezier, EASE, REDUCED, reducedMotion, @@ -646,6 +647,45 @@ function fadeHover(el: HTMLElement, entering: boolean) { hoverFades.set(el, fade) } +/** A tab made in the background glows if its row first appears within this long of it (ms). */ +const GLOW_WINDOW = 1500 +/** + * Tabs whose row has appeared in this window, while they are young enough to glow, by when each was + * made. A row mounts again when it moves to another section, its folder opens or it comes back near + * the visible band (FL-21): none of those is its first appearance. + */ +const appeared = new Map() +/** Each row's running glow. */ +const glows = new WeakMap() + +/** + * A tab made in the background glows as its row first appears, so the eye finds it (FL-06): the row + * tints with `--row-glow` and fades back over 420 ms, once. The Web Animations API plays it, so + * nothing plays it again: a CSS animation restarts whenever its row is put back in the document (a + * reorder moves rows) or its rule matches again (the row is deselected). A tab made selected never + * glows, and with reduced motion (FL-28) none does. Starts in the frame the row first shows. + */ +function glowOnce(el: HTMLElement, tabId: TabId, createdAt: number, active: boolean) { + const now = Date.now() + for (const [id, made] of appeared) if (now - made >= GLOW_WINDOW) appeared.delete(id) + if (now - createdAt >= GLOW_WINDOW || appeared.has(tabId)) return + appeared.set(tabId, createdAt) + if (active || reducedMotion()) return + // Up to the tint over the first 30 % and back, each with an ease-out, from and to what the row + // shows underneath (its hover, say): the keyframe at 0 carries no colour, only the rise's easing. + const out = cubicBezier(EASE.instant) + const glow = el.animate( + [ + { offset: 0, easing: out }, + { offset: 0.3, backgroundColor: 'var(--row-glow)', easing: out }, + ], + { duration: 420, id: 'row-glow' }, + ) + const start = document.timeline.currentTime + if (typeof start === 'number') glow.startTime = start + glows.set(el, glow) +} + function openContextMenu(e: ReactMouseEvent, id: ItemId) { e.preventDefault() const sel = useSelection.getState().ids @@ -737,13 +777,20 @@ export const TabRowBody = memo(function TabRowBody({ const renaming = useRename((s) => (s.id === tabId ? s.mode : null)) const menuOpen = useRowMenuOpen(tabId) const [iconHover, setIconHover] = useState(false) - // A tab that just opened in the background glows once so the eye finds it (FL-06). - const [fresh] = useState(() => !!tab && !active && Date.now() - tab.createdAt < 1500) const rowRef = useRef(null) + // A tab made in the background glows once, as its row first appears (FL-06): decided as it mounts. + // biome-ignore lint/correctness/useExhaustiveDependencies: once per row, with the tab as it mounts + useLayoutEffect(() => { + if (rowRef.current && tab) glowOnce(rowRef.current, tabId, tab.createdAt, active) + }, []) // Selected: the highlight shows in this frame, not after a hover fade still running over it (FL-05). useLayoutEffect(() => { if (active && rowRef.current) hoverFades.get(rowRef.current)?.cancel() }, [active]) + // Nor after the glow: a row selected while it glows shows its highlight at once (FL-05). + useLayoutEffect(() => { + if ((active || selected) && rowRef.current) glows.get(rowRef.current)?.cancel() + }, [active, selected]) if (!tab) return null const { prefix, title } = rowTitle(tab) const away = isAwayFromPinned(tab) @@ -758,7 +805,7 @@ export const TabRowBody = memo(function TabRowBody({ aria-posinset={index >= 0 ? index + 1 : undefined} aria-setsize={index >= 0 ? size : undefined} aria-label={prefix ? `${prefix} / ${title}` : title} - className={`tab-row${active ? ' is-active' : ''}${selected ? ' is-selected' : ''}${tab.asleep ? ' is-asleep' : ''}${tab.loading ? ' is-loading' : ''}${away ? ' is-away' : ''}${focused ? ' is-focused' : ''}${fresh ? ' is-new-background' : ''}${menuOpen ? ' is-menu-open' : ''}`} + className={`tab-row${active ? ' is-active' : ''}${selected ? ' is-selected' : ''}${tab.asleep ? ' is-asleep' : ''}${tab.loading ? ' is-loading' : ''}${away ? ' is-away' : ''}${focused ? ' is-focused' : ''}${menuOpen ? ' is-menu-open' : ''}`} style={{ paddingLeft: 8 + depth * INDENT }} data-testid="tab-row" data-tab-id={tabId} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index c0843e0c..04198919 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1377,15 +1377,13 @@ html.is-dragging-tab * { overflow: visible; } -/* A tab opened in the background glows briefly so the eye finds it (FL-06); selected, it shows at once. */ -.tab-row.is-new-background:not(.is-active) { - animation: row-glow 420ms ease-out; -} - -@keyframes row-glow { - 30% { - background: color-mix(in srgb, var(--accent) 22%, transparent); - } +/* + * A tab opened in the background glows once, as its row first appears, so the eye finds it (FL-06): + * the tint it rises to. ItemList plays it with the Web Animations API, so a row deselected, dragged + * or moved doesn't play it again, as a CSS animation would. + */ +.tab-row { + --row-glow: color-mix(in srgb, var(--accent) 22%, transparent); } .drag-ghost--tile { diff --git a/tests/e2e/feel-fix-2.spec.ts b/tests/e2e/feel-fix-2.spec.ts new file mode 100644 index 00000000..fadc4972 --- /dev/null +++ b/tests/e2e/feel-fix-2.spec.ts @@ -0,0 +1,191 @@ +/** + * Feel fixes 2 (D91): the gaps sign-off 5 held back in the sidebar, Spaces and profiles, each with a + * test that fails without its fix (D18). + */ +import { expect, type Page, test } from '@playwright/test' +import { type Recording, record, type Target } from './frames' +import { action, type Fixtures, launchOrbit, type Orbit, openViaCommandBar, startFixtures } from './helpers' +import { act, activeId, cmd, drag, newTab, row, rowIds, settled } from './tabs-support' + +let fx: Fixtures +let orbit: Orbit | undefined + +test.beforeAll(async () => { + fx = await startFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.afterEach(async () => { + await orbit?.app.close() + orbit = undefined +}) + +async function launch(): Promise { + orbit = await launchOrbit() + return orbit +} + +// ---------- FL-06: a background tab's row glows once, as it first appears ---------- + +/** Every tab row, keyed by its tab, with its running animations. */ +const ROWS: Record = { + rows: { selector: '[data-testid="tab-row"]', key: 'data-tab-id', animations: true, matches: ['.is-active'] }, +} +const GLOW = 'row-glow' + +/** The rows that run their glow now. */ +function glowingRows(shell: Page): Promise { + return shell.evaluate((name) => { + return [...document.querySelectorAll('[data-testid="tab-row"]')] + .filter((el) => + el + .getAnimations() + .some((a) => a.playState === 'running' && (a instanceof CSSAnimation ? a.animationName : a.id) === name), + ) + .map((el) => el.dataset.tabId ?? '') + }, GLOW) +} + +/** + * What a recording shows of one row's glow: the frames that draw the row, those in which it glows, + * and how many glows it played (each play has a start time of its own: a glow played again, or a CSS + * animation restarted, has a new one). + */ +function glowOf(rec: Recording, tabId: string) { + const drawn: number[] = [] + const glowing: number[] = [] + const starts = new Set() + let glowWhileSelected = 0 + for (const f of rec.frames) { + const box = f.lists.rows?.[tabId] + if (!box) continue + drawn.push(f.i) + const glows = (box.animations ?? []).filter((a) => a.name === GLOW) + if (!glows.length) continue + glowing.push(f.i) + for (const g of glows) starts.add(g.start) + if (box.matched?.includes('.is-active')) glowWhileSelected++ + } + // A glow seen before its first frame (no start time yet) and after it is one glow. + if (starts.size > 1) starts.delete(null) + return { drawn, glowing, plays: starts.size, glowWhileSelected } +} + +/** The tab row the first window shows selected, once main's state and the sidebar agree. */ +async function selected(o: Orbit, tabId: string): Promise { + await expect.poll(() => activeId(o.app)).toBe(tabId) + await expect(row(o.shell, tabId)).toHaveClass(/is-active/) +} + +test('[FL-06][FL-05][TB-04][TB-05][TB-08][SB-22] a background tab’s row glows once, as it first appears: selected, left, dragged past, left by the keyboard, by ⌃Tab or for a new tab, no row glows again, and a tab made selected never glows', async () => { + const o = await launch() + const { app, shell } = o + // A tab made selected, then three made in the background as Orbit starts: their glows play now. + const first = await newTab(app, fx.url('/page/First')) + const made: string[] = [] + for (const n of ['GlowA', 'GlowB', 'GlowC']) made.push(await newTab(app, fx.url(`/page/${n}`), { activate: false })) + const [a, b, c] = made as [string, string, string] + await expect.poll(() => rowIds(shell, 'today-list')).toEqual([c, b, a, first]) + await settled(shell) + await expect.poll(() => glowingRows(shell)).toEqual([]) + + const rec = await record(shell, ROWS, { maxMs: 90_000 }) + // A tab made in the background: its row glows as it appears. + const fresh = await newTab(app, fx.url('/page/Fresh'), { activate: false }) + await expect(row(shell, fresh)).toBeVisible() + await settled(shell) + await expect.poll(() => glowingRows(shell)).toEqual([]) + // TB-04: a click selects it, and another selects a row beside it. + await row(shell, fresh).click() + await selected(o, fresh) + await row(shell, a).click() + await selected(o, a) + // TB-08: a row dragged from the bottom to the top passes it and the rest (a reorder moves their rows). + const top = await row(shell, fresh).boundingBox() + if (!top) throw new Error('no row') + await drag(shell, row(shell, first), { x: top.x + top.width / 2, y: top.y + 4 }) + await expect.poll(() => rowIds(shell, 'today-list')).toEqual([first, fresh, c, b, a]) + await selected(o, first) + // SB-22: the keyboard in the sidebar moves down a row and Enter opens it. + await act(app, 'sidebar.focus') + await expect(row(shell, first)).toHaveClass(/is-focused/) + await shell.keyboard.press('ArrowDown') + await expect(row(shell, fresh)).toHaveClass(/is-focused/) + await shell.keyboard.press('Enter') + await selected(o, fresh) + // TB-05: a quick ⌃Tab goes back to the tab used before. + await cmd(app, 'tabs.focusPage', {}) + await expect + .poll(() => app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL())) + .toBe(fx.url('/page/Fresh')) + // Each key goes to whatever has the keyboard: from the first Tab on, the switcher's overlay. + await app.evaluate(({ webContents }) => { + const keys = [ + ['keyDown', 'Control', true], + ['keyDown', 'Tab', true], + ['keyUp', 'Tab', true], + ['keyUp', 'Control', false], + ] as const + for (const [type, keyCode, control] of keys) { + webContents.getFocusedWebContents()?.sendInputEvent({ type, keyCode, modifiers: control ? ['control'] : [] }) + } + }) + await selected(o, first) + // A new tab from the command bar: made selected, it doesn't glow, nor does the row it leaves. + await openViaCommandBar(o, fx.url('/page/Typed')) + await expect.poll(async () => (await activeId(app)) !== first).toBe(true) + const typed = (await activeId(app)) as string + await expect(row(shell, typed)).toHaveClass(/is-active/) + // FL-07: a background tab moved to Pinned as it glows doesn't glow again in its new place. + const moved = await newTab(app, fx.url('/page/Moved'), { activate: false }) + await expect(row(shell, moved)).toBeVisible() + await cmd(app, 'tabs.pin', { tabId: moved }) + await expect(shell.getByTestId('pinned-list').locator(`[data-tab-id="${moved}"]`)).toBeVisible() + // FL-05: a background tab pressed while it glows, as its row grows in, shows its highlight at once. + const pressed = await newTab(app, fx.url('/page/Pressed'), { activate: false }) + const slot = shell.locator(`[data-slot][data-key="${pressed}"]`) + await expect.poll(async () => (await slot.boundingBox())?.height ?? 0).toBeGreaterThan(3) + const box = await slot.boundingBox() + if (!box) throw new Error('no row') + // The row's top shows first, and the rows above it don't move. + await shell.mouse.click(box.x + box.width / 2, box.y + 2) + await selected(o, pressed) + await settled(shell) + await expect.poll(() => glowingRows(shell)).toEqual([]) + const frames = await rec.stop() + + const glow = (id: string) => glowOf(frames, id) + // The new background tab's row glowed, once, from the first frame that drew it. + const f = glow(fresh) + expect(f.drawn.length, 'the background tab’s row was drawn').toBeGreaterThan(0) + expect.soft(f.plays, 'its row glowed once').toBe(1) + expect.soft(f.glowing[0], 'from the first frame that drew it').toBe(f.drawn[0]) + // No row glowed again: not when it was left, dragged past, or left by the keyboard, ⌃Tab or a new tab. + for (const [name, id] of Object.entries({ first, a, b, c })) { + expect.soft(glow(id).plays, `${name}’s row, made before the recording, doesn’t glow again`).toBe(0) + } + expect.soft(glow(typed).plays, 'a tab made selected never glows').toBe(0) + expect.soft(glow(moved).plays, 'a row moved as it glows doesn’t glow again').toBe(1) + expect.soft(glow(pressed).plays, 'the pressed row glowed').toBe(1) + expect.soft(glow(pressed).glowWhileSelected, 'selected, it shows its highlight, not the glow').toBe(0) +}) + +for (const how of ['switch', 'system'] as const) { + test(`[FL-06][FL-28] reduced motion (${how === 'switch' ? 'Orbit’s switch' : 'the system setting'}): a background tab’s row appears without the glow`, async () => { + const o = await launch() + const { app, shell } = o + if (how === 'switch') await action(app, 'app.toggleReduceMotion') + else await shell.emulateMedia({ reducedMotion: 'reduce' }) + await expect(shell.locator('html')).toHaveClass(/\breduce-motion\b/) + const began = Date.now() + const rec = await record(shell, ROWS, { maxMs: 30_000 }) + const quiet = await newTab(app, fx.url('/page/Quiet'), { activate: false }) + await expect(row(shell, quiet)).toBeVisible() + // Longer than the glow would last after the row showed. + const frames = await rec.until(Date.now() - began + 800) + const g = glowOf(frames, quiet) + expect(g.drawn.length, 'the row was drawn').toBeGreaterThan(0) + expect(g.glowing, 'and never glowed').toEqual([]) + }) +} diff --git a/tests/e2e/frames.ts b/tests/e2e/frames.ts index 5993790b..4eeb6150 100644 --- a/tests/e2e/frames.ts +++ b/tests/e2e/frames.ts @@ -37,8 +37,20 @@ export type Target = text?: boolean /** Attributes to keep, e.g. `['aria-selected', 'data-kind']`. */ attrs?: string[] + /** Keep the element's own running animations (`getAnimations()`), each as an `Anim`. */ + animations?: boolean } +/** + * An animation running on an element in a frame: a CSS animation by its `animation-name`, a Web + * Animations API one by its `id`. `start` is its start time on the document timeline (null while it + * waits for its first frame): an animation that restarts, or plays again, has a new one. + */ +export interface Anim { + name: string + start: number | null +} + export interface Box { x: number y: number @@ -52,6 +64,7 @@ export interface Box { matched?: string[] text?: string attrs?: Record + animations?: Anim[] } export interface Frame { @@ -130,6 +143,7 @@ export async function record( matches?: string[] text?: boolean attrs?: string[] + animations?: boolean } type W = { __feelRec?: PageRecorder } const w = window as unknown as W @@ -156,6 +170,15 @@ export async function record( if (spec.matches) out.matched = spec.matches.filter((m) => el.matches(m)) if (spec.text) out.text = el instanceof HTMLInputElement ? el.value : (el.textContent ?? '') if (spec.attrs) out.attrs = Object.fromEntries(spec.attrs.map((a) => [a, el.getAttribute(a)])) + if (spec.animations) { + out.animations = el + .getAnimations() + .filter((a) => a.playState === 'running') + .map((a) => ({ + name: a instanceof CSSAnimation ? a.animationName : a.id, + start: typeof a.startTime === 'number' ? a.startTime : null, + })) + } if (spec.style) { const cs = getComputedStyle(el) out.style = Object.fromEntries(spec.style.map((p) => [p, cs.getPropertyValue(p)])) From 5574f09dbd3d00222f29eb38f7f4651ea558dc56 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:42:40 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(sidebar):=20Change=20Icon=E2=80=A6=20op?= =?UTF-8?q?ens=20the=20Space=20icon's=20emoji=20picker=20(TB-12,=20FD-07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon picker for a tab, folder, split or Favorite was a one-off grid of 48 emoji. It now hosts the Space icon's EmojiPicker (SP-05) in its six-column form, under the row: search, categories, skin tones and the emoji used last, shared with the Space picker through the same storage; an emoji typed or pasted is offered as it is; Remove icon (shown when there is one) brings back the favicon. The open/close motion (FL-25, FL-28), Escape and click-outside closing and the keys leaving its search at once are kept; it is placed with its measured height. The picker's search takes the keyboard in a layout effect, from the frame it first shows. Tests: feel-fix-2 TB-12 (tab: "sun", a skin tone and a hand, a typed emoji; folder; Favorite; Escape, click outside; Remove icon; the Space picker sees the same tone and recents), FL-25/FL-28 open and close; the specs that drove the old picker now use its search. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 4 +- docs/feature-status.json | 4 +- src/renderer/src/shell/sidebar/IconPicker.tsx | 145 +++---------- src/renderer/src/shell/spaces/EmojiPicker.tsx | 5 +- src/renderer/src/styles/global.css | 54 +---- tests/e2e/feel-fix-2.spec.ts | 204 +++++++++++++++++- tests/e2e/finish-tabs.spec.ts | 2 +- tests/e2e/finish-views.spec.ts | 3 +- tests/e2e/tabs-more.spec.ts | 6 +- 9 files changed, 253 insertions(+), 174 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index bde1d4bf..1fc7764f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -66,7 +66,7 @@ Spec: `spec/tabs.md` | TB-09 | Drag and drop to other destinations | P0 | 🟡 partial | ✅ e2e+unit | – | Space switcher drop, edge dwell to the next Space, drag out to a new window, Alt-drag copy; a row on the page's edge or centre makes a split (SV-04); over a place a page takes links (a text field or editable area in sight, in any pane) the row drops its URL there instead: the page gets the drag events (the link only in the drop: passing over, they list its types with empty values, as a system drag does) and may take it itself, else it is typed into the field, which gets the keyboard (its pane too); a field inside another site's frame, and a page's own drop area that is no field, aren't reached; not built: dropping into another window's sidebar, and into the Library's Spaces overview (SP-13) | | TB-10 | Duplicate a tab | P1 | ✅ | ✅ e2e+unit | – | menu/action Duplicate (selected, below the original) and Alt-drag copy, both with history; Cmd-click Reload is the top bar's | | TB-11 | Move a tab to another Space | P0 | ✅ | ✅ e2e | – | Move to > Space > Pinned/Today/folder (menu + tabs.moveToSpace); command-bar entry is the commandbar lane's | -| TB-12 | Rename a tab and set a custom icon | P1 | ✅ | ✅ e2e | – | double-click/menu rename, empty reverts; emoji icon picker | +| TB-12 | Rename a tab and set a custom icon | P1 | ✅ | ✅ e2e | – | double-click/menu rename, empty reverts; Change Icon… opens the Space icon's emoji picker (SP-05) under the row: search, categories, skin tones and the emoji used last, shared with the Space picker; an emoji typed or pasted is offered as it is; Remove icon brings back the favicon | | TB-13 | Multi-select tabs | P1 | ✅ | ✅ e2e | – | ⌘-click toggles a row and ⇧-click selects a range, with a shared highlight; Escape or a plain click clears; ⌘W closes them all, ⌘⇧C copies all their URLs, and the menu pins or unpins them, moves them to a Space, puts them in a new pinned folder or opens two to four as a split (more than four is refused); share is a later phase | | TB-14 | Tab context menu | P0 | ✅ | ✅ e2e+unit | – | Orbit's own menu in the overlay, in the Space's colours (SP-16), for tab, pinned, Favorite, folder, multi-selection and split rows (a split pane's menu over the page stays the system's): every item the spec lists (Share is a later phase there); arrows (round the ends), Home/End, type-ahead letters, → / ← and hover for submenus, Enter or Space chooses, Esc closes a level then the menu, a click outside closes it; each item shows its key from the live keymap (KS-18); it takes the keys first and a key bound to an Orbit action only closes it (KS-19), then the keyboard goes back where it was (KS-16), the sidebar for a menu opened there, however the menu closes (Esc, Enter, a click, a bound key, a click outside) and before a chosen item runs, so Rename… and Change Icon… (a tab’s or a split’s) can still take it; opened by the keyboard it sits under the row's slot, and its row stays drawn however far the list scrolls (FL-21). While a Peek is open the row menu is the system's: the Peek has the overlay then, and the sidebar must not close it (PK-13) | | TB-15 | Audio, mute, microphone/camera and screen-share indicators | P1 | ✅ | ✅ e2e+unit | – | the speaker icon on a row that sounds toggles mute and shows a muted speaker; Mute Tab and Unmute Tab are in every tab's menu, a silent one's too; a pulsing camera/microphone indicator and a distinct screen-share one (media lane, MD-11); a Favorite playing sound shows small notes rising over its tile, still under reduced motion (any site that sounds: Orbit can't tell music sites apart); media tabs are never auto-archived (TD-02) or put to sleep (TB-16) | @@ -110,7 +110,7 @@ Spec: `spec/tabs.md` | FD-04 | Collapse and expand folders | P0 | ✅ | ✅ e2e | – | click toggles with height reveal; selected child peeks under a closed folder | | FD-05 | Drag into and out of folders | P0 | ✅ | ✅ e2e+unit | – | drop into closed folder (no expand, peeks) or between children; folders drag/nest | | FD-06 | Nested folders | P1 | ✅ | ✅ e2e | – | nesting by drag or New Folder from children; 14px indent per level | -| FD-07 | Folder icon | P1 | ✅ | ✅ e2e | – | Change Icon picks an emoji; without one, a folder glyph drawn in full colour in the Space's accent, with a closed and an open version that cross-fade as the folder opens or closes (130 ms, --motion-quick; FL-12), which reduced motion keeps as it is a fade | +| FD-07 | Folder icon | P1 | ✅ | ✅ e2e | – | Change Icon… opens the Space icon's emoji picker (search, skin tones, the emoji used last, Remove icon); without one, a folder glyph drawn in full colour in the Space's accent, with a closed and an open version that cross-fade as the folder opens or closes (130 ms, --motion-quick; FL-12), which reduced motion keeps as it is a fade | | FD-08 | Folder hover preview and search | P1 | ✅ | ✅ e2e | – | magnifier on folder hover opens a search popover (custom names, first match selected, Enter opens and peeks) | | FD-09 | Folder context menu | P1 | ✅ | ✅ e2e+unit | – | Rename, Change Icon, Duplicate, Copy All Links, Paste URL as New Tab, Move to ▸ Space and Delete; the command bar finds a folder by its name or by "Open" and its name, and opens it expanded (CB-13); Share is a later phase | | FD-10 | Delete a folder | P1 | ✅ | ✅ e2e+unit | – | confirm when non-empty, contents to Archive, Cmd+Z restores all | diff --git a/docs/feature-status.json b/docs/feature-status.json index 035cc9e5..2843a769 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -31,7 +31,7 @@ "TB-09": {"built": "partial", "note": "Space switcher drop, edge dwell to the next Space, drag out to a new window, Alt-drag copy; a row on the page's edge or centre makes a split (SV-04); over a place a page takes links (a text field or editable area in sight, in any pane) the row drops its URL there instead: the page gets the drag events (the link only in the drop: passing over, they list its types with empty values, as a system drag does) and may take it itself, else it is typed into the field, which gets the keyboard (its pane too); a field inside another site's frame, and a page's own drop area that is no field, aren't reached; not built: dropping into another window's sidebar, and into the Library's Spaces overview (SP-13)"}, "TB-10": {"built": "yes", "note": "menu/action Duplicate (selected, below the original) and Alt-drag copy, both with history; Cmd-click Reload is the top bar's"}, "TB-11": {"built": "yes", "note": "Move to > Space > Pinned/Today/folder (menu + tabs.moveToSpace); command-bar entry is the commandbar lane's"}, - "TB-12": {"built": "yes", "note": "double-click/menu rename, empty reverts; emoji icon picker"}, + "TB-12": {"built": "yes", "note": "double-click/menu rename, empty reverts; Change Icon… opens the Space icon's emoji picker (SP-05) under the row: search, categories, skin tones and the emoji used last, shared with the Space picker; an emoji typed or pasted is offered as it is; Remove icon brings back the favicon"}, "TB-13": {"built": "yes", "note": "⌘-click toggles a row and ⇧-click selects a range, with a shared highlight; Escape or a plain click clears; ⌘W closes them all, ⌘⇧C copies all their URLs, and the menu pins or unpins them, moves them to a Space, puts them in a new pinned folder or opens two to four as a split (more than four is refused); share is a later phase"}, "TB-14": {"built": "yes", "note": "Orbit's own menu in the overlay, in the Space's colours (SP-16), for tab, pinned, Favorite, folder, multi-selection and split rows (a split pane's menu over the page stays the system's): every item the spec lists (Share is a later phase there); arrows (round the ends), Home/End, type-ahead letters, → / ← and hover for submenus, Enter or Space chooses, Esc closes a level then the menu, a click outside closes it; each item shows its key from the live keymap (KS-18); it takes the keys first and a key bound to an Orbit action only closes it (KS-19), then the keyboard goes back where it was (KS-16), the sidebar for a menu opened there, however the menu closes (Esc, Enter, a click, a bound key, a click outside) and before a chosen item runs, so Rename… and Change Icon… (a tab’s or a split’s) can still take it; opened by the keyboard it sits under the row's slot, and its row stays drawn however far the list scrolls (FL-21). While a Peek is open the row menu is the system's: the Peek has the overlay then, and the sidebar must not close it (PK-13)"}, "TB-15": {"built": "yes", "note": "the speaker icon on a row that sounds toggles mute and shows a muted speaker; Mute Tab and Unmute Tab are in every tab's menu, a silent one's too; a pulsing camera/microphone indicator and a distinct screen-share one (media lane, MD-11); a Favorite playing sound shows small notes rising over its tile, still under reduced motion (any site that sounds: Orbit can't tell music sites apart); media tabs are never auto-archived (TD-02) or put to sleep (TB-16)"}, @@ -77,7 +77,7 @@ "FD-04": {"built": "yes", "note": "click toggles with height reveal; selected child peeks under a closed folder"}, "FD-05": {"built": "yes", "note": "drop into closed folder (no expand, peeks) or between children; folders drag/nest"}, "FD-06": {"built": "yes", "note": "nesting by drag or New Folder from children; 14px indent per level"}, - "FD-07": {"built": "yes", "note": "Change Icon picks an emoji; without one, a folder glyph drawn in full colour in the Space's accent, with a closed and an open version that cross-fade as the folder opens or closes (130 ms, --motion-quick; FL-12), which reduced motion keeps as it is a fade"}, + "FD-07": {"built": "yes", "note": "Change Icon… opens the Space icon's emoji picker (search, skin tones, the emoji used last, Remove icon); without one, a folder glyph drawn in full colour in the Space's accent, with a closed and an open version that cross-fade as the folder opens or closes (130 ms, --motion-quick; FL-12), which reduced motion keeps as it is a fade"}, "FD-08": {"built": "yes", "note": "magnifier on folder hover opens a search popover (custom names, first match selected, Enter opens and peeks)"}, "FD-09": {"built": "yes", "note": "Rename, Change Icon, Duplicate, Copy All Links, Paste URL as New Tab, Move to ▸ Space and Delete; the command bar finds a folder by its name or by \"Open\" and its name, and opens it expanded (CB-13); Share is a later phase"}, "FD-10": {"built": "yes", "note": "confirm when non-empty, contents to Archive, Cmd+Z restores all"}, diff --git a/src/renderer/src/shell/sidebar/IconPicker.tsx b/src/renderer/src/shell/sidebar/IconPicker.tsx index 64bdf667..08585b98 100644 --- a/src/renderer/src/shell/sidebar/IconPicker.tsx +++ b/src/renderer/src/shell/sidebar/IconPicker.tsx @@ -3,108 +3,61 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { isFolderId, isSplitId } from '../../../../shared/ids' import { invoke } from '../../api' import { POPOVER, useReducedMotion } from '../../motion' +import { useOrbit } from '../../store' +import { EmojiPicker } from '../spaces/EmojiPicker' import { useIconPicker } from './ui-state' -/** A compact emoji set with search words (TB-12, FD-07). Any emoji can also be typed or pasted. */ -const EMOJI: [string, string][] = [ - ['⭐', 'star favorite'], - ['🔥', 'fire hot'], - ['📌', 'pin'], - ['📁', 'folder files'], - ['📚', 'books docs read'], - ['📝', 'note memo write'], - ['📅', 'calendar date'], - ['✉️', 'mail email'], - ['💬', 'chat message'], - ['🎵', 'music song'], - ['🎬', 'movie video film'], - ['📷', 'camera photo'], - ['🛒', 'shop cart buy'], - ['💰', 'money finance bank'], - ['📈', 'chart growth stats'], - ['🧪', 'test lab experiment'], - ['🐛', 'bug issue'], - ['🚀', 'rocket launch deploy'], - ['💡', 'idea light'], - ['🔧', 'tool settings wrench'], - ['🧠', 'brain think'], - ['🎨', 'art design paint'], - ['🏠', 'home house'], - ['💼', 'work job briefcase'], - ['✈️', 'travel plane flight'], - ['🍕', 'food pizza'], - ['☕', 'coffee'], - ['🎮', 'game play'], - ['⚽', 'sport ball football'], - ['🌍', 'world globe earth'], - ['🔒', 'lock secure private'], - ['🔑', 'key password'], - ['❤️', 'heart love'], - ['✅', 'done check todo'], - ['❗', 'important alert'], - ['🗂️', 'index archive'], - ['🧾', 'receipt invoice'], - ['📦', 'package box'], - ['🎓', 'school study learn'], - ['🐙', 'code repo octopus'], - ['🤖', 'robot ai bot'], - ['📰', 'news paper'], - ['🎧', 'podcast audio headphones'], - ['🌱', 'plant grow'], - ['🏃', 'run fitness'], - ['🧘', 'calm health'], - ['🐶', 'dog pet'], - ['🌙', 'moon night'], -] - -/** Emoji picker for a tab, folder or split icon (TB-12, FD-07, SV-22). Removing the icon restores the favicon. */ +/** + * Change Icon… on a tab, folder, split or Favorite (TB-12, FD-07, SV-22): the Space icon's emoji + * picker (SP-05) in its six-column form, under the row. Search, skin tones and the recently used + * emoji are the Space picker's own; an emoji typed or pasted into the search is offered as it is. + * Remove icon restores the favicon. + */ export function IconPicker() { const id = useIconPicker((s) => s.id) // FL-25: it closes with the quick timing (fade and back to 0.97). return {id && } } +const close = () => useIconPicker.setState({ id: null }) + function IconPickerPanel({ id }: { id: string }) { const present = useIsPresent() // With reduced motion it only fades, also when the setting changes while it is open (FL-28). const reduced = useReducedMotion() - const [query, setQuery] = useState('') + const icon = useOrbit((s) => + isFolderId(id) ? s.folders[id]?.icon : isSplitId(id) ? s.splits[id]?.icon : s.tabs[id]?.icon, + ) const [top, setTop] = useState(0) const ref = useRef(null) - const input = useRef(null) + // Under its row or Favorite, kept inside the sidebar. useLayoutEffect(() => { - if (!id) return - setQuery('') - const root = ref.current?.parentElement + const panel = ref.current + const root = panel?.parentElement const row = root?.querySelector(`[data-slot][data-key="${id}"], [data-fav-id="${id}"]`) - if (root && row) { - const r = row.getBoundingClientRect() - const p = root.getBoundingClientRect() - setTop(Math.max(4, Math.min(r.bottom - p.top + 4, p.height - 230))) - } - input.current?.focus() + if (!panel || !root || !row) return + const r = row.getBoundingClientRect() + const p = root.getBoundingClientRect() + setTop(Math.max(4, Math.min(r.bottom - p.top + 4, p.height - panel.offsetHeight - 4))) }, [id]) // Closing: it fades out, and keys go back to the sidebar at once. useEffect(() => { - if (!present) input.current?.blur() + const focused = document.activeElement + if (!present && focused instanceof HTMLElement && ref.current?.contains(focused)) focused.blur() }, [present]) useEffect(() => { - if (!id) return const onDown = (e: PointerEvent) => { - if (!ref.current?.contains(e.target as Node)) useIconPicker.setState({ id: null }) + if (!ref.current?.contains(e.target as Node)) close() } window.addEventListener('pointerdown', onDown, true) return () => window.removeEventListener('pointerdown', onDown, true) - }, [id]) - const set = (icon: string | null) => { - if (isFolderId(id)) void invoke('folders.setIcon', { folderId: id, icon }) - else if (isSplitId(id)) void invoke('split.setIcon', { splitId: id, icon }) - else void invoke('tabs.setIcon', { tabId: id, icon }) - useIconPicker.setState({ id: null }) + }, []) + const set = (value: string | null) => { + if (isFolderId(id)) void invoke('folders.setIcon', { folderId: id, icon: value }) + else if (isSplitId(id)) void invoke('split.setIcon', { splitId: id, icon: value }) + else void invoke('tabs.setIcon', { tabId: id, icon: value }) + close() } - const q = query.trim().toLowerCase() - const list = q ? EMOJI.filter(([e, words]) => words.includes(q) || e === q) : EMOJI - const typed = q && !/^[a-z0-9 ]+$/i.test(q) ? [...q][0] : undefined return ( { + if (e.key === 'Escape') close() + }} > - setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') useIconPicker.setState({ id: null }) - if (e.key === 'Enter') { - const pick = typed ?? list[0]?.[0] - if (pick) set(pick) - } - }} - /> -
- {typed && ( - - )} - {list.map(([emoji, words]) => ( - - ))} -
- + set(null) : undefined} />
) } diff --git a/src/renderer/src/shell/spaces/EmojiPicker.tsx b/src/renderer/src/shell/spaces/EmojiPicker.tsx index a93d5666..c70b99cc 100644 --- a/src/renderer/src/shell/spaces/EmojiPicker.tsx +++ b/src/renderer/src/shell/spaces/EmojiPicker.tsx @@ -1,5 +1,5 @@ import { Search, Trash2 } from 'lucide-react' -import { type CSSProperties, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { type CSSProperties, type KeyboardEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { EMOJI_CATEGORIES, type EmojiEntry, SKIN_TONES, withTone } from './emoji-data' import './emoji-picker.css' @@ -86,7 +86,8 @@ export function EmojiPicker({ onPick, onRemove, current, autoFocus, compact }: E const modifier = tone?.modifier ?? '' const currentKey = current ? baseKey(current) : '' - useEffect(() => { + // The search has the keyboard from the frame the picker first shows: what is typed finds the emoji. + useLayoutEffect(() => { if (autoFocus) inputRef.current?.focus() }, [autoFocus]) diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 04198919..d0959646 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1453,8 +1453,10 @@ html.is-dragging-tab * { } } -/* Emoji picker (TB-12, FD-07) */ +/* Change Icon…'s emoji picker (TB-12, FD-07): the Space picker's (SP-05), under the row. */ .icon-picker { + /* Its section labels stick over the grid on the picker's own colour. */ + --surface: color-mix(in srgb, var(--frame-top) 22%, white); position: absolute; left: 4px; right: 4px; @@ -1462,7 +1464,7 @@ html.is-dragging-tab * { padding: 8px; border-radius: 12px; color: var(--text); - background: color-mix(in srgb, var(--frame-top) 22%, white); + background: var(--surface); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2), 0 0 0 0.5px rgba(0, 0, 0, 0.12); @@ -1470,56 +1472,10 @@ html.is-dragging-tab * { @media (prefers-color-scheme: dark) { .icon-picker { - background: color-mix(in srgb, var(--frame-top) 35%, #1f1f24); + --surface: color-mix(in srgb, var(--frame-top) 35%, #1f1f24); } } -.icon-picker__search { - width: 100%; - height: 26px; - margin-bottom: 6px; - padding: 0 8px; - border: 0; - border-radius: 7px; - outline: none; - font: inherit; - color: inherit; - background: var(--hover); - user-select: text; -} - -.icon-picker__grid { - display: grid; - grid-template-columns: repeat(8, 1fr); - gap: 2px; - max-height: 150px; - overflow-y: auto; -} - -.icon-picker__emoji { - height: 26px; - border-radius: 6px; - font-size: 16px; -} - -.icon-picker__emoji:hover { - background: var(--hover); -} - -.icon-picker__remove { - width: 100%; - height: 24px; - margin-top: 6px; - border-radius: 6px; - font-size: 12px; - color: var(--text-muted); -} - -.icon-picker__remove:hover { - background: var(--hover); - color: var(--text); -} - /* Folder search (FD-08) */ .folder-row__search { flex: none; diff --git a/tests/e2e/feel-fix-2.spec.ts b/tests/e2e/feel-fix-2.spec.ts index fadc4972..f6e7e2de 100644 --- a/tests/e2e/feel-fix-2.spec.ts +++ b/tests/e2e/feel-fix-2.spec.ts @@ -2,10 +2,32 @@ * Feel fixes 2 (D91): the gaps sign-off 5 held back in the sidebar, Spaces and profiles, each with a * test that fails without its fix (D18). */ -import { expect, type Page, test } from '@playwright/test' +import { expect, type Locator, type Page, test } from '@playwright/test' import { type Recording, record, type Target } from './frames' -import { action, type Fixtures, launchOrbit, type Orbit, openViaCommandBar, startFixtures } from './helpers' -import { act, activeId, cmd, drag, newTab, row, rowIds, settled } from './tabs-support' +import { + action, + type Fixtures, + launchOrbit, + type Orbit, + openViaCommandBar, + startFixtures, + state, + viewPage, +} from './helpers' +import { + act, + activeId, + chooseRowMenu, + cmd, + drag, + menuFor, + newTab, + row, + rowIds, + settled, + tab, + waitTab, +} from './tabs-support' let fx: Fixtures let orbit: Orbit | undefined @@ -189,3 +211,179 @@ for (const how of ['switch', 'system'] as const) { expect(g.glowing, 'and never glowed').toEqual([]) }) } + +// ---------- TB-12, FD-07: Change Icon… opens the Space icon's emoji picker ---------- + +/** Right-clicks a row or tile and chooses Change Icon… in Orbit's menu: the icon picker, open. */ +async function changeIcon(o: Orbit, target: Locator, key: string): Promise { + await target.click({ button: 'right' }) + await menuFor(o.app, key) + expect(await chooseRowMenu(o.app, 'Change Icon…')).toBe(true) + const picker = o.shell.getByTestId('icon-picker') + await expect(picker).toBeVisible() + return picker +} + +/** The picker's cell for an emoji, in one of its sections. */ +function option(picker: Locator, emoji: string, section: string): Locator { + return picker.locator(`[data-section="${section}"] [data-testid="emoji-option"][data-emoji="${emoji}"]`) +} + +/** The emoji a picker offers as recently used, newest first. */ +function recents(picker: Locator): Promise { + return picker + .locator('[data-section="recent"] [data-testid="emoji-option"]') + .evaluateAll((els) => els.map((el) => (el as HTMLElement).dataset.emoji ?? '')) +} + +/** It opens under `target`, with its top below the target's bottom. */ +async function under(picker: Locator, target: Locator): Promise { + const [p, t] = [await picker.boundingBox(), await target.boundingBox()] + if (!p || !t) throw new Error('not on screen') + expect(p.y, 'the picker opens under its row').toBeGreaterThanOrEqual(t.y + t.height - 1) +} + +test('[TB-12][FD-07][SP-05] Change Icon… on a tab, a folder or a Favorite opens the Space icon’s emoji picker: its search, its skin tones and the emoji used last, an emoji typed as it is, and Remove icon brings back the favicon', async () => { + const o = await launch() + const { app, shell } = o + const t = await newTab(app, fx.url('/page/Sunny')) + await waitTab(app, t, '/page/Sunny') + await expect.poll(async () => !!(await tab(app, t))?.favicon).toBe(true) + const other = await newTab(app, fx.url('/page/Other'), { activate: false }) + await settled(shell) + const r = row(shell, t) + const icon = r.locator('.tab-row__emoji') + await expect(r.locator('img.favicon')).toBeVisible() + + // A tab: the emoji picker opens under its row, its search with the keyboard; "sun" finds the sun. + let picker = await changeIcon(o, r, t) + await expect(picker.getByTestId('emoji-picker')).toBeVisible() + const search = picker.getByTestId('emoji-search') + await expect(search).toBeFocused() + await under(picker, r) + await shell.keyboard.type('sun') + await option(picker, '☀️', 'results').click() + await expect(picker).toHaveCount(0) + await expect(icon).toHaveText('☀️') + + // A skin tone, and a hand in it. + picker = await changeIcon(o, r, t) + await picker.getByTestId('emoji-tone').click() + await picker.getByRole('radio', { name: 'Medium', exact: true }).click() + await search.fill('wave') + await option(picker, '👋🏽', 'results').click() + await expect(picker).toHaveCount(0) + await expect(icon).toHaveText('👋🏽') + + // An emoji typed or pasted is offered as it is, also one the picker doesn't list; Enter takes it. + picker = await changeIcon(o, r, t) + await expect(search).toBeFocused() + await shell.keyboard.insertText('🫎') + await expect(picker.getByTestId('emoji-option').first()).toHaveAttribute('data-emoji', '🫎') + await shell.keyboard.press('Enter') + await expect(picker).toHaveCount(0) + await expect(icon).toHaveText('🫎') + expect((await tab(app, t))?.icon).toBe('🫎') + + // A folder: the same picker, under the folder, with the emoji used last. + const s = await state(app) + const spaceId = Object.values(s.windows)[0]?.spaceId as string + const folder = await cmd(app, 'folders.create', { + container: { kind: 'pinned', spaceId }, + name: 'Box', + items: [other], + }) + const folderRow = shell.locator(`[data-folder-id="${folder}"] [data-testid="folder-row"]`).first() + await settled(shell) + picker = await changeIcon(o, folderRow, folder) + await expect(picker.getByTestId('emoji-picker')).toBeVisible() + await expect(search).toBeFocused() + await under(picker, folderRow) + expect(await recents(picker)).toEqual(['🫎', '👋🏽', '☀️']) + await option(picker, '☀️', 'recent').click() + await expect(picker).toHaveCount(0) + await expect(folderRow.locator('.tab-row__emoji')).toHaveText('☀️') + expect((await state(app)).folders[folder]?.icon).toBe('☀️') + + // A Favorite: the same picker, under its tile. + const fav = await newTab(app, fx.url('/page/Starred'), { activate: false }) + await cmd(app, 'tabs.favorite', { tabId: fav }) + const tile = shell.locator(`[data-testid="favorite-tile"][data-fav-id="${fav}"]`) + await expect(tile).toBeVisible() + await settled(shell) + picker = await changeIcon(o, tile, fav) + await expect(picker.getByTestId('emoji-picker')).toBeVisible() + await under(picker, tile) + await shell.keyboard.type('star') + await option(picker, '⭐', 'results').click() + await expect(picker).toHaveCount(0) + await expect(tile.locator('.favorite__emoji')).toHaveText('⭐') + + // Escape closes it, and the keys leave its search at once; so does a click outside it. + picker = await changeIcon(o, r, t) + await expect(search).toBeFocused() + await shell.keyboard.press('Escape') + await expect.poll(() => shell.evaluate(() => document.activeElement?.tagName ?? '')).not.toBe('INPUT') + await expect(picker).toHaveCount(0) + picker = await changeIcon(o, r, t) + await row(shell, other).click() + await expect(picker).toHaveCount(0) + expect((await tab(app, t))?.icon).toBe('🫎') + + // Remove icon: the favicon comes back. + picker = await changeIcon(o, r, t) + await picker.getByRole('button', { name: 'Remove icon' }).click() + await expect(picker).toHaveCount(0) + await expect(icon).toHaveCount(0) + await expect(r.locator('img.favicon')).toBeVisible() + await expect.poll(async () => (await tab(app, t))?.icon).toBeUndefined() + + // SP-05: the Space icon's picker shows the same emoji used last and the same skin tone, and still + // sets the Space's icon. + await shell.getByTestId('space-title-row').hover() + await shell.getByTestId('space-menu-button').click() + const overlay = await viewPage(app, 'overlay') + await overlay.getByTestId('menu-icon').click() + const spacePicker = overlay.getByTestId('emoji-picker') + await expect(overlay.getByTestId('emoji-search')).toBeFocused() + expect(await recents(spacePicker)).toEqual(['⭐', '☀️', '🫎', '👋🏽']) + await overlay.getByTestId('emoji-tone').click() + await expect(overlay.getByRole('radio', { name: 'Medium', exact: true })).toHaveAttribute('aria-checked', 'true') + await overlay.getByTestId('emoji-search').fill('rocket') + await overlay.getByTestId('emoji-option').first().click() + await expect.poll(async () => (await state(app)).spaces[spaceId]?.icon).toBe('🚀') +}) + +for (const reduced of [false, true]) { + test(`[TB-12][FL-25]${reduced ? '[FL-28]' : ''} the icon picker ${reduced ? 'with reduced motion fades in and out, at its size' : 'opens from 0.97 and fades out as it closes'}`, async () => { + const o = await launch() + const { app, shell } = o + if (reduced) { + await action(app, 'app.toggleReduceMotion') + await expect(shell.locator('html')).toHaveClass(/\breduce-motion\b/) + } + const t = await newTab(app, fx.url('/page/Moving')) + await settled(shell) + await row(shell, t).click({ button: 'right' }) + await menuFor(app, t) + const rec = await record(shell, { pop: '[data-testid="icon-picker"]' }, { maxMs: 20_000 }) + expect(await chooseRowMenu(app, 'Change Icon…')).toBe(true) + const picker = shell.getByTestId('icon-picker') + await expect(picker.getByTestId('emoji-search')).toBeFocused() + await expect.poll(() => picker.evaluate((el) => getComputedStyle(el).opacity)).toBe('1') + await shell.keyboard.press('Escape') + await expect(picker).toHaveCount(0) + const r = await rec.stop() + const esc = r.inputs.find((i) => i.type === 'keydown' && i.key === 'Escape') + if (!esc) throw new Error('Escape never reached the shell') + const open = r.frames.filter((f) => f.i < esc.frame && f.els.pop) + const first = open[0]?.els.pop + const full = open.at(-1)?.els.pop + if (!first || !full) throw new Error('the picker never showed') + const closing = r.frames.filter((f) => f.i >= esc.frame && (f.els.pop?.opacity ?? 0) > 0.01) + expect(first.opacity, 'it fades in').toBeLessThan(0.99) + if (reduced) expect(first.width / full.width, 'at its size').toBeGreaterThan(0.999) + else expect(first.width / full.width, 'from about 0.97').toBeLessThanOrEqual(0.99) + expect(closing.length, 'it fades out').toBeGreaterThanOrEqual(2) + }) +} diff --git a/tests/e2e/finish-tabs.spec.ts b/tests/e2e/finish-tabs.spec.ts index 762c21b8..8bd539c2 100644 --- a/tests/e2e/finish-tabs.spec.ts +++ b/tests/e2e/finish-tabs.spec.ts @@ -461,7 +461,7 @@ test('[TB-14][SV-22] a split row’s Change Icon…, though the page had the key await expect(picker).toBeVisible() await expect.poll(focusedUrl).toContain('view=shell') await typeText('rocket') - await expect(picker.locator('.icon-picker__search')).toHaveValue('rocket') + await expect(picker.getByTestId('emoji-search')).toHaveValue('rocket') await keys(['Enter']) await expect.poll(async () => (await state(app)).splits[split]?.icon).toBe('🚀') }) diff --git a/tests/e2e/finish-views.spec.ts b/tests/e2e/finish-views.spec.ts index 2aa37020..8cc33dc4 100644 --- a/tests/e2e/finish-views.spec.ts +++ b/tests/e2e/finish-views.spec.ts @@ -168,7 +168,8 @@ test("[SV-21][SV-22][SV-23] a split's icon replaces its panes' favicons on its r expect(await chooseRowMenu(app, 'Change Icon…')).toBe(true) const picker = shell.getByTestId('icon-picker') await expect(picker).toBeVisible() - await picker.getByTestId('icon-choice').first().click() + await picker.getByTestId('emoji-search').fill('star') + await picker.locator('[data-testid="emoji-option"][data-emoji="⭐"]').click() await expect(picker).toHaveCount(0) await expect(row.getByTestId('split-icon')).toHaveText('⭐') await expect(row.getByTestId('split-segment')).toHaveCount(0) diff --git a/tests/e2e/tabs-more.spec.ts b/tests/e2e/tabs-more.spec.ts index bc41b2a2..804ec697 100644 --- a/tests/e2e/tabs-more.spec.ts +++ b/tests/e2e/tabs-more.spec.ts @@ -112,8 +112,10 @@ test('[TB-12][PN-08] rename by double-click survives navigation and relaunch; em await row(shell, p).click({ button: 'right' }) await menuFor(app, p) expect(await chooseRowMenu(app, 'Change Icon…')).toBe(true) - await expect(shell.getByTestId('icon-picker')).toBeVisible() - await shell.getByTestId('icon-choice').first().click() + const picker = shell.getByTestId('icon-picker') + await expect(picker).toBeVisible() + await picker.getByTestId('emoji-search').fill('star') + await picker.locator('[data-testid="emoji-option"][data-emoji="⭐"]').click() await expect(row(shell, p).locator('.tab-row__emoji')).toHaveText('⭐') await app.close() orbit = await launchOrbit({ userData }) From 4bd2fcad3555de1b9e46792aebaf83b0db382d50 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:05:37 +0000 Subject: [PATCH 3/5] fix(spaces): between Spaces of two profiles the Favorites grid swaps in place (PR-06, SP-10) A profile change made the grid's AnimatePresence see one profile's tiles leave and the other's arrive. The real page was still hidden when they left, so popLayout measured them at 0 and left them in the flow: for several frames the grid wrapped into an extra row, pushed the title row and the lists down, and the new tiles popped in and slid back up. The grid's LayoutGroup is now keyed by the profile: a profile change swaps the whole grid at rest, and the switcher's cross-fade alone carries it. Within a profile nothing changes. The stand-in and neighbour pages drew only tab Favorites, so a profile with a split Favorite showed fewer, wider tiles until the real page arrived; they now count and draw split tiles as the grid does. Tests (feel-fix-2.spec.ts): click and swipe there and back, full and reduced motion, one and two rows, sampling every frame: title rows stay at their places, no tile below the grid, each page's tiles drawn as a resting grid. frames.ts gains `nth` for keyed matches that share a key. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/renderer/src/shell/sidebar/Favorites.tsx | 4 +- .../src/shell/spaces/SpacePreview.tsx | 52 +++-- tests/e2e/feel-fix-2.spec.ts | 189 +++++++++++++++++- tests/e2e/frames.ts | 17 +- 6 files changed, 243 insertions(+), 23 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index a3d1297a..56b26bed 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -153,7 +153,7 @@ Spec: `spec/spaces.md` | PR-03 | Assign a Space to a profile | P0 | ✅ | ✅ e2e+unit | – | tabs move with the Space and restart in the new session | | PR-04 | Profile step in the new-Space flow | P1 | ✅ | ✅ e2e+unit | – | profile step when more than one profile exists, active Space's profile preselected, inline New Profile… | | PR-05 | What each profile isolates | P0 | ✅ | ✅ e2e+unit | – | one persistent session partition per profile; cookie isolation covered by e2e | -| PR-06 | Favorites per profile | P0 | ✅ | ✅ e2e+unit | – | Favorites per profile; still during same-profile swipes, cross-fade between profiles | +| PR-06 | Favorites per profile | P0 | ✅ | ✅ e2e+unit | – | Favorites per profile; still during same-profile swipes; between profiles the whole grid cross-fades in place, no tile pops, leaves or moves, and the list below moves once if the grids differ in height | | PR-07 | Profile indicator | P1 | ✅ | ✅ e2e | – | hover badge on the title row, 'Space · Profile' tooltip, check mark in the Profile submenu | | PR-08 | Delete a profile | P1 | ✅ | ✅ e2e+unit | – | Settings → Profiles → Delete Profile…, disabled with a reason while a Space or a blank window uses the profile, and not offered for Default; the confirmation names the profile and what goes (logins, cookies, history, Favorites, site data; Orbit has no extensions yet); on confirm its Favorites close, its session's data is cleared, its history and archive entries go, and its folder on disk is removed at once and again at the next launch, in case its session wrote to it as Orbit quit; a Space deleted keeps its profile (SP-12) | | PR-09 | Rename a profile | P1 | ✅ | ✅ e2e | – | Settings → Profiles → select a profile and rename it inline, Default too when it is the only one; the new name shows at once in Settings, the Space menu, the profile badge (PR-07) and the switcher's tooltip | diff --git a/docs/feature-status.json b/docs/feature-status.json index a3d9f639..8f306199 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -185,7 +185,7 @@ "PR-03": {"built": "yes", "note": "tabs move with the Space and restart in the new session"}, "PR-04": {"built": "yes", "note": "profile step when more than one profile exists, active Space's profile preselected, inline New Profile…"}, "PR-05": {"built": "yes", "note": "one persistent session partition per profile; cookie isolation covered by e2e"}, - "PR-06": {"built": "yes", "note": "Favorites per profile; still during same-profile swipes, cross-fade between profiles"}, + "PR-06": {"built": "yes", "note": "Favorites per profile; still during same-profile swipes; between profiles the whole grid cross-fades in place, no tile pops, leaves or moves, and the list below moves once if the grids differ in height"}, "PR-07": {"built": "yes", "note": "hover badge on the title row, 'Space · Profile' tooltip, check mark in the Profile submenu"}, "PR-08": {"built": "yes", "note": "Settings → Profiles → Delete Profile…, disabled with a reason while a Space or a blank window uses the profile, and not offered for Default; the confirmation names the profile and what goes (logins, cookies, history, Favorites, site data; Orbit has no extensions yet); on confirm its Favorites close, its session's data is cleared, its history and archive entries go, and its folder on disk is removed at once and again at the next launch, in case its session wrote to it as Orbit quit; a Space deleted keeps its profile (SP-12)"}, "PR-09": {"built": "yes", "note": "Settings → Profiles → select a profile and rename it inline, Default too when it is the only one; the new name shows at once in Settings, the Space menu, the profile badge (PR-07) and the switcher's tooltip"}, diff --git a/src/renderer/src/shell/sidebar/Favorites.tsx b/src/renderer/src/shell/sidebar/Favorites.tsx index 51cf3482..01f98f15 100644 --- a/src/renderer/src/shell/sidebar/Favorites.tsx +++ b/src/renderer/src/shell/sidebar/Favorites.tsx @@ -54,7 +54,9 @@ export const Favorites = memo( data-testid="favorites" style={tiles.length > 0 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined} > - + {/* Between Spaces of two profiles the whole grid swaps: no tile leaves, arrives or moves, and + the Space switcher's cross-fade alone carries it (PR-06, SP-10). */} + {tiles.map((id) => id ? ( diff --git a/src/renderer/src/shell/spaces/SpacePreview.tsx b/src/renderer/src/shell/spaces/SpacePreview.tsx index 6196434b..3f2842f5 100644 --- a/src/renderer/src/shell/spaces/SpacePreview.tsx +++ b/src/renderer/src/shell/spaces/SpacePreview.tsx @@ -1,6 +1,7 @@ import { ChevronRight, Folder, Plus } from 'lucide-react' import { memo, type ReactNode } from 'react' import { isFolderId, isSplitId, isTabId } from '../../../../shared/ids' +import { paneOrder } from '../../../../shared/split' import type { ItemId, OrbitState, SpaceId } from '../../../../shared/state' import { splitLeaves } from '../../../../shared/tree' import { getOrbitState, useOrbit } from '../../store' @@ -39,35 +40,23 @@ export const SpacePreview = memo(function SpacePreview({ const state = getOrbitState() const scrollTop = scrollMemory.get(spaceId) ?? 0 const budget = Math.ceil((scrollTop + (height || 900)) / ROW_PITCH) + 4 + // Tabs and split views, counted as the real grid counts them (FV-02, SV-23). + const tiles = favorites?.filter((id) => isTabId(id) || isSplitId(id)) ?? [] const pinnedRows = collectRows(state, space.pinned, 0, budget, active) const todayRows = collectRows(state, space.today, 0, Math.max(0, budget - pinnedRows.length - 2), active) return (
- {favorites && favorites.length > 0 ? ( + {tiles.length > 0 ? ( // Same grid and tiles as the tabs lane's Favorites, so a stand-in or a cross-fading // neighbour never changes the tiles' size or place (PR-06; review V2).
- {favorites.filter(isTabId).map((id) => { - const tab = state.tabs[id] - return ( -
- {tab?.icon ? ( - {tab.icon} - ) : ( - - )} -
- ) - })} + {tiles.map((id) => favoriteTile(state, id, active))}
) : null}
@@ -96,6 +85,35 @@ export const SpacePreview = memo(function SpacePreview({ ) }) +/** A Favorites tile as the real grid draws it, without handlers: a tab's, or a split view's (SV-23). */ +function favoriteTile(state: OrbitState, id: string, active: string | null): ReactNode { + if (isSplitId(id)) { + const split = state.splits[id] + if (!split) return null + const panes = paneOrder(split.layout) + return ( +
+ {split.icon ? ( + {split.icon} + ) : ( + + {panes.map((pane) => ( + + ))} + + )} +
+ ) + } + const tab = state.tabs[id] + if (!tab) return null + return ( +
+ {tab.icon ? {tab.icon} : } +
+ ) +} + function collectRows(state: OrbitState, items: ItemId[], depth: number, budget: number, active: string | null) { const rows: ReactNode[] = [] const visit = (id: ItemId, d: number) => { diff --git a/tests/e2e/feel-fix-2.spec.ts b/tests/e2e/feel-fix-2.spec.ts index f6e7e2de..89e1aeef 100644 --- a/tests/e2e/feel-fix-2.spec.ts +++ b/tests/e2e/feel-fix-2.spec.ts @@ -3,17 +3,19 @@ * test that fails without its fix (D18). */ import { expect, type Locator, type Page, test } from '@playwright/test' -import { type Recording, record, type Target } from './frames' +import { type Box, type Recording, record, type Target } from './frames' import { action, type Fixtures, launchOrbit, type Orbit, openViaCommandBar, + run, startFixtures, state, viewPage, } from './helpers' +import { activeSpace, createSpace, hoverSidebar, settled as stripAtRest, swipe } from './spaces-support' import { act, activeId, @@ -387,3 +389,188 @@ for (const reduced of [false, true]) { expect(closing.length, 'it fades out').toBeGreaterThanOrEqual(2) }) } + +// ---------- PR-06, SP-10: between profiles the Favorites grid swaps in place ---------- + +/** Each page's title row in the Space strip, and every Favorites tile, by page and place. */ +const STRIP: Record = { + titles: { selector: '.space-strip .space-title', key: 'data-slot' }, + tiles: { selector: '.space-strip .favorite', key: 'data-slot', nth: true }, +} + +/** Space `a` on the Default profile with `mine` Favorites, Space `b` on a Work profile with `theirs`. */ +async function twoProfiles(o: Orbit, mine: number, theirs: number): Promise<{ a: string; b: string; work: string }> { + const { app, shell } = o + const a = Object.values((await state(app)).windows)[0]?.spaceId as string + const work = (await run(app, 'profiles.create', { name: 'Work' })) as string + const b = await createSpace(app, { name: 'Work', activate: false, profileId: work, afterSpaceId: a }) + const favorite = (profileId: string, spaceId: string, name: string) => + newTab(app, fx.url(`/page/${name}`), { activate: false, spaceId, container: { kind: 'favorites', profileId } }) + for (let i = 0; i < mine; i++) await favorite('p-default', a, `Mine${i}`) + for (let i = 0; i < theirs; i++) await favorite(work, b, `Theirs${i}`) + await stripAtRest(shell) + return { a, b, work } +} + +interface Rest { + /** The title row's top. */ + title: number + /** The Favorites grid's area, top to bottom. */ + top: number + bottom: number + /** Each tile's x, y, width and height. */ + tiles: number[][] +} + +/** Where the title row and the Favorites grid rest on the active Space, once its `tiles` stopped moving. */ +async function rest(shell: Page, tiles: number): Promise { + let last = '' + await expect + .poll( + async () => { + const now = await shell.evaluate(() => { + const real = document.querySelector('[data-slot="0"] .space-slot__real') + const title = real?.querySelector('.space-title')?.getBoundingClientRect() + const grid = real?.querySelector('[data-favorites-layer]')?.getBoundingClientRect() + const tiles = [...(real?.querySelectorAll('.favorite') ?? [])].map((el) => { + const r = el.getBoundingClientRect() + return [r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10) + }) + return JSON.stringify({ title: title?.top, top: grid?.top, bottom: grid?.bottom, tiles }) + }) + const still = now === last + last = now + return still && (JSON.parse(now).tiles as number[][]).length === tiles + }, + { intervals: [100, 150, 250] }, + ) + .toBe(true) + return JSON.parse(last) as Rest +} + +/** What a recorded switch drew out of place: title rows off `[lo, hi]`, tiles past the grid's area. */ +function outOfPlace(rec: Recording, lo: number, hi: number, gridBottom: number): string[] { + const out: string[] = [] + for (const f of rec.frames) { + for (const [page, b] of Object.entries(f.lists.titles ?? {})) { + if (b.opacity > 0.01 && (b.y < lo - 1 || b.y > hi + 1)) + out.push(`frame ${f.i}: page ${page}'s title row at ${b.y.toFixed(1)}`) + } + for (const [tile, b] of Object.entries(f.lists.tiles ?? {})) { + const end = b.y + b.height + if (b.opacity > 0.01 && end > gridBottom + 1) out.push(`frame ${f.i}: tile ${tile} down to ${end.toFixed(1)}`) + } + } + return out +} + +/** + * Frames where a page drew its Favorites otherwise than one of the resting `grids` does: a tile that + * pops in or out, shrinks, moves, or is missing. + */ +function offGrid(rec: Recording, grids: number[][][]): string[] { + const out: string[] = [] + for (const f of rec.frames) { + const pages = new Map() + for (const [tile, b] of Object.entries(f.lists.tiles ?? {})) { + const page = tile.slice(0, tile.indexOf('#')) + if (b.opacity > 0.01) pages.set(page, [...(pages.get(page) ?? []), b]) + } + for (const [page, tiles] of pages) { + const drawnAs = (grid: number[][]) => + grid.length === tiles.length && + grid.every((g, i) => { + const t = tiles[i] as Box + return [t.x, t.y, t.width, t.height].every((v, j) => Math.abs(v - (g[j] as number)) <= 1) + }) + if (!grids.some(drawnAs)) { + const drawn = tiles.map( + (t) => `${t.x.toFixed(0)},${t.y.toFixed(0)} ${t.width.toFixed(0)}×${t.height.toFixed(0)}`, + ) + out.push(`frame ${f.i}: page ${page} drew ${drawn.join('; ')}`) + } + } + } + return out +} + +for (const motion of ['full', 'reduced'] as const) { + test(`[PR-06][SP-10]${motion === 'reduced' ? '[FL-28] with reduced motion,' : ''} between Spaces of two profiles the Favorites grid swaps in place: by a click and by a swipe, the title row stays put and no tile shows below the grid`, async () => { + const o = await launch() + const { app, shell } = o + if (motion === 'reduced') { + // Orbit's own switch: a click cross-fades the sidebar instead of sliding it (FL-28). + await run(app, 'settings.update', { reduceMotion: true }) + await expect(shell.locator('html')).toHaveClass(/\breduce-motion\b/) + } + // One row each: two Favorites in the Default profile, four in Work, the last a split view (the + // stand-in page draws it as the real grid does, SV-23). + const { a, b, work } = await twoProfiles(o, 2, 3) + const panes = [ + await newTab(app, fx.url('/page/Left'), { activate: false, spaceId: b }), + await newTab(app, fx.url('/page/Right'), { activate: false, spaceId: b }), + ] + const split = (await run(app, 'split.create', { tabIds: panes })) as string + expect(await run(app, 'tabs.move', { id: split, to: { kind: 'favorites', profileId: work }, index: 3 })).toBe(true) + await run(app, 'spaces.switch', { spaceId: a }) + await expect.poll(() => activeSpace(app)).toBe(a) + await stripAtRest(shell) + const at = await rest(shell, 2) + expect(await rest(shell, 2)).toEqual(at) + const rec = await record(shell, STRIP, { maxMs: 60_000 }) + const dot = (id: string) => shell.locator(`[data-testid="space-dot"][data-space-id="${id}"]`) + const reach = async (id: string, tiles: number) => { + await expect.poll(() => activeSpace(app)).toBe(id) + await stripAtRest(shell) + return rest(shell, tiles) + } + // By a click, there and back. + await dot(b).click() + const there = await reach(b, 4) + expect(there.title, 'Work’s title row rests where Personal’s did').toBeCloseTo(at.title, 0) + await dot(a).click() + await reach(a, 2) + // By a swipe, there and back. + await hoverSidebar(shell) + await swipe(shell, 12, 16, 16) + await reach(b, 4) + await swipe(shell, -12, 16, 16) + await reach(a, 2) + const frames = await rec.stop() + const drawn = frames.frames.filter((f) => Object.keys(f.lists.titles ?? {}).length > 1).length + expect(drawn, 'the switches were recorded').toBeGreaterThan(4) + expect(outOfPlace(frames, at.title, at.title, at.bottom)).toEqual([]) + expect(offGrid(frames, [at.tiles, there.tiles])).toEqual([]) + }) +} + +test('[PR-06][SP-10] between a one-row and a two-row Favorites grid, the title row moves once, from its place to the other, never past either', async () => { + const o = await launch() + const { app, shell } = o + // One row in the Default profile, two in Work. + const { a, b } = await twoProfiles(o, 2, 5) + const one = await rest(shell, 2) + const rec = await record(shell, STRIP, { maxMs: 60_000 }) + const dot = (id: string) => shell.locator(`[data-testid="space-dot"][data-space-id="${id}"]`) + await dot(b).click() + await expect.poll(() => activeSpace(app)).toBe(b) + await stripAtRest(shell) + const two = await rest(shell, 5) + expect(two.title, 'a row lower').toBeGreaterThan(one.title + 40) + await dot(a).click() + await expect.poll(() => activeSpace(app)).toBe(a) + await stripAtRest(shell) + await rest(shell, 2) + await hoverSidebar(shell) + await swipe(shell, 12, 16, 16) + await expect.poll(() => activeSpace(app)).toBe(b) + await stripAtRest(shell) + await rest(shell, 5) + await swipe(shell, -12, 16, 16) + await expect.poll(() => activeSpace(app)).toBe(a) + await stripAtRest(shell) + await rest(shell, 2) + const frames = await rec.stop() + expect(outOfPlace(frames, one.title, two.title, two.bottom)).toEqual([]) + expect(offGrid(frames, [one.tiles, two.tiles])).toEqual([]) +}) diff --git a/tests/e2e/frames.ts b/tests/e2e/frames.ts index 4eeb6150..ba832e0b 100644 --- a/tests/e2e/frames.ts +++ b/tests/e2e/frames.ts @@ -39,6 +39,11 @@ export type Target = attrs?: string[] /** Keep the element's own running animations (`getAnimations()`), each as an `Anim`. */ animations?: boolean + /** + * With `key`, for matches that share their key (e.g. a page's tiles, keyed by the page): key each + * as `#`, its place among those on screen, in document order, counted from 0. + */ + nth?: boolean } /** @@ -144,6 +149,7 @@ export async function record( text?: boolean attrs?: string[] animations?: boolean + nth?: boolean } type W = { __feelRec?: PageRecorder } const w = window as unknown as W @@ -239,10 +245,17 @@ export async function record( for (const [name, spec] of specs) { if (spec.key) { const all: Record = {} + const shared = new Map() for (const el of document.querySelectorAll(spec.selector)) { const b = box(el, spec) - const k = el.closest(`[${spec.key}]`)?.getAttribute(spec.key) - if (b && k != null) all[k] = b + let k = el.closest(`[${spec.key}]`)?.getAttribute(spec.key) + if (!b || k == null) continue + if (spec.nth) { + const n = shared.get(k) ?? 0 + shared.set(k, n + 1) + k = `${k}#${n}` + } + all[k] = b } frame.lists[name] = all } else { From 2f71b388cf23c5677b38ab5927dada7cccfb5684 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:11:32 +0000 Subject: [PATCH 4/5] fix(spaces): a window's first visit to a Space selects its last-used tab; a switch never takes another window's tab (SP-10, SP-23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window that had nothing remembered for a Space (a window opened with ⌘N, then switched away and back) showed the empty state although the Space had tabs. As a window switches, `tabOnSwitch` now picks the tab it last used there, unless that tab has gone or another window shows it (or a pane of its split view); else the Space's most recently used tab that no other window shows; else none, the empty state. A switch never takes a tab from another window: before, the switching window kept its remembered tab and the other window had to pick again. A new window still opens with no tab selected (SP-24). Tests: feel-fix-2.spec.ts (⌘N, window 2 to B and back to A shows A's most recent tab window 1 doesn't show; window 1 keeps its page; a remembered tab window 1 now shows isn't taken back; a Space whose only tab window 1 shows gives the empty state); spaces.spec.ts TB-17 and the unit test now expect the switching window to pick another tab; new unit tests for a first visit, a gone tab and the empty state. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 6 ++-- docs/feature-status.json | 4 +-- src/main/spaces/model.ts | 9 +++-- src/main/tabs/model.ts | 14 ++++++++ tests/e2e/feel-fix-2.spec.ts | 64 +++++++++++++++++++++++++++++++++++- tests/e2e/spaces.spec.ts | 7 ++-- tests/unit/spaces.test.ts | 64 ++++++++++++++++++++++++++++++++++-- 7 files changed, 153 insertions(+), 15 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 56b26bed..f2ddb647 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -131,7 +131,7 @@ Spec: `spec/spaces.md` | SP-07 | Click to switch, and the switcher icon menu | P0 | ✅ | ✅ e2e | – | click slides one page; right-click opens that Space's menu without switching; Rename brings it into view for the inline rename | | SP-08 | Switch Spaces with a trackpad swipe | P0 | ✅ | ✅ e2e+unit | – | wheel deltaX and touch/pen drags; 1:1 tracking, projected-velocity commit, motion springs, rubber-band, over-swipe opens the new-Space flow; momentum detected heuristically | | SP-09 | Switch Spaces with the keyboard, mouse buttons and command bar | P0 | ✅ | ✅ e2e+unit | – | Ctrl/Alt+1-9 and Cmd/Ctrl+Alt+arrows (rate-limited, nudge at the ends), mouse back/forward over the sidebar | -| SP-10 | What switching Space does | P0 | ✅ | ✅ e2e+unit | – | per-window Space, remembered tab per Space, page swap at commit, scroll position per Space | +| SP-10 | What switching Space does | P0 | ✅ | ✅ e2e+unit | – | per-window Space, remembered tab per Space (on a first visit, or when that tab has gone or shows in another window, the Space's most recently used tab that no other window shows; the empty state only when there is none), page swap at commit, scroll position per Space | | SP-11 | Reorder Spaces | P1 | 🟡 partial | ✅ e2e+unit | – | drag targets in the switcher to reorder, and the Space shown stays; ⌘Z puts the Spaces back in their order and ⌘⇧Z reorders them again (TB-18); reordering in the Library's Spaces overview waits for SP-13 | | SP-12 | Delete a Space | P0 | ✅ | ✅ e2e+unit | – | confirmation dialog, tabs archived with the Space name, Undo toast and Cmd+Z, last Space protected | | SP-13 | Spaces overview in the Library | P1 | – | – | – | | @@ -144,8 +144,8 @@ Spec: `spec/spaces.md` | SP-20 | Per-Space pinned and Today sections | P0 | ✅ | ✅ e2e | – | per-Space pinned and Today containers (tab behaviour in the tabs lane) | | SP-21 | Move tabs between Spaces | P0 | ✅ | ✅ e2e | – | drop on a switcher target moves the tab (tabs lane drag); edge dwell slides the strip and measures once it settles | | SP-22 | Spaces in the command bar | P1 | 🟡 partial | ✅ e2e+unit | – | typing a Space's name shows it and Enter switches (CB-05); New Space, Rename Space, Edit Theme, Change Space Icon, Delete Space, Move to / Pin to [Space] (CB-13), Next and Previous Space (found as Select Next / Previous Space too), New Blank Window (TB-17); the bar stays open across a Space switch and its results follow the new Space and profile (CB-19), in the Space's theme (SP-16); Manage Spaces / View Spaces wait for the Spaces overview (SP-13) | -| SP-23 | Spaces across windows | P0 | ✅ | ✅ e2e+unit | – | each window keeps its own Space; all windows share Spaces | -| SP-24 | What a new window shows | P0 | ✅ | ✅ e2e | – | Cmd/Ctrl+N: same Space, sidebar shown, cascaded, command bar open, full screen follows | +| SP-23 | Spaces across windows | P0 | ✅ | ✅ e2e+unit | – | each window keeps its own Space; all windows share Spaces; a Space switch never takes a tab another window shows | +| SP-24 | What a new window shows | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+N: same Space, sidebar shown, cascaded, command bar open, full screen follows | | SP-25 | Spaces menu in the app menu | P2 | – | – | – | | | SP-26 | Sharing a Space (out of scope) | P2 | ⛔ out of scope | – | – | it needs an online service, so the spec leaves it out of milestone 1 (D41) | | PR-01 | What a profile is | P0 | ✅ | ✅ e2e+unit | – | Default profile always exists and cannot be deleted | diff --git a/docs/feature-status.json b/docs/feature-status.json index 8f306199..fbe78be4 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -162,7 +162,7 @@ "SP-07": {"built": "yes", "note": "click slides one page; right-click opens that Space's menu without switching; Rename brings it into view for the inline rename"}, "SP-08": {"built": "yes", "note": "wheel deltaX and touch/pen drags; 1:1 tracking, projected-velocity commit, motion springs, rubber-band, over-swipe opens the new-Space flow; momentum detected heuristically"}, "SP-09": {"built": "yes", "note": "Ctrl/Alt+1-9 and Cmd/Ctrl+Alt+arrows (rate-limited, nudge at the ends), mouse back/forward over the sidebar"}, - "SP-10": {"built": "yes", "note": "per-window Space, remembered tab per Space, page swap at commit, scroll position per Space"}, + "SP-10": {"built": "yes", "note": "per-window Space, remembered tab per Space (on a first visit, or when that tab has gone or shows in another window, the Space's most recently used tab that no other window shows; the empty state only when there is none), page swap at commit, scroll position per Space"}, "SP-11": {"built": "partial", "note": "drag targets in the switcher to reorder, and the Space shown stays; ⌘Z puts the Spaces back in their order and ⌘⇧Z reorders them again (TB-18); reordering in the Library's Spaces overview waits for SP-13"}, "SP-12": {"built": "yes", "note": "confirmation dialog, tabs archived with the Space name, Undo toast and Cmd+Z, last Space protected"}, "SP-14": {"built": "yes", "note": "theme picker beside the sidebar (Space menu, right-click on the sidebar, Edit Theme action, new-Space flow, first run); edits apply live; Esc, Enter or a click outside keeps the result; Cmd+Z after it closes reverts what that opening changed (theme, and light/dark if changed there), Cmd+Shift+Z re-applies, from the sidebar or a page; no trackpad haptics (Electron has no haptics API)"}, @@ -174,7 +174,7 @@ "SP-20": {"built": "yes", "note": "per-Space pinned and Today containers (tab behaviour in the tabs lane)"}, "SP-21": {"built": "yes", "note": "drop on a switcher target moves the tab (tabs lane drag); edge dwell slides the strip and measures once it settles"}, "SP-22": {"built": "partial", "note": "typing a Space's name shows it and Enter switches (CB-05); New Space, Rename Space, Edit Theme, Change Space Icon, Delete Space, Move to / Pin to [Space] (CB-13), Next and Previous Space (found as Select Next / Previous Space too), New Blank Window (TB-17); the bar stays open across a Space switch and its results follow the new Space and profile (CB-19), in the Space's theme (SP-16); Manage Spaces / View Spaces wait for the Spaces overview (SP-13)"}, - "SP-23": {"built": "yes", "note": "each window keeps its own Space; all windows share Spaces"}, + "SP-23": {"built": "yes", "note": "each window keeps its own Space; all windows share Spaces; a Space switch never takes a tab another window shows"}, "SP-24": {"built": "yes", "note": "Cmd/Ctrl+N: same Space, sidebar shown, cascaded, command bar open, full screen follows"}, "SP-26": {"scope": "out", "note": "it needs an online service, so the spec leaves it out of milestone 1 (D41)"}, "NV-22": {"scope": "out", "note": "Orbit's engine has no built-in translation, and the spec allows no paid API (D45). ST-14's offer to translate, the Translate command and the address field's translate button go with it"}, diff --git a/src/main/spaces/model.ts b/src/main/spaces/model.ts index 9af4ac85..b9f894c2 100644 --- a/src/main/spaces/model.ts +++ b/src/main/spaces/model.ts @@ -26,7 +26,7 @@ import type { } from '../../shared/state' import { normalizeSpaceTheme, sameTheme } from '../../shared/theme' import { splitLeaves, tabsInItem } from '../../shared/tree' -import { dedupeDisplayed } from '../tabs/model' +import { dedupeDisplayed, tabOnSwitch } from '../tabs/model' /** Archive size cap; trimmed in chunks from the oldest end so trims are rare (review F2, same rule as tabs/ops.ts). */ export const MAX_ARCHIVE = 5000 @@ -104,9 +104,12 @@ export function switchSpaceInDraft(d: OrbitState, windowId: WindowId, spaceId: S // A private or blank window keeps its own Space, and no other window shows one (NV-11, TB-17). if (isPrivateWindow(d, windowId) || isPrivateSpace(d, spaceId)) return false if (isBlankSpace(d, win.spaceId) || isBlankSpace(d, spaceId)) return false + // The tab it last used there; on a first visit, or when that tab has gone or shows in another + // window, the Space's most recently used tab no other window shows: a switch never takes a tab + // from another window (SP-10, SP-23, TB-17). None: the empty state. A new window still opens on + // its Space with no tab selected (SP-24). + win.activeTab[spaceId] = tabOnSwitch(d, windowId, spaceId) win.spaceId = spaceId - // The Space's remembered tab may be on screen in another window: a page is live in one - // window at a time, so the window that switched keeps it and the other picks again (TB-17). dedupeDisplayed(d, windowId) return true } diff --git a/src/main/tabs/model.ts b/src/main/tabs/model.ts index 703e0996..e6c50e46 100644 --- a/src/main/tabs/model.ts +++ b/src/main/tabs/model.ts @@ -145,6 +145,20 @@ export function displayedTabs(state: OrbitState): Set { return out } +/** + * The tab a window selects as it switches to `spaceId` (SP-10): the one it last used there, unless + * that tab has gone or another window shows it (or a pane of its split view); else the Space's most + * recently used tab that no other window shows; else none, the empty state. A switch never takes a + * tab from another window (SP-23, TB-17). + */ +export function tabOnSwitch(state: OrbitState, windowId: WindowId, spaceId: SpaceId): TabId | null { + const elsewhere = shownElsewhere(state, windowId) + const free = (id: TabId) => !splitLeavesOf(state, id).some((t) => elsewhere.has(t)) + const remembered = state.windows[windowId]?.activeTab[spaceId] + if (remembered && free(remembered) && sidebarTabOrder(state, spaceId).includes(remembered)) return remembered + return mostRecentTab(state, spaceId, elsewhere, (t) => free(t.id)) +} + /** Most recently used tab of a Space (Favorites included), skipping `excluding`. */ export function mostRecentTab( state: OrbitState, diff --git a/tests/e2e/feel-fix-2.spec.ts b/tests/e2e/feel-fix-2.spec.ts index 89e1aeef..836ba239 100644 --- a/tests/e2e/feel-fix-2.spec.ts +++ b/tests/e2e/feel-fix-2.spec.ts @@ -2,7 +2,7 @@ * Feel fixes 2 (D91): the gaps sign-off 5 held back in the sidebar, Spaces and profiles, each with a * test that fails without its fix (D18). */ -import { expect, type Locator, type Page, test } from '@playwright/test' +import { type ElectronApplication, expect, type Locator, type Page, test } from '@playwright/test' import { type Box, type Recording, record, type Target } from './frames' import { action, @@ -13,6 +13,7 @@ import { run, startFixtures, state, + tabView, viewPage, } from './helpers' import { activeSpace, createSpace, hoverSidebar, settled as stripAtRest, swipe } from './spaces-support' @@ -574,3 +575,64 @@ test('[PR-06][SP-10] between a one-row and a two-row Favorites grid, the title r expect(outOfPlace(frames, one.title, two.title, two.bottom)).toEqual([]) expect(offGrid(frames, [one.tiles, two.tiles])).toEqual([]) }) + +// ---------- SP-10, SP-23: a window's first visit to a Space selects the Space's last-used tab ---------- + +/** Runs a command in the window `windowId`, as the user acting there would. */ +function runIn(app: ElectronApplication, windowId: string, name: string, args: unknown = {}): Promise { + return app.evaluate( + (_e, [w, n, a]) => + ( + globalThis as unknown as { __orbit: { run: (n: string, a: unknown, w?: string) => Promise } } + ).__orbit.run(n, a, w), + [windowId, name, args] as const, + ) +} + +test('[SP-10][SP-23][SP-24] a new window that leaves its Space and comes back selects the Space’s most recent tab the first window doesn’t show; a switch never takes a tab another window shows', async () => { + const { app } = await launch() + const a = await activeSpace(app) + // Window 1 on A with three tabs, used Two, Three, then One, which it shows. + const one = await newTab(app, fx.url('/page/One'), { activate: false }) + const two = await newTab(app, fx.url('/page/Two'), { activate: false }) + const three = await newTab(app, fx.url('/page/Three'), { activate: false }) + for (const id of [two, three, one]) await run(app, 'tabs.activate', { tabId: id }) + const b = await createSpace(app, { name: 'B', activate: false }) + const w1 = Object.keys((await state(app)).windows)[0] as string + const win = async (id: string) => (await state(app)).windows[id] + // ⌘N: window 2 opens on A with no tab selected and the command bar open (SP-24). + await action(app, 'window.new') + await expect.poll(async () => Object.keys((await state(app)).windows).length).toBe(2) + const w2 = Object.keys((await state(app)).windows).find((id) => id !== w1) as string + expect((await win(w2))?.spaceId).toBe(a) + expect((await win(w2))?.activeTab[a] ?? null, 'no tab selected (SP-24)').toBeNull() + await expect.poll(async () => (await win(w2))?.overlay.kind).toBe('commandbar') + await runIn(app, w2, 'overlay.close') + + // Window 2 goes to B, which has no tabs (the empty state), and back to A: it shows Three. + await runIn(app, w2, 'spaces.switch', { spaceId: b }) + expect((await win(w2))?.activeTab[b] ?? null, 'B has no tabs').toBeNull() + await runIn(app, w2, 'spaces.switch', { spaceId: a }) + expect((await win(w2))?.activeTab[a], 'A’s most recent tab window 1 doesn’t show').toBe(three) + expect((await win(w1))?.activeTab[a], 'window 1’s page stays').toBe(one) + await expect.poll(async () => (await tabView(app, three))?.visible, 'window 2 draws Three').toBe(true) + expect((await tabView(app, one))?.visible, 'window 1 still draws One').toBe(true) + + // Window 2 goes to B, and window 1 selects Three, the tab window 2 remembers for A. Back on A, + // window 2 doesn't take it: it shows One, the most recent tab window 1 doesn't show (SP-23, TB-17). + await runIn(app, w2, 'spaces.switch', { spaceId: b }) + await runIn(app, w1, 'tabs.activate', { tabId: three }) + await runIn(app, w2, 'spaces.switch', { spaceId: a }) + expect((await win(w1))?.activeTab[a], 'window 1 keeps Three').toBe(three) + expect((await win(w2))?.activeTab[a]).toBe(one) + + // A Space whose only tab window 1 shows: window 2 gets its empty state. + const c = await createSpace(app, { name: 'C', activate: false }) + const only = await newTab(app, fx.url('/page/Only'), { activate: false, spaceId: c }) + await runIn(app, w1, 'tabs.activate', { tabId: only }) + expect((await win(w1))?.spaceId).toBe(c) + await runIn(app, w2, 'spaces.switch', { spaceId: c }) + expect((await win(w2))?.spaceId).toBe(c) + expect((await win(w2))?.activeTab[c] ?? null, 'the empty state').toBeNull() + expect((await win(w1))?.activeTab[c], 'window 1 keeps its tab').toBe(only) +}) diff --git a/tests/e2e/spaces.spec.ts b/tests/e2e/spaces.spec.ts index b9fa4d0b..6577fc99 100644 --- a/tests/e2e/spaces.spec.ts +++ b/tests/e2e/spaces.spec.ts @@ -1085,10 +1085,11 @@ test('[SP-23][TB-17] switching Space never puts two windows on the same tab', as await runIn(w1, 'spaces.switch', { spaceId: b }) await runIn(w2, 'tabs.activate', { tabId: t1 }) await expect.poll(async () => (await state(app)).windows[w2]?.activeTab[a]).toBe(t1) - // Window 1 comes back to A: it keeps t1, and window 2 shows another tab. + // Window 1 comes back to A: a switch never takes a tab from another window, so window 2 keeps t1 + // and window 1 shows t2, the most recent tab window 2 doesn't show (SP-10, feel-fix-2). await runIn(w1, 'spaces.switch', { spaceId: a }) await expect.poll(async () => (await state(app)).windows[w1]?.spaceId).toBe(a) const s = await state(app) - expect(s.windows[w1]?.activeTab[a]).toBe(t1) - expect(s.windows[w2]?.activeTab[a]).toBe(t2) + expect(s.windows[w2]?.activeTab[a]).toBe(t1) + expect(s.windows[w1]?.activeTab[a]).toBe(t2) }) diff --git a/tests/unit/spaces.test.ts b/tests/unit/spaces.test.ts index b0b1d589..1a62c68e 100644 --- a/tests/unit/spaces.test.ts +++ b/tests/unit/spaces.test.ts @@ -125,7 +125,7 @@ describe('[SP-10][SP-23] switching', () => { }) describe('[SP-23][TB-17] Spaces across windows', () => { - it('switching Space never leaves two windows on the same tab (the switching window keeps it)', () => { + it('a switch never takes a tab another window shows: the switching window picks another', () => { const { state, a, win, addTab } = world() const b = createSpaceInDraft(state, { name: 'B', afterSpaceId: a }) const t1 = addTab(a, 'today') @@ -137,8 +137,66 @@ describe('[SP-23][TB-17] Spaces across windows', () => { w2.activeTab[a] = t1 state.windows[w2.id] = w2 switchSpaceInDraft(state, win, a) - expect(state.windows[win]?.activeTab[a]).toBe(t1) - expect(state.windows[w2.id]?.activeTab[a]).toBe(t2) + expect(state.windows[w2.id]?.activeTab[a]).toBe(t1) + expect(state.windows[win]?.activeTab[a]).toBe(t2) + }) +}) + +describe('[SP-10][SP-23][SP-24] a window with no tab of its own for a Space', () => { + /** Window 1 on A shows One; Two, Three and One were used in that order. A second window opens on A. */ + function twoWindows() { + const w = world() + const b = createSpaceInDraft(w.state, { name: 'B', afterSpaceId: w.a }) + const one = w.addTab(w.a, 'today') + const two = w.addTab(w.a, 'today') + const three = w.addTab(w.a, 'today') + w.state.tabs[two]!.lastActiveAt = 10 + w.state.tabs[three]!.lastActiveAt = 20 + w.state.tabs[one]!.lastActiveAt = 30 + w.state.windows[w.win]!.activeTab[w.a] = one + const w2 = createWindowState(w.a) + w.state.windows[w2.id] = w2 + return { ...w, b: b.id, w2: w2.id, one, two, three } + } + + it('selects, on its first visit, the most recently used tab that no other window shows', () => { + const { state, a, b, win, w2, one, three } = twoWindows() + switchSpaceInDraft(state, w2, b) + expect(state.windows[w2]?.activeTab[b] ?? null).toBeNull() + switchSpaceInDraft(state, w2, a) + expect(state.windows[w2]?.activeTab[a]).toBe(three) + expect(state.windows[win]?.activeTab[a]).toBe(one) + }) + + it('does the same when the tab it remembers has gone', () => { + const { state, a, b, w2, three, two } = twoWindows() + state.windows[w2]!.activeTab[a] = 't-gone' + state.windows[w2]!.spaceId = b + delete state.tabs[three] + state.spaces[a]!.today = state.spaces[a]!.today.filter((id) => id !== three) + switchSpaceInDraft(state, w2, a) + expect(state.windows[w2]?.activeTab[a]).toBe(two) + }) + + it('shows the empty state when every tab of the Space shows in another window, a split pane too', () => { + const { state, a, b, win, w2, one, two, three } = twoWindows() + // Two and Three are one split view, which window 1 shows; One shows in a third window. + const leaf = (tabId: string) => ({ type: 'leaf' as const, tabId }) + state.splits['v-1'] = { + id: 'v-1', + layout: { type: 'row', children: [leaf(two), leaf(three)], sizes: [1, 1] }, + createdAt: 0, + } + state.spaces[a]!.today = [one, 'v-1'] + state.windows[win]!.activeTab[a] = three + const w3 = createWindowState(a) + w3.activeTab[a] = one + state.windows[w3.id] = w3 + switchSpaceInDraft(state, w2, b) + switchSpaceInDraft(state, w2, a) + expect(state.windows[w2]?.activeTab[a] ?? null).toBeNull() + expect(state.windows[win]?.activeTab[a]).toBe(three) + expect(state.windows[w3.id]?.activeTab[a]).toBe(one) }) }) From 23081b6a0171ff61ce5b0a11b5139da97e300414 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:26:41 +0000 Subject: [PATCH 5/5] test(tabs): TB-09's drop target is B's row, now that the window shows B's tab (SP-10) Gate r1 found it: once a window's first visit to a Space selects that Space's last-used tab, the window that slides to B mid-drag shows B's tab, and its page's pane carries the same data-tab-id as the row, so the bare `[data-tab-id]` locator matched two elements. The test now takes the row and checks the window selected B's tab. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/tabs-more.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/tabs-more.spec.ts b/tests/e2e/tabs-more.spec.ts index 804ec697..19861ebd 100644 --- a/tests/e2e/tabs-more.spec.ts +++ b/tests/e2e/tabs-more.spec.ts @@ -599,8 +599,10 @@ test('[TB-09][TB-11] holding a dragged tab at the sidebar edge slides to the nex await shell.mouse.move(edgeX, start.y + 20, { steps: 8 }) // Hold near the right edge: the window shows the next Space. await expect.poll(async () => Object.values((await state(app)).windows)[0]?.spaceId, { timeout: 4000 }).toBe(sB) + // The window's first visit to B selects B's last-used tab (SP-10), so its page shows beside its row. + await expect.poll(async () => Object.values((await state(app)).windows)[0]?.activeTab[sB]).toBe(inB) // Drop at the top of that Space's Today. - const target = await shell.locator(`[data-tab-id="${inB}"]`).boundingBox() + const target = await row(shell, inB).boundingBox() await shell.mouse.move(start.x, (target?.y ?? 0) + 4, { steps: 8 }) await shell.mouse.up() await expect.poll(async () => (await state(app)).spaces[sB]?.today).toEqual([t, inB])