From 5c491b78b5610b3bfa3c69aec5deb3b47fab2bcd Mon Sep 17 00:00:00 2001 From: xarmian Date: Tue, 2 Jun 2026 08:41:54 -0400 Subject: [PATCH] feat(web): mobile bottom navigation (PLAN-1694) (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): mobile bottom navigation + quick-capture (PLAN-1694) Add a mobile-only persistent bottom navigation bar that coexists with the existing TopBar. Five slots: Dashboard, Search, Quick-capture (center οΌ‹), Activity, More. The "More" slot opens a BottomSheet with the sidebar's overflow destinations + collections; the center action opens a quick-capture sheet that creates an item via the existing API. - New shared nav source (lib/nav/destinations.ts) consumed by both the desktop Sidebar and the new BottomNav/More sheet so the two can't drift; Sidebar refactored to derive active-state from it (lossless). - BottomNav mounts in the workspace layout (workspace-only), shows only on mobile (uiStore.isMobile, ≀768px), and toggles body.has-bottom-nav so app.css reflows .main-content above the fixed bar. - iOS: app.html viewport-fit=cover + env(safe-area-inset-bottom) padding. - z-index 40 (above TopBar/sidebar, below sheets). Header retirement + full "You" (workspace/account) consolidation are deliberately deferred to a human design checkpoint (DR-3). Verify: web npm run check (0 errors) + npm run build (βœ“). Codex review loop clean (1 P2 fixed: quick-capture re-defaults a stale collection slug). Refs PLAN-1694 (TASK-1695, TASK-1696, TASK-1697, TASK-1698); parent ROADM-28. * feat(web): retire mobile header in-workspace, add You sheet + contextual bar (PLAN-1694) Complete the mobile nav vision on top of the bottom bar: inside a workspace the global mobile header is gone, replaced by the BottomNav + a "You" sheet + a contextual back/title bar on detail screens. - BottomNav 5th slot More β†’ You (πŸ‘€). New YouSheet composes the workspace switcher, the nav overflow, and the full account menu carried over from the retired TopBar (Workspaces/Settings/Billing/Admin/theme/Resources/Connect/ Sign out) so nothing is lost. - Root layout gates on !inWorkspace (page.params.workspace), keeping it for the non-workspace picker/home which has no BottomNav; the .app-layout top offset now applies only via body.has-mobile-topbar. - New MobileContextBar: fixed back+title bar shown only on detail screens (path depth β‰₯2); body.has-context-bar reflows .main-content top. Root/tab screens render full-height (reclaimed space). Codex consensus + review clean (no P1). P2s fixed: safe in-app-history check via afterNavigate (deep-link fallback to parent URL), humanized title fallback for pages that don't wire titleStore, and the You sheet closes on navigation so a workspace switch dismisses it. Verify: web npm run check (0 errors) + npm run build (βœ“). Refs PLAN-1694 (TASK-1699, TASK-1700); parent ROADM-28. * feat(web): redesign mobile nav into Workspace + You docked sheets (PLAN-1694) Address design feedback: the previous "You" sheet reused common/BottomSheet (covered the nav bar) and embedded the old WorkspaceSwitcher (plain, unadapted). Replace it with two purpose-built surfaces that dock ABOVE the nav. - DockedSheet (new): bottom sheet anchored above the bottom nav β€” backdrop stops at the nav's top edge so the bar stays visible + tappable and the active slot stays lit. ~2/3 height, grab handle, slide-up, swipe-down / tap-out / Escape to dismiss. No more full-screen overlay covering the nav. - Bottom-nav slot 1 Dashboard β†’ Workspace (icon = current workspace avatar), opening WorkspaceSheet: a designed switcher card (inline-expand list) + Navigate tile grid + Collections. - "You" slot β†’ YouSheet, now account-only: profile header, theme toggle, account Settings / Workspaces / Billing / Admin / Connect / Resources / Sign out. Search / οΌ‹ / Activity unchanged. - New avatar util (avatarColor/avatarInitial) for workspace + user avatars. Splits the overstuffed single sheet into two focused surfaces (where-am-I vs me) and removes the reused WorkspaceSwitcher/BottomSheet. Codex review CLEAN. Verify: web npm run check (0 errors) + npm run build (βœ“). Refs PLAN-1694 (TASK-1701); parent ROADM-28. * feat(web): toggle Workspace/You sheets on repeat nav tap (PLAN-1694) Tapping the Workspace or You nav slot a second time now closes its open sheet (tap to open, tap again to close). Opening one still closes the other. The docked backdrop stops above the nav, so the slot button stays tappable while its sheet is open. Refs PLAN-1694 (TASK-1701). * feat(web): dock mobile search to match the nav sheets (PLAN-1694) The CommandPalette was the last mobile surface that didn't match β€” a full-screen takeover (square, no handle, hid the nav). Give its mobile presentation the same docked treatment as the Workspace/You sheets; desktop ⌘K is untouched. - Mobile: dock the palette above the bottom nav (backdrop stops at the nav's top edge so the bar stays visible), rounded top, grab handle with swipe-down-to-dismiss, tap-outside to close, no X. Lift above the nav only when it's present (:global(body.has-bottom-nav)); flush to bottom on the non-workspace picker. Keyboard handling preserved: input stays at the sheet top, .results is the sole scroll area, min-height keeps the input clear of the keyboard when empty. - BottomNav: Search slot toggles (tap to open, tap again to close) and lights up while open; the three surfaces (Workspace / You / Search) are now mutually exclusive. Codex review CLEAN. Verify: web npm run check (0 errors) + build (βœ“). Note: on-screen-keyboard feel should get an on-device smoke test. Refs PLAN-1694 (TASK-1701); parent ROADM-28. --- web/src/app.css | 27 ++ web/src/app.html | 2 +- .../lib/components/layout/BottomNav.svelte | 223 +++++++++++ .../lib/components/layout/DockedSheet.svelte | 134 +++++++ .../components/layout/MobileContextBar.svelte | 155 ++++++++ .../layout/QuickCaptureSheet.svelte | 199 ++++++++++ web/src/lib/components/layout/Sidebar.svelte | 34 +- .../components/layout/WorkspaceSheet.svelte | 362 ++++++++++++++++++ web/src/lib/components/layout/YouSheet.svelte | 308 +++++++++++++++ .../components/search/CommandPalette.svelte | 112 ++++-- web/src/lib/nav/destinations.ts | 96 +++++ web/src/lib/utils/avatar.ts | 32 ++ web/src/routes/+layout.svelte | 29 +- .../[username]/[workspace]/+layout.svelte | 6 + 14 files changed, 1671 insertions(+), 48 deletions(-) create mode 100644 web/src/lib/components/layout/BottomNav.svelte create mode 100644 web/src/lib/components/layout/DockedSheet.svelte create mode 100644 web/src/lib/components/layout/MobileContextBar.svelte create mode 100644 web/src/lib/components/layout/QuickCaptureSheet.svelte create mode 100644 web/src/lib/components/layout/WorkspaceSheet.svelte create mode 100644 web/src/lib/components/layout/YouSheet.svelte create mode 100644 web/src/lib/nav/destinations.ts create mode 100644 web/src/lib/utils/avatar.ts diff --git a/web/src/app.css b/web/src/app.css index 8040e649..5f0e7307 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -27,6 +27,8 @@ --status-archived: #444444; --topbar-height: 44px; + --bottom-nav-height: 56px; + --context-bar-height: 44px; --sidebar-width: 260px; --detail-panel-width: 300px; --content-max-width: 960px; @@ -956,3 +958,28 @@ dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover { } } +/* + Mobile bottom-nav reflow (PLAN-1694). is rendered in the + workspace layout and uses position: fixed; bottom: 0 on mobile. It toggles + `body.has-bottom-nav` while mounted on mobile so the scroll container + (.main-content, defined in the root +layout.svelte) gets bottom padding and + content can't hide under the bar. Gated by the same ≀768px breakpoint + uiStore.isMobile uses, and by the body class so non-workspace shell pages + (which share .main-content but have no bottom nav) aren't padded. The + safe-area inset keeps the last row clear of the iOS home indicator. +*/ +@media (max-width: 768px) { + body.has-bottom-nav .main-content { + padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)); + } + /* + Contextual top bar on detail screens (MobileContextBar, PLAN-1694 + Phase 2). It's position: fixed; top: 0 and toggles body.has-context-bar + while shown, so pad the scroll container's top to match. Same body-class + pattern as the bottom-nav reflow above; safe-area inset clears the notch. + */ + body.has-context-bar .main-content { + padding-top: calc(var(--context-bar-height) + env(safe-area-inset-top, 0px)); + } +} + diff --git a/web/src/app.html b/web/src/app.html index 730a9448..c346a7af 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -2,7 +2,7 @@ - + diff --git a/web/src/lib/components/layout/BottomNav.svelte b/web/src/lib/components/layout/BottomNav.svelte new file mode 100644 index 00000000..1180d3c5 --- /dev/null +++ b/web/src/lib/components/layout/BottomNav.svelte @@ -0,0 +1,223 @@ + + + +{#if uiStore.isMobile && wsPrefix} + + + (workspaceOpen = false)} /> + (youOpen = false)} /> + + (captureOpen = false)} + {wsSlug} + {wsPrefix} + /> +{/if} + + diff --git a/web/src/lib/components/layout/DockedSheet.svelte b/web/src/lib/components/layout/DockedSheet.svelte new file mode 100644 index 00000000..1c217a3d --- /dev/null +++ b/web/src/lib/components/layout/DockedSheet.svelte @@ -0,0 +1,134 @@ + + + + + +{#if open} + + +
+ +{/if} + + diff --git a/web/src/lib/components/layout/MobileContextBar.svelte b/web/src/lib/components/layout/MobileContextBar.svelte new file mode 100644 index 00000000..4f171159 --- /dev/null +++ b/web/src/lib/components/layout/MobileContextBar.svelte @@ -0,0 +1,155 @@ + + + +{#if show} +
+ +

{title}

+ +
+{/if} + + diff --git a/web/src/lib/components/layout/QuickCaptureSheet.svelte b/web/src/lib/components/layout/QuickCaptureSheet.svelte new file mode 100644 index 00000000..09cb53aa --- /dev/null +++ b/web/src/lib/components/layout/QuickCaptureSheet.svelte @@ -0,0 +1,199 @@ + + + + +
+ + +
+ + +
+
+
+ + diff --git a/web/src/lib/components/layout/Sidebar.svelte b/web/src/lib/components/layout/Sidebar.svelte index bd539eaa..cb016f14 100644 --- a/web/src/lib/components/layout/Sidebar.svelte +++ b/web/src/lib/components/layout/Sidebar.svelte @@ -10,6 +10,7 @@ import { goto } from '$app/navigation'; import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client'; import { parseSchema, parseSettings, itemUrlId } from '$lib/types'; + import { getActiveKey } from '$lib/nav/destinations'; import type { Collection } from '$lib/types'; import { toastStore } from '$lib/stores/toast.svelte'; import NotificationPanel from '$lib/components/common/NotificationPanel.svelte'; @@ -29,28 +30,19 @@ let wsUsername = $derived(workspaceStore.current?.owner_username ?? ''); let wsPrefix = $derived(wsUsername && wsSlug ? `/${wsUsername}/${wsSlug}` : ''); let isGuest = $derived(workspaceStore.current?.is_guest ?? false); - let isDashboardPage = $derived(wsPrefix ? page.url.pathname === wsPrefix : false); - let isInsightsPage = $derived(wsPrefix ? page.url.pathname === `${wsPrefix}/insights` : false); - let isRolesPage = $derived(wsPrefix ? page.url.pathname === `${wsPrefix}/roles` : false); - let isActivityPage = $derived(wsPrefix ? page.url.pathname === `${wsPrefix}/activity` : false); - let isStarredPage = $derived(wsPrefix ? page.url.pathname === `${wsPrefix}/starred` : false); - let isTagsPage = $derived( - wsPrefix - ? page.url.pathname === `${wsPrefix}/tags` || - page.url.pathname.startsWith(`${wsPrefix}/tags/`) - : false - ); + // Active-state derives from the shared nav source so the Sidebar and the + // mobile BottomNav/More sheet can't drift (PLAN-1694 DR-1). + let activeKey = $derived(getActiveKey(page.url.pathname, wsPrefix)); + let isDashboardPage = $derived(activeKey === 'dashboard'); + let isInsightsPage = $derived(activeKey === 'insights'); + let isRolesPage = $derived(activeKey === 'roles'); + let isActivityPage = $derived(activeKey === 'activity'); + let isStarredPage = $derived(activeKey === 'starred'); + let isTagsPage = $derived(activeKey === 'tags'); - let activeCollectionSlug = $derived.by(() => { - if (!wsPrefix) return null; - const prefix = `${wsPrefix}/`; - const path = page.url.pathname; - if (!path.startsWith(prefix)) return null; - const rest = path.slice(prefix.length); - const slug = rest.split('/')[0]; - if (slug === 'settings' || slug === 'new' || slug === 'library' || slug === 'activity' || slug === 'starred' || slug === 'tags' || slug === 'roles' || slug === 'insights' || slug === '') return null; - return slug; - }); + let activeCollectionSlug = $derived( + activeKey && activeKey.startsWith('collection:') ? activeKey.slice('collection:'.length) : null + ); let activeColl = $derived( activeCollectionSlug diff --git a/web/src/lib/components/layout/WorkspaceSheet.svelte b/web/src/lib/components/layout/WorkspaceSheet.svelte new file mode 100644 index 00000000..ab72c4ac --- /dev/null +++ b/web/src/lib/components/layout/WorkspaceSheet.svelte @@ -0,0 +1,362 @@ + + + + +
+ + + + {#if switching} +
+ {#each workspaceStore.workspaces as ws (ws.slug)} + + {/each} + +
+ {/if} + + +
Navigate
+
+ {#each navDestinations as dest (dest.key)} + + {/each} +
+ + + {#if regularCollections.length || agentCollections.length} +
Collections
+
+ {#each regularCollections as coll (coll.id)} + + {/each} + {#each agentCollections as coll (coll.id)} + + {/each} +
+ {/if} +
+
+ + diff --git a/web/src/lib/components/layout/YouSheet.svelte b/web/src/lib/components/layout/YouSheet.svelte new file mode 100644 index 00000000..6689d1fd --- /dev/null +++ b/web/src/lib/components/layout/YouSheet.svelte @@ -0,0 +1,308 @@ + + + + +
+ {#if authStore.user} +
+ + {avatarInitial(userName || userEmail)} + + + {userName} + {userEmail} + +
+ +
+ + + + + + Account settings + + + + + Workspaces + + + {#if authStore.cloudMode} + + + Billing + + + {/if} + {#if authStore.user?.role === 'admin'} + + + Admin + + + {/if} + {#if wsSlug} + + {/if} +
+ + +
+ +
+ + + {/if} +
+
+ +{#if wsSlug} + +{/if} + + diff --git a/web/src/lib/components/search/CommandPalette.svelte b/web/src/lib/components/search/CommandPalette.svelte index 6e313440..34ef9737 100644 --- a/web/src/lib/components/search/CommandPalette.svelte +++ b/web/src/lib/components/search/CommandPalette.svelte @@ -55,6 +55,26 @@ let searchTimeout: ReturnType; let inputEl = $state(); + // Mobile: swipe down on the grab handle to dismiss, matching the + // Workspace/You docked sheets (PLAN-1694). Desktop is unaffected β€” the grip + // is display:none above 768px. + let dragY = $state(0); + let dragging = $state(false); + let dragStartY = 0; + function gripStart(e: TouchEvent) { + dragStartY = e.touches[0].clientY; + dragging = true; + } + function gripMove(e: TouchEvent) { + if (!dragging) return; + dragY = Math.max(0, e.touches[0].clientY - dragStartY); + } + function gripEnd() { + dragging = false; + if (dragY > 90) uiStore.closeSearch(); + dragY = 0; + } + // Filters let filterCollection = $state(null); let filterStatus = $state(null); @@ -682,7 +702,26 @@
uiStore.closeSearch()}> -
e.stopPropagation()} onkeydown={handleKeydown}> +
e.stopPropagation()} + onkeydown={handleKeydown} + style:transform={dragY ? `translateY(${dragY}px)` : undefined} + style:transition={dragging ? 'none' : undefined} + > + + +
+ +