From 9810695782262650014a7000e790bf31d3a5d6eb Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Tue, 8 Sep 2026 17:23:22 +0000 Subject: [PATCH] Open the space menu in a drawer instead of navigating away from the room (#425) --- AGENTS.md | 2 + e2e/USER_STORIES.md | 4 +- e2e/specs/notifications.spec.ts | 20 +++-- e2e/specs/rooms.spec.ts | 3 +- e2e/specs/routing.spec.ts | 34 ++++++++ e2e/specs/space.spec.ts | 79 +++++++++++++++++++ src/app/components/CallBanner.svelte | 4 +- src/app/components/Chat.svelte | 5 +- src/app/components/ComposeMenu.svelte | 5 +- src/app/components/ContentMarkdown.svelte | 4 +- src/app/components/MenuSettings.svelte | 11 +-- src/app/components/ModalContainer.svelte | 2 +- src/app/components/PinboardEdit.svelte | 4 +- src/app/components/PrimaryNav.svelte | 44 ++++++++--- src/app/components/PrimaryNavItemSpace.svelte | 4 +- src/app/components/PrimaryNavSpaces.svelte | 10 ++- src/app/components/ProfileDetail.svelte | 5 +- src/app/components/RecentConversation.svelte | 4 +- src/app/components/RoomChat.svelte | 4 +- src/app/components/RoomCreate.svelte | 4 +- src/app/components/RoomDetailMenu.svelte | 5 +- src/app/components/RoomEdit.svelte | 4 +- src/app/components/Share.svelte | 2 +- src/app/components/ShareEvent.svelte | 2 +- src/app/components/SpaceAuthError.svelte | 7 +- src/app/components/SpaceBar.svelte | 12 +-- src/app/components/SpaceExit.svelte | 4 +- src/app/components/SpaceInviteAccept.svelte | 4 +- .../components/SpaceMenuActionsMobile.svelte | 7 +- src/app/components/SpaceMenuDrawer.svelte | 38 +++++++++ src/app/components/SpaceMenuHeader.svelte | 2 +- src/app/components/SpaceMenuRoomItem.svelte | 7 +- src/app/components/SpaceTrustRelay.svelte | 5 +- src/app/components/ThreadBoardItem.svelte | 4 +- src/app/components/VoiceRoomItem.svelte | 9 +-- src/app/components/hosting/RelayCreate.svelte | 5 +- src/app/modal.ts | 11 +++ src/app/push/adapters/common.ts | 6 +- src/app/routes.ts | 28 ++++--- src/app/share.ts | 7 +- src/lib/components/Drawer.svelte | 8 +- src/lib/components/Link.svelte | 4 +- src/lib/components/PrimaryNavItem.svelte | 4 +- src/lib/components/SecondaryNavItem.svelte | 16 +--- src/lib/components/base.css | 2 + src/lib/components/primary-nav.css | 6 +- src/lib/transition.ts | 14 +++- src/routes/spaces/[relay]/about/+page.svelte | 19 +++-- src/routes/spaces/[relay]/admin/+page.svelte | 19 ++--- 49 files changed, 346 insertions(+), 167 deletions(-) create mode 100644 src/app/components/SpaceMenuDrawer.svelte diff --git a/AGENTS.md b/AGENTS.md index 1dff8651..424a4b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,8 @@ return a `Command`, so `.then(publish)` is usually all you need. - Import from `app/modal.ts` or `app/toast.ts` - Pass component objects with parameters - Use `$state.snapshot` if calling component might unmount +- Navigate with `navigate` from `app/modal.ts` rather than `goto` — an open modal owns the current + history entry, so a navigation that drops it replaces that entry instead of stacking on it ## Development Workflow diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 6f9943ad..26c1bcd6 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1565,14 +1565,14 @@ Acceptance: ### US-110 — See another space's unread activity from a phone -As bob on a phone, I want the bottom bar's Spaces button to tell me another +As bob on a phone, I want the bottom bar's space-menu button to tell me another space wants attention, so that I don't have to leave the room I'm reading to find out. Acceptance: - While bob has a room open, a message in a space he isn't in raises an unread - dot on the Spaces button, matching the dot that space's row carries in + dot on the space-menu button, matching the dot that space's row carries in `/spaces`. - A message in another room of the space he's already in does not; that space's own room list carries it. diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index f326ab70..c5a988d0 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -41,10 +41,9 @@ const settingRow = (page: Page, label: string) => const spaceNavItem = (page: Page, name: string) => page.locator(`.primary-nav [data-tip^="${name}"]`) -// The desktop rail and the phone's bottom bar each carry a link to the space list, and neither one -// has an accessible name — both icons are masked svgs. Below tailwind's md breakpoint the rail is -// display:none, so on a phone the visible one is the bar's. -const spacesNavItem = (page: Page) => page.locator('a[href="/spaces"]:visible') +// The phone's bottom bar opens the space menu in a drawer; the desktop rail has no equivalent, +// since the menu is always on screen there. +const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"}) // The space menu's header, the one button in the secondary nav carrying the relay's address. const spaceMenu = (page: Page, url: string) => @@ -53,8 +52,7 @@ const spaceMenu = (page: Page, url: string) => const roomLink = (page: Page, name: string) => page.locator(".space-menu__scroll").getByRole("link", {name}) -// The room's page bar carries a back arrow (display:none at this viewport), a search button and the -// detail button, in that order. +// The room's page bar carries a search button and the detail button, in that order. const openRoomDetail = (page: Page) => page.locator('[data-component="PageBar"]').getByRole("button").last().click() @@ -448,16 +446,16 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as context: {viewport: {width: 390, height: 844}, hasTouch: true}, }) - const spacesButton = spacesNavItem(bob) + const menuButton = spaceMenuNavItem(bob) - await expect(spacesButton).toBeVisible() - await expect(unreadDot(spacesButton)).toHaveCount(0) + await expect(menuButton).toBeVisible() + await expect(unreadDot(menuButton)).toHaveCount(0) const inOther = await as(users.alice, roomPath(other.url, "general")) await send(inOther, "the server is on fire") - await expect(unreadDot(spacesButton)).toBeVisible() + await expect(unreadDot(menuButton)).toBeVisible() // Meanwhile the space bob is sitting in gets a message too, in a room he isn't reading const inSpace = await as(users.alice, roomPath(space.url, "random")) @@ -473,7 +471,7 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as await bob.goto(spacePath(space.url)) await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() - await expect(unreadDot(spacesButton)).toHaveCount(0) + await expect(unreadDot(menuButton)).toHaveCount(0) }) // SpaceMenuNavItems hides a content type until the space has an event of that kind, so this link diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index fe9a235e..0bf2d4f9 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -43,8 +43,7 @@ const openMessageMenu = (page: Page, text: string) => messageActions(page, text) // left corner of the viewport is outside it. const dismissMenu = (page: Page) => page.mouse.move(0, 0) -// The room's page bar carries a back arrow, a search button and the detail button, in that order. -// The back arrow is display:none at this viewport, so a role query sees the other two. +// The room's page bar carries a search button and the detail button, in that order. const pageBar = (page: Page) => page.locator('[data-component="PageBar"]') const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click() diff --git a/e2e/specs/routing.spec.ts b/e2e/specs/routing.spec.ts index 3e44977b..d68feaf3 100644 --- a/e2e/specs/routing.spec.ts +++ b/e2e/specs/routing.spec.ts @@ -112,3 +112,37 @@ test("does not stack a history entry for the space you are already in", async ({ await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) }) + +test("takes over the space menu's history entry when you navigate out of it", async ({ + seed, + as, +}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("lounge", {name: "Space Lounge"}) + space.room("garden", {name: "Space Garden"}) + space.join(user.alice, "lounge") + space.join(user.alice, "garden") + }) + + const space = scenario.space("space") + const page = await as(users.alice, roomPath(space.url, "lounge"), { + context: {viewport: {width: 390, height: 844}, hasTouch: true}, + }) + + const drawer = page.locator(".drawer") + + await page.getByRole("button", {name: "Open space menu"}).click() + await drawer.getByRole("link", {name: "Space Garden"}).click() + + await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) + await expect(drawer).toHaveCount(0) + + // The menu held the entry the room it opened now holds, so one step back is the room it opened + // over. Stacked instead, this lands on the menu again. + await page.goBack() + + await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) + await expect(drawer).toHaveCount(0) +}) diff --git a/e2e/specs/space.spec.ts b/e2e/specs/space.spec.ts index 51882fd4..5e115991 100644 --- a/e2e/specs/space.spec.ts +++ b/e2e/specs/space.spec.ts @@ -24,3 +24,82 @@ test("renders a seeded space, its rooms and its messages", async ({seed, as}) => await expect(page.getByText("morning all")).toBeVisible() await expect(page.getByText("morning!")).toBeVisible() }) + +test("opens the space menu in a drawer on a phone", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general"), { + context: {viewport: {width: 390, height: 844}, hasTouch: true}, + }) + + const drawer = page.locator(".drawer") + + await expect(drawer).toHaveCount(0) + + await page.getByRole("button", {name: "Open space menu"}).click() + + await expect(drawer.getByRole("link", {name: "General"})).toBeVisible() + + // The space rail sits beside the menu inside the panel, so the menu gets what the rail leaves. + // Sized to the panel instead, it runs off the right of the screen. + await expect(drawer.locator(".space-menu")).toBeInViewport({ratio: 1}) + + // The panel is full width, so the only way back out is the bottom bar, which the drawer stops + // short of rather than covering. Playwright's hit-target check is what proves it: a drawer over + // the bar would take the click itself. + const closeButton = page.getByRole("button", {name: "Close space menu"}) + + await closeButton.click() + + await expect(drawer).toHaveCount(0) + + await page.getByRole("button", {name: "Open space menu"}).click() + + await expect(drawer.getByRole("link", {name: "General"})).toBeVisible() + + // The modal stack lives in the url hash, so the drawer is a history entry and back closes it. + await page.goBack() + + await expect(drawer).toHaveCount(0) + + // The button is the bottom bar's rather than the page's, so it still opens the menu from a page + // that is in no space at all. + await page.locator('a[href="/people"]:visible').click() + + await page.getByRole("button", {name: "Open space menu"}).click() + + await expect(drawer.getByRole("link", {name: "General"})).toBeVisible() +}) + +test("keeps the drawer's space rail free of a horizontal scrollbar", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + + // No touch, so the app is not in mobile mode and hover tooltips are live. The rail scrolls + // vertically, which makes it scroll horizontally too, and a tooltip is 140px wider than it is. + const page = await as(users.alice, roomPath(url, "general"), { + context: {viewport: {width: 390, height: 844}}, + }) + + await page.getByRole("button", {name: "Open space menu"}).click() + + const rail = page.locator(".drawer .primary-nav") + + await expect(rail).toBeVisible() + + const overflow = await rail.evaluate(el => el.scrollWidth - el.clientWidth) + + expect(overflow).toBe(0) +}) diff --git a/src/app/components/CallBanner.svelte b/src/app/components/CallBanner.svelte index 5b272d24..ffe76ded 100644 --- a/src/app/components/CallBanner.svelte +++ b/src/app/components/CallBanner.svelte @@ -1,6 +1,6 @@ diff --git a/src/app/components/MenuSettings.svelte b/src/app/components/MenuSettings.svelte index 790286b9..adf726ef 100644 --- a/src/app/components/MenuSettings.svelte +++ b/src/app/components/MenuSettings.svelte @@ -21,12 +21,11 @@
- +
@@ -34,7 +33,6 @@ {#if Capacitor.getPlatform() !== "ios"} @@ -42,21 +40,18 @@ {/if} Hosting Content @@ -64,9 +59,9 @@
- Theme + Theme / - About + About /
diff --git a/src/app/components/ModalContainer.svelte b/src/app/components/ModalContainer.svelte index 32e4cd41..bc1aa93c 100644 --- a/src/app/components/ModalContainer.svelte +++ b/src/app/components/ModalContainer.svelte @@ -61,4 +61,4 @@ -
+
diff --git a/src/app/components/PinboardEdit.svelte b/src/app/components/PinboardEdit.svelte index c0f9ead6..59763142 100644 --- a/src/app/components/PinboardEdit.svelte +++ b/src/app/components/PinboardEdit.svelte @@ -1,5 +1,5 @@ -
+