From 2174993f689af5a889aed550d74e08ada47442cd Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 28 Sep 2026 11:37:37 -0400 Subject: [PATCH] feat(web): "+" finds a workspace outside your tabs, creates one, or restores one (TASK-3276) (#1639) PLAN-3002 U4, Dave's Q4 ruling. Since U3 the bar shows only the open set, so the TopBar "+" becomes the way to reach any other workspace: - WorkspaceDiscovery: a combobox over a listbox of every visible workspace NOT open as a tab, filtered by name or slug, then "New workspace", then recently deleted. Arrows (wrapping, aria-activedescendant), Enter, Escape (returns focus to "+"), and outside press. Choosing a workspace opens it as a KEPT tab and lands on its last route; a failed open still navigates. - RecentlyDeletedWorkspaces: the deleted-list piece moved out of WorkspaceSwitcher, which now renders it too (U6 will build on the same). - The "+" title is now "Find or create a workspace", since it no longer opens the create modal directly; two e2e specs follow the new path. - Pending invitations are split out to U4b (TASK-3277); "+" keeps a slot. Also: the U3 drag e2e leg released before svelte-dnd-action sampled the final pointer position (1 in 10); it now waits for the drag to engage and for the shadow slot to move before releasing. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 --- web/e2e/workspace-bundle-roundtrip.spec.ts | 7 +- web/e2e/workspace-tabs.spec.ts | 129 ++++++++- .../layout/RecentlyDeletedWorkspaces.svelte | 173 +++++++++++ web/src/lib/components/layout/TopBar.svelte | 30 +- .../components/layout/TopBar.svelte.test.ts | 9 +- .../layout/WorkspaceDiscovery.svelte | 268 ++++++++++++++++++ .../layout/WorkspaceDiscovery.svelte.test.ts | 220 ++++++++++++++ .../layout/WorkspaceSwitcher.svelte | 171 +---------- 8 files changed, 821 insertions(+), 186 deletions(-) create mode 100644 web/src/lib/components/layout/RecentlyDeletedWorkspaces.svelte create mode 100644 web/src/lib/components/layout/WorkspaceDiscovery.svelte create mode 100644 web/src/lib/components/layout/WorkspaceDiscovery.svelte.test.ts diff --git a/web/e2e/workspace-bundle-roundtrip.spec.ts b/web/e2e/workspace-bundle-roundtrip.spec.ts index cf9fd4a6c..8314d30b4 100644 --- a/web/e2e/workspace-bundle-roundtrip.spec.ts +++ b/web/e2e/workspace-bundle-roundtrip.spec.ts @@ -168,7 +168,9 @@ test('workspace export → import round-trip via web UI', async ({ page, fixture await writeFile(bundlePath, bundleBytes); // 3. Open the Create Workspace modal and switch to Import. - // On desktop the TopBar exposes a "+" button (title="New workspace"); + // On desktop the TopBar exposes a "+" button (title="Find or create a + // workspace", PLAN-3002 U4) whose discovery surface has a "New workspace…" + // option; // on mobile that button is hidden behind the layout's collapse. // Try the TopBar trigger first and fall back to opening the // WorkspaceSwitcher dropdown/sheet and clicking its @@ -188,9 +190,10 @@ test('workspace export → import round-trip via web UI', async ({ page, fixture // rendered by the dashboard route the moment hydration completes. await expect(page.getByRole('heading', { name: /E2E Workspace/i })).toBeVisible(); - const topbarTrigger = page.getByTitle('New workspace'); + const topbarTrigger = page.getByTitle('Find or create a workspace'); if (await topbarTrigger.first().isVisible().catch(() => false)) { await topbarTrigger.first().click(); + await page.getByRole('option', { name: /new workspace/i }).click(); } else { // Mobile path: tap the workspace switcher's current-workspace // button to open the picker, then click "+ New Workspace". diff --git a/web/e2e/workspace-tabs.spec.ts b/web/e2e/workspace-tabs.spec.ts index 10054059a..82323fe1f 100644 --- a/web/e2e/workspace-tabs.spec.ts +++ b/web/e2e/workspace-tabs.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type SuiteFixture } from './fixtures'; -import { request, type APIRequestContext, type BrowserContext, type Page } from '@playwright/test'; +import { request, type APIRequestContext, type BrowserContext, type Locator, type Page } from '@playwright/test'; import { quietCrossActorToasts } from './fixtures'; /** @@ -103,6 +103,18 @@ async function barOrder(page: Page): Promise { return tabs(page).evaluateAll((els) => els.map((el) => (el as HTMLElement).dataset.wsSlug ?? '')); } +/** A locator's box once two reads 100 ms apart agree (an animation has settled). */ +async function stableBox(locator: Locator) { + let prev = await locator.boundingBox(); + for (let i = 0; i < 30; i++) { + await locator.page().waitForTimeout(100); + const next = await locator.boundingBox(); + if (prev && next && prev.x === next.x && prev.y === next.y && prev.width === next.width) return next; + prev = next; + } + throw new Error('box never settled'); +} + async function closeTab(page: Page, slug: string) { await tab(page, slug).hover(); await tab(page, slug).locator('.workspace-tab-close').click(); @@ -152,15 +164,25 @@ test.describe('workspace tab bar (TASK-3274)', () => { await expect(tabs(page)).toHaveCount(2); await expect(page).toHaveURL(new RegExp(`/${a}$`)); - // Drag B in front of A, with a real mouse. - const from = await tab(page, b).locator('a').boundingBox(); - const to = await tab(page, a).locator('a').boundingBox(); - if (!from || !to) throw new Error('tab has no box'); + // Drag B in front of A, with a real mouse. The close just reflowed + // the zone with a 150 ms FLIP animation, so the boxes are read only + // once they hold still: a box read mid-animation aims the drag at + // where a tab was, and the drop lands nowhere. + const from = await stableBox(tab(page, b).locator('a')); + const to = await stableBox(tab(page, a).locator('a')); await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); await page.mouse.down(); await page.mouse.move(from.x + from.width / 2 - 10, from.y + from.height / 2, { steps: 4 }); + // The drag has engaged once svelte-dnd-action mounts its clone. + await expect(page.locator('#dnd-action-dragged-el')).toHaveCount(1); await page.mouse.move(to.x + 4, to.y + to.height / 2, { steps: 10 }); + // svelte-dnd-action samples the pointer on an interval, so a + // release straight after the last move can drop before it has seen + // where the pointer is. Hold over the target until the shadow slot + // has moved in front of A, then release. + await expect.poll(() => barOrder(page)).toEqual([b, a]); await page.mouse.up(); + await expect(page.locator('#dnd-action-dragged-el')).toHaveCount(0); await expect.poll(() => barOrder(page)).toEqual([b, a]); await expect.poll(() => serverOrder(account)).toEqual([b, a]); // The drop's synthetic click must not have navigated to B. @@ -278,7 +300,8 @@ test.describe('workspace tab bar (TASK-3274)', () => { await page.goto(`/${account.username}/${home}`); await expect(tabs(page)).toHaveCount(1); - await page.getByTitle('New workspace').click(); + await page.getByTitle('Find or create a workspace').click(); + await page.getByRole('option', { name: /new workspace/i }).click(); const dialog = page.getByRole('dialog', { name: /new workspace/i }); await expect(dialog).toBeVisible(); const name = `Made here ${Date.now()}`; @@ -299,3 +322,97 @@ test.describe('workspace tab bar (TASK-3274)', () => { } }); }); + +/** + * TASK-3276 (PLAN-3002 U4): the "+" discovery surface. Since U3 the bar shows + * only the open set, so "+" is how a workspace outside it is reached. The + * create path is covered above ('a workspace created with "+" …'). + */ +test.describe('"+" discovery surface (TASK-3276)', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'the tab bar is desktop-only (PLAN-3002 Q11)'); + await page.setViewportSize(DESKTOP); + }); + + test('a user with seven workspaces and six tabs reaches the seventh from "+", as a kept tab', async ({ + page, + context, + fixture + }) => { + let world: World | undefined; + try { + world = await seed(fixture, ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seventh']); + const { account, slugs } = world; + const seventh = slugs[6]; + await setOpenSet(account, slugs.slice(0, 6)); + await actAs(context, account); + + await page.goto(`/${account.username}/${slugs[0]}`); + await expect(tabs(page)).toHaveCount(6); + await expect(tab(page, seventh)).toHaveCount(0); + + await page.getByTitle('Find or create a workspace').click(); + const search = page.getByRole('combobox', { name: 'Find a workspace' }); + await expect(search).toBeFocused(); + // The six open workspaces are not offered; the seventh is. + await expect(page.locator('.discovery-option[data-ws-slug]')).toHaveCount(1); + await search.fill('Seventh'); + await page.locator(`.discovery-option[data-ws-slug="${seventh}"]`).click(); + + await expect(page).toHaveURL(new RegExp(`/${account.username}/${seventh}$`)); + await expect(tabs(page)).toHaveCount(7); + await expect(tab(page, seventh)).not.toHaveClass(/ephemeral/); + await expect.poll(() => serverOrder(account)).toContain(seventh); + + await page.reload(); + await expect(tab(page, seventh)).toHaveCount(1); + await expect(tab(page, seventh)).not.toHaveClass(/ephemeral/); + } finally { + await teardown(world); + } + }); + + test('keyboard only: open "+", search, arrow, Enter; and Escape returns focus to "+"', async ({ + page, + context, + fixture + }) => { + let world: World | undefined; + try { + world = await seed(fixture, ['Home', 'Kb Alpha', 'Kb Beta']); + const { account, slugs } = world; + const [home, , beta] = slugs; + await setOpenSet(account, [home]); + await actAs(context, account); + + await page.goto(`/${account.username}/${home}`); + await expect(tabs(page)).toHaveCount(1); + const plus = page.getByTitle('Find or create a workspace'); + const search = page.getByRole('combobox', { name: 'Find a workspace' }); + + // Escape closes and hands focus back to "+". + await plus.focus(); + await page.keyboard.press('Enter'); + await expect(search).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(search).toHaveCount(0); + await expect(plus).toBeFocused(); + + // Open again from the keyboard, narrow to the two "Kb" workspaces, + // step to the second, and take it. + await page.keyboard.press('Enter'); + await expect(search).toBeFocused(); + await page.keyboard.type('Kb'); + await expect(page.locator('.discovery-option[data-ws-slug]')).toHaveCount(2); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('.discovery-option[aria-selected="true"]')).toHaveAttribute('data-ws-slug', beta); + await page.keyboard.press('Enter'); + + await expect(page).toHaveURL(new RegExp(`/${account.username}/${beta}$`)); + await expect(tab(page, beta)).toHaveCount(1); + await expect(tab(page, beta)).not.toHaveClass(/ephemeral/); + } finally { + await teardown(world); + } + }); +}); diff --git a/web/src/lib/components/layout/RecentlyDeletedWorkspaces.svelte b/web/src/lib/components/layout/RecentlyDeletedWorkspaces.svelte new file mode 100644 index 000000000..f11ea584b --- /dev/null +++ b/web/src/lib/components/layout/RecentlyDeletedWorkspaces.svelte @@ -0,0 +1,173 @@ + + +{#if deleted.length > 0} +
+ + {#if expanded} +
+ {#each deleted as ws (ws.slug)} +
+ {ws.name} + + {ws.days_left} {ws.days_left === 1 ? 'day' : 'days'} left + + +
+ {/each} +
+ {/if} +
+{/if} + + diff --git a/web/src/lib/components/layout/TopBar.svelte b/web/src/lib/components/layout/TopBar.svelte index ed3993408..95d6e8737 100644 --- a/web/src/lib/components/layout/TopBar.svelte +++ b/web/src/lib/components/layout/TopBar.svelte @@ -13,6 +13,7 @@ import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte'; import UserMenuResources from '$lib/components/layout/UserMenuResources.svelte'; import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte'; + import WorkspaceDiscovery from '$lib/components/layout/WorkspaceDiscovery.svelte'; import Menu from '$lib/components/common/Menu.svelte'; import MenuItem from '$lib/components/common/MenuItem.svelte'; import { workspaceRestoreTarget } from '$lib/utils/workspace-route'; @@ -23,6 +24,10 @@ let userTriggerEl: HTMLButtonElement | undefined = $state(undefined); let currentTheme = $state<'dark' | 'light'>('dark'); let connectOpen = $state(false); + // The "+" discovery surface (PLAN-3002 U4): find a workspace that is not + // open as a tab, create one, or restore a deleted one. + let discoveryOpen = $state(false); + let addEl: HTMLButtonElement | undefined = $state(undefined); let currentSlug = $derived(workspaceStore.current?.slug ?? ''); @@ -407,13 +412,20 @@ {/each} - +
+ + (discoveryOpen = false)} trigger={addEl} /> +
@@ -676,6 +688,10 @@ text-overflow: ellipsis; } + .workspace-add-anchor { + position: relative; + flex-shrink: 0; + } .workspace-add { display: flex; align-items: center; diff --git a/web/src/lib/components/layout/TopBar.svelte.test.ts b/web/src/lib/components/layout/TopBar.svelte.test.ts index 42ad57c19..31224efec 100644 --- a/web/src/lib/components/layout/TopBar.svelte.test.ts +++ b/web/src/lib/components/layout/TopBar.svelte.test.ts @@ -32,7 +32,7 @@ vi.mock('$app/navigation', () => ({ goto: mocks.goto })); vi.mock('$lib/api/client', () => ({ PadApiError: class extends Error {}, - api: { workspaces: { tabs: mocks.tabs } }, + api: { workspaces: { tabs: mocks.tabs, listDeleted: vi.fn(async () => []) } }, })); vi.mock('$lib/stores/workspace.svelte', () => ({ @@ -239,8 +239,11 @@ describe('TopBar: the Q2 highlight', () => { it('opening the create flow from "+" clears it', async () => { await mountWith([ALPHA]); uiStore.highlightAddWorkspace(); - const add = document.querySelector('.workspace-add')!; - add.click(); + document.querySelector('.workspace-add')!.click(); + await tick(); + flushSync(); + // "+" opens the discovery surface (U4); its last option is create. + document.querySelector('.discovery-option.create')!.click(); flushSync(); expect(uiStore.addWorkspaceHighlighted).toBe(false); uiStore.closeCreateWorkspace(); diff --git a/web/src/lib/components/layout/WorkspaceDiscovery.svelte b/web/src/lib/components/layout/WorkspaceDiscovery.svelte new file mode 100644 index 000000000..3418d1ae3 --- /dev/null +++ b/web/src/lib/components/layout/WorkspaceDiscovery.svelte @@ -0,0 +1,268 @@ + + +{#if open} +
[trigger], + }} + > + +
    + {#each options as option, i (option.kind === 'workspace' ? option.ws.slug : '__create')} + + +
  • e.preventDefault()} + onmousemove={() => (activeIndex = i)} + onclick={() => activate(option)} + > + {#if option.kind === 'workspace'} + {option.ws.name} + {#if option.ws.is_guest}Shared with you{/if} + {:else} + + New workspace… + {/if} +
  • + {/each} +
+ {#if matches.length === 0} +

+ {query.trim() ? 'No workspace outside your tabs matches.' : 'Every workspace is already open.'} +

+ {/if} + + +
+{/if} + + diff --git a/web/src/lib/components/layout/WorkspaceDiscovery.svelte.test.ts b/web/src/lib/components/layout/WorkspaceDiscovery.svelte.test.ts new file mode 100644 index 000000000..444bc882d --- /dev/null +++ b/web/src/lib/components/layout/WorkspaceDiscovery.svelte.test.ts @@ -0,0 +1,220 @@ +// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`). +// +// TASK-3276 (PLAN-3002 U4): the TopBar "+" discovery surface. It lists the +// workspaces that are NOT open as tabs, searches them, opens one as a KEPT tab +// and lands on its last route, and offers create. Driven through the real +// tabs store over a mocked API, so "not open" means what a committed server +// answer says. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/svelte'; +import { tick, flushSync } from 'svelte'; + +const mocks = vi.hoisted(() => ({ + goto: vi.fn(async () => {}), + workspaces: [] as { id: string; slug: string; name: string; owner_username: string; is_guest?: boolean }[], + tabs: { + list: vi.fn(), + open: vi.fn(), + close: vi.fn(), + reorder: vi.fn(), + update: vi.fn(), + }, + listDeleted: vi.fn(async () => []), +})); + +vi.mock('$app/navigation', () => ({ goto: mocks.goto })); + +vi.mock('$lib/api/client', () => ({ + PadApiError: class extends Error {}, + api: { workspaces: { tabs: mocks.tabs, listDeleted: mocks.listDeleted, restore: vi.fn() } }, +})); + +vi.mock('$lib/stores/workspace.svelte', () => ({ + workspaceStore: { + get workspaces() { + return mocks.workspaces; + }, + current: null, + loadAll: vi.fn(async () => {}), + }, +})); + +import WorkspaceDiscovery from './WorkspaceDiscovery.svelte'; +import { tabsStore } from '$lib/stores/tabs.svelte'; +import { uiStore } from '$lib/stores/ui.svelte'; + +const ws = (slug: string, name: string, extra: { is_guest?: boolean } = {}) => ({ + id: slug, + slug, + name, + owner_username: 'u', + ...extra, +}); + +function answer(slugs: string[], lastRoutes: Record = {}) { + return { + tabs: slugs.map((slug, i) => ({ + slug, + name: slug, + owner_username: 'u', + is_guest: false, + ephemeral: false, + position: i, + created_at: '', + updated_at: '', + ...(lastRoutes[slug] ? { last_route: lastRoutes[slug] } : {}), + })), + }; +} + +let onclose: ReturnType; +let trigger: HTMLButtonElement; + +async function mountOpen(openSlugs: string[]) { + mocks.tabs.list.mockResolvedValueOnce(answer(openSlugs)); + await tabsStore.load(); + trigger = document.createElement('button'); + document.body.appendChild(trigger); + onclose = vi.fn(); + render(WorkspaceDiscovery, { props: { open: true, onclose, trigger } }); + await tick(); + await tick(); + flushSync(); +} + +const input = () => document.querySelector('.discovery-search')!; +const optionSlugs = () => + Array.from(document.querySelectorAll('.discovery-option[data-ws-slug]')).map( + (el) => el.dataset.wsSlug + ); +const activeOption = () => document.querySelector('.discovery-option[aria-selected="true"]'); + +async function key(k: string) { + await fireEvent.keyDown(input(), { key: k }); + flushSync(); +} + +async function settle() { + for (let i = 0; i < 6; i++) await Promise.resolve(); + await tick(); + flushSync(); +} + +beforeEach(() => { + mocks.goto.mockClear(); + for (const fn of Object.values(mocks.tabs)) fn.mockReset(); + mocks.workspaces = [ws('alpha', 'Alpha'), ws('beta', 'Beta'), ws('gamma', 'Gamma'), ws('delta', 'Delta', { is_guest: true })]; + try { + localStorage.clear(); + } catch {} +}); + +afterEach(() => { + cleanup(); + uiStore.closeCreateWorkspace(); + document.body.innerHTML = ''; +}); + +describe('WorkspaceDiscovery: what it lists', () => { + it('lists only the workspaces that are not open as tabs, then create', async () => { + await mountOpen(['alpha', 'gamma']); + expect(optionSlugs()).toEqual(['beta', 'delta']); + expect(document.querySelector('.discovery-option.create')).not.toBeNull(); + }); + + it('focuses the search when it opens', async () => { + await mountOpen([]); + expect(document.activeElement).toBe(input()); + }); + + it('filters by name or slug, case-insensitively', async () => { + await mountOpen([]); + await fireEvent.input(input(), { target: { value: 'ALP' } }); + flushSync(); + expect(optionSlugs()).toEqual(['alpha']); + // A slug that shares nothing with its name, so a name-only filter + // cannot pass this half. + mocks.workspaces = [...mocks.workspaces, ws('ops-2024', 'Operations')]; + flushSync(); + await fireEvent.input(input(), { target: { value: 'ops-20' } }); + flushSync(); + expect(optionSlugs()).toEqual(['ops-2024']); + }); + + it('says when nothing outside the tabs matches', async () => { + await mountOpen([]); + await fireEvent.input(input(), { target: { value: 'zzz' } }); + flushSync(); + expect(optionSlugs()).toEqual([]); + expect(document.querySelector('.discovery-empty')?.textContent).toMatch(/no workspace/i); + }); + + it('marks a guest workspace with the shared wording', async () => { + await mountOpen(['alpha', 'beta', 'gamma']); + expect(document.querySelector('.discovery-option[data-ws-slug="delta"] .discovery-meta')?.textContent).toBe( + 'Shared with you' + ); + }); +}); + +describe('WorkspaceDiscovery: the keyboard path', () => { + it('arrows move the active option, wrapping, and the input names it', async () => { + await mountOpen(['gamma', 'delta']); + // Options: alpha, beta, create. + expect(activeOption()?.dataset.wsSlug).toBe('alpha'); + await key('ArrowDown'); + expect(activeOption()?.dataset.wsSlug).toBe('beta'); + await key('ArrowDown'); + expect(activeOption()?.classList.contains('create')).toBe(true); + await key('ArrowDown'); + expect(activeOption()?.dataset.wsSlug).toBe('alpha'); + await key('ArrowUp'); + expect(activeOption()?.classList.contains('create')).toBe(true); + expect(input().getAttribute('aria-activedescendant')).toBe(activeOption()?.id); + }); + + it('Enter opens the active workspace as a KEPT tab, then lands on its last route', async () => { + await mountOpen(['alpha']); + mocks.tabs.open.mockResolvedValueOnce(answer(['alpha', 'beta'], { beta: '/u/beta/docs' })); + await fireEvent.input(input(), { target: { value: 'bet' } }); + flushSync(); + await key('Enter'); + await settle(); + expect(mocks.tabs.open).toHaveBeenCalledWith('beta', false); + expect(onclose).toHaveBeenCalled(); + expect(mocks.goto).toHaveBeenCalledWith('/u/beta/docs'); + }); + + it('a failed open still navigates', async () => { + await mountOpen([]); + mocks.tabs.open.mockRejectedValueOnce(new TypeError('network down')); + await key('Enter'); + await settle(); + expect(mocks.goto).toHaveBeenCalledWith('/u/alpha'); + }); + + it('Enter on create opens the create flow', async () => { + await mountOpen(['alpha', 'beta', 'gamma', 'delta']); + await key('Enter'); + expect(uiStore.createWorkspaceOpen).toBe(true); + expect(mocks.tabs.open).not.toHaveBeenCalled(); + }); + + it('Escape closes and returns focus to "+"', async () => { + await mountOpen([]); + await key('Escape'); + expect(onclose).toHaveBeenCalled(); + expect(document.activeElement).toBe(trigger); + }); +}); + +describe('WorkspaceDiscovery: the pointer path', () => { + it('a click on a workspace opens it the same way', async () => { + await mountOpen([]); + mocks.tabs.open.mockResolvedValueOnce(answer(['gamma'])); + document.querySelector('.discovery-option[data-ws-slug="gamma"]')!.click(); + await settle(); + expect(mocks.tabs.open).toHaveBeenCalledWith('gamma', false); + expect(mocks.goto).toHaveBeenCalledWith('/u/gamma'); + }); +}); diff --git a/web/src/lib/components/layout/WorkspaceSwitcher.svelte b/web/src/lib/components/layout/WorkspaceSwitcher.svelte index 2e7dce7d3..e844d5d67 100644 --- a/web/src/lib/components/layout/WorkspaceSwitcher.svelte +++ b/web/src/lib/components/layout/WorkspaceSwitcher.svelte @@ -1,13 +1,11 @@ {#snippet workspaceList()} @@ -152,46 +85,6 @@ {/snippet} -{#snippet deletedSection()} - - {#if deleted.length > 0} -
- - {#if deletedExpanded} -
- {#each deleted as ws (ws.slug)} -
- {ws.name} - - {ws.days_left} {ws.days_left === 1 ? 'day' : 'days'} left - - -
- {/each} -
- {/if} -
- {/if} -{/snippet} -
@@ -295,62 +188,4 @@ border-radius: var(--radius-sm); } - /* ── Recently deleted ─────────────────────────────────────────────── */ - .deleted-section { border-top: 1px solid var(--border); } - .deleted-header { - display: flex; - align-items: center; - gap: var(--space-2); - color: var(--text-muted); - font-size: 0.8em; - text-transform: uppercase; - letter-spacing: 0.03em; - } - .deleted-caret { font-size: 0.9em; flex-shrink: 0; } - .deleted-title { flex: 1; min-width: 0; } - .deleted-count { - flex-shrink: 0; - padding: 0 var(--space-2); - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - font-size: 0.9em; - } - .deleted-list { display: flex; flex-direction: column; } - .deleted-item { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - font-size: 0.9em; - } - .deleted-name { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-secondary); - } - .deleted-days { - flex-shrink: 0; - color: var(--text-muted); - font-size: 0.85em; - white-space: nowrap; - } - .restore-btn { - flex-shrink: 0; - padding: var(--space-1) var(--space-2); - background: none; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--accent-blue); - cursor: pointer; - font-size: 0.85em; - } - .restore-btn:hover:not(:disabled) { background: var(--bg-hover); } - .restore-btn:disabled { opacity: 0.6; cursor: default; } - - /* Inside the mobile sheet, give the deleted rows the same roomier - padding as the workspace rows above. */ - .sheet-body .deleted-item { padding: var(--space-2) var(--space-3); }