feat(web): unified mobile top bar with always-on workspace switcher (IDEA-1835) (#737)

Promote MobileContextBar from a detail-only back+title bar to the persistent
mobile top bar shown on every screen inside a workspace. The collection/item
name now reads the same everywhere and the workspace switcher (moved out of the
bottom-nav sheet) is always visible for at-a-glance context and quick switching.

- MobileContextBar: show on all workspace screens (was depth >= 2); gate the
  back affordance to non-root screens; embed WorkspaceSwitcher on the right,
  restyled onto --bg-tertiary and sized to fill the 44px bar.
- WorkspaceSheet: drop the switcher card + inline list (now in the top bar);
  the "Workspace" bottom-nav slot keeps its label/avatar and Navigate +
  Collections.
- Collection list: hide the now-duplicate in-page collection-name heading on
  mobile; the name lives in the bar.
This commit is contained in:
xarmian
2026-06-17 21:56:25 -04:00
committed by GitHub
parent 8c8e858e1a
commit c93f2aa12c
3 changed files with 78 additions and 217 deletions
@@ -1,13 +1,18 @@
<!--
MobileContextBar — slim contextual top bar for detail screens (PLAN-1694
Phase 2, TASK-1700).
MobileContextBar — the unified mobile top bar (PLAN-1694 Phase 2,
TASK-1700; generalized to all screens in IDEA-1835).
With the global mobile <TopBar /> retired inside workspaces, root/tab
screens render full-height (no top chrome). Drill-in "detail" screens still
need a back affordance + title — that's this bar. Shown only on mobile and
only when the path is ≥2 segments deep past the workspace prefix (item
detail `/coll/slug`, tag detail `/tags/tag`, playbook editor, …). Root tabs
and collection lists (depth 0-1) have their own in-page headers.
Originally a detail-only back+title bar. IDEA-1835 promoted it to a
persistent top bar shown on EVERY mobile screen inside a workspace so the
workspace switcher (moved here from the bottom nav) is always visible and
the collection/item name reads the same everywhere. Layout is
`[back?] [title] [workspace switcher]`:
- back affordance shows only when there's a parent to return to (i.e.
not on the workspace home/root, where `segments` is empty);
- title is the page's own title (item ref → section → humanized last
segment), so collection lists show the collection name and item
details show the ref — no per-page in-page header needed;
- the workspace switcher (name + chevron) opens a BottomSheet to switch.
Mounted in the workspace layout next to <BottomNav />. While shown it
toggles `body.has-context-bar`, which app.css uses to pad .main-content top.
@@ -19,6 +24,7 @@
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { titleStore } from '$lib/stores/title.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
// Count in-app navigations so goBack() knows whether history.back() stays
// inside Pad. history.length is unreliable — a deep link opened from
@@ -43,14 +49,22 @@
let wsUsername = $derived(workspaceStore.current?.owner_username ?? '');
let wsPrefix = $derived(wsUsername && wsSlug ? `/${wsUsername}/${wsSlug}` : '');
// Segments past the workspace prefix. depth ≥ 2 ⇒ a drill-in detail screen.
// Segments past the workspace prefix. Empty ⇒ the workspace home/root.
let segments = $derived.by(() => {
const path = page.url.pathname;
if (!wsPrefix || !path.startsWith(`${wsPrefix}/`)) return [];
return path.slice(wsPrefix.length + 1).split('/').filter(Boolean);
});
let isDetail = $derived(segments.length >= 2);
let show = $derived(uiStore.isMobile && isDetail);
// Shown on every mobile screen inside a workspace (IDEA-1835) — the home
// (path === wsPrefix) plus any deeper route. The back affordance is gated
// separately so the root screen doesn't offer a dead-end "back".
let inWorkspace = $derived(
!!wsPrefix &&
(page.url.pathname === wsPrefix || page.url.pathname.startsWith(`${wsPrefix}/`))
);
let show = $derived(uiStore.isMobile && inWorkspace);
let canGoBack = $derived(segments.length >= 1);
// Deterministic parent (drop the last path segment) — used as a safe
// fallback when there's no in-app history to go back to (deep link).
@@ -87,19 +101,23 @@
{#if show}
<header class="context-bar">
<button class="cb-back" type="button" onclick={goBack} aria-label="Back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M12.5 4L6.5 10L12.5 16"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if canGoBack}
<button class="cb-back" type="button" onclick={goBack} aria-label="Back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M12.5 4L6.5 10L12.5 16"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
<h1 class="cb-title">{title}</h1>
<span class="cb-spacer" aria-hidden="true"></span>
<div class="cb-ws">
<WorkspaceSwitcher mobile={true} />
</div>
</header>
{/if}
@@ -125,8 +143,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
width: 40px;
height: 40px;
background: none;
border: none;
border-radius: var(--radius-sm);
@@ -140,16 +158,32 @@
flex: 1;
min-width: 0;
margin: 0;
font-size: 1em;
font-size: 1.15em;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Balance the back button so the title sits optically centered. */
.cb-spacer {
flex: 0 0 auto;
width: 36px;
/* Workspace switcher, right-anchored. Capped so a long workspace name
can't crowd out the title — the switcher's own `.name` ellipsizes. */
.cb-ws {
flex: 0 1 auto;
min-width: 0;
max-width: 62%;
}
/* The embedded <WorkspaceSwitcher> paints its trigger with the same
--bg-secondary as this bar, so it'd vanish. Lift it onto --bg-tertiary
and size it to fill the bar's height (without growing the bar) so it
reads as a substantial control rather than a small floating chip. */
.cb-ws :global(.current) {
max-width: 100%;
min-height: 34px;
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
font-size: 1em;
}
.cb-ws :global(.current:hover) {
background: var(--bg-hover);
}
</style>
@@ -2,9 +2,12 @@
WorkspaceSheet — the mobile "Workspace" slot surface (PLAN-1694, TASK-1701).
A docked sheet (DockedSheet, anchored above the nav) for moving around the
current workspace: a purpose-built workspace switcher card (tap to expand an
inline list — no reused WorkspaceSwitcher), a Navigate tile grid
(Dashboard/Insights/Roles/Starred/Tags/Settings), and the Collections list.
current workspace: a Navigate tile grid (Dashboard/Insights/Roles/Starred/
Tags/Settings) and the Collections list.
The workspace *switcher* used to live here as a card; IDEA-1835 moved it to
the persistent top bar (MobileContextBar), so this sheet is now navigation
only. The bottom-nav slot keeps its "Workspace" label and avatar.
-->
<script lang="ts">
import { page } from '$app/state';
@@ -12,8 +15,6 @@
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { collectionStore } from '$lib/stores/collections.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
import { avatarColor, avatarInitial } from '$lib/utils/avatar';
import { getPrimaryDestinations, getActiveKey } from '$lib/nav/destinations';
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
@@ -22,9 +23,7 @@
let wsSlug = $derived(workspaceStore.current?.slug);
let wsUsername = $derived(workspaceStore.current?.owner_username ?? '');
let wsPrefix = $derived(wsUsername && wsSlug ? `/${wsUsername}/${wsSlug}` : '');
let wsName = $derived(workspaceStore.current?.name ?? 'Workspace');
let isGuest = $derived(workspaceStore.current?.is_guest ?? false);
let role = $derived(workspaceStore.currentRole);
let activeKey = $derived(getActiveKey(page.url.pathname, wsPrefix));
// Navigate tiles: every static destination except Activity (its own nav
@@ -43,19 +42,7 @@
collectionStore.collections.filter((c) => agentSlugs.includes(c.slug))
);
// Inline switcher list expansion. Reassignable $derived (Svelte 5.25+)
// instead of $state: the card's onclick toggles it by reassignment, and
// it snaps back to collapsed whenever `open` changes. This state lives
// here (not in DockedSheet's children, which unmount on close), so plain
// $state would survive close/reopen and the list would come back stale —
// including via backdrop/swipe dismissal, which no handler in this
// component sees (IDEA-1720).
let switching = $derived.by(() => {
void open;
return false;
});
// Close on navigation (the switcher card navigates on select).
// Close on navigation (the tiles/collections navigate on select).
afterNavigate((nav) => {
if (nav.type !== 'enter' && open) onclose();
});
@@ -65,58 +52,10 @@
uiStore.onNavigate();
goto(href);
}
function selectWorkspace(ws: { slug: string; owner_username?: string }) {
const current = workspaceStore.current;
const isCurrent = !!current && ws.slug === current.slug;
const target = isCurrent
? `/${current.owner_username}/${current.slug}`
: workspaceRestoreTarget(ws);
onclose();
goto(target);
}
function newWorkspace() {
onclose();
uiStore.openCreateWorkspace();
}
</script>
<DockedSheet {open} {onclose} label="Workspace">
<div class="ws">
<!-- Workspace switcher card -->
<button class="ws-card" type="button" onclick={() => (switching = !switching)}>
<span class="ws-avatar" style:background={avatarColor(wsName)}>{avatarInitial(wsName)}</span>
<span class="ws-meta">
<span class="ws-name">{wsName}</span>
{#if role}<span class="ws-role">{role}</span>{/if}
</span>
<span class="ws-switch">Switch <span class="ws-chev" class:up={switching}>⌄</span></span>
</button>
{#if switching}
<div class="ws-list">
{#each workspaceStore.workspaces as ws (ws.slug)}
<button
class="ws-row"
class:active={ws.slug === wsSlug}
type="button"
onclick={() => selectWorkspace(ws)}
>
<span class="ws-row-avatar" style:background={avatarColor(ws.name)}>
{avatarInitial(ws.name)}
</span>
<span class="ws-row-name">{ws.name}</span>
{#if ws.slug === wsSlug}<span class="ws-row-check" aria-hidden="true"></span>{/if}
</button>
{/each}
<button class="ws-row ws-new" type="button" onclick={newWorkspace}>
<span class="ws-row-avatar plus" aria-hidden="true"></span>
<span class="ws-row-name">New workspace</span>
</button>
</div>
{/if}
<!-- Navigate -->
<div class="ws-label">Navigate</div>
<div class="ws-grid">
@@ -171,125 +110,6 @@
padding: 0 var(--space-4);
}
/* Workspace switcher card */
.ws-card {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
}
.ws-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: var(--radius-md, 6px);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 1.1em;
}
.ws-meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.ws-name {
font-weight: 600;
color: var(--text-primary);
font-size: 1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-role {
font-size: 0.75em;
color: var(--text-muted);
text-transform: capitalize;
}
.ws-switch {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
font-size: 0.8em;
color: var(--text-secondary);
}
.ws-chev {
display: inline-block;
transition: transform 0.15s ease;
}
.ws-chev.up {
transform: rotate(180deg);
}
.ws-list {
display: flex;
flex-direction: column;
margin-top: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-md, 6px);
overflow: hidden;
}
.ws-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--text-secondary);
}
.ws-row:hover {
background: var(--bg-hover);
}
.ws-row.active {
color: var(--text-primary);
}
.ws-row-avatar {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 0.8em;
}
.ws-row-avatar.plus {
background: transparent;
border: 1px dashed var(--border);
color: var(--text-muted);
}
.ws-row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95em;
}
.ws-row-check {
color: var(--accent-blue);
font-size: 0.9em;
}
.ws-new .ws-row-name {
color: var(--text-muted);
}
.ws-label {
padding: var(--space-4) var(--space-1) var(--space-2);
font-size: 0.7em;
@@ -2768,6 +2768,13 @@
}
@media (max-width: 768px) {
/* The collection name now lives in the persistent top bar
(MobileContextBar, IDEA-1835) — drop the duplicate in-page heading
but keep the view controls in the row. */
.title-row h1 {
display: none;
}
.title-row {
flex-direction: column;
align-items: flex-start;