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 @@