mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
feat(web): mobile bottom navigation (PLAN-1694) (#692)
* 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 <TopBar mobile /> 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.
This commit is contained in:
@@ -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). <BottomNav /> 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon-16x16.png" type="image/png" sizes="16x16" />
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<!--
|
||||
BottomNav — mobile-only persistent bottom navigation (PLAN-1694).
|
||||
|
||||
Five slots: Workspace · Search · Quick-capture (center +) · Activity · You.
|
||||
"Workspace" and "You" open docked sheets (DockedSheet) that anchor ABOVE
|
||||
this bar, so the nav stays visible and the active slot stays lit:
|
||||
- WorkspaceSheet — switcher card + Navigate + Collections.
|
||||
- YouSheet — account (profile, theme, settings, sign out, …).
|
||||
Together with the contextual bar they replace the retired mobile <TopBar />
|
||||
inside a workspace (PLAN-1694 Phase 2-3, redesigned in TASK-1701).
|
||||
|
||||
Rendered in the workspace layout; shows only on mobile (uiStore.isMobile).
|
||||
Toggles `body.has-bottom-nav` so app.css reflows .main-content.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { avatarColor, avatarInitial } from '$lib/utils/avatar';
|
||||
import { getActiveKey } from '$lib/nav/destinations';
|
||||
import QuickCaptureSheet from '$lib/components/layout/QuickCaptureSheet.svelte';
|
||||
import WorkspaceSheet from '$lib/components/layout/WorkspaceSheet.svelte';
|
||||
import YouSheet from '$lib/components/layout/YouSheet.svelte';
|
||||
|
||||
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 activeKey = $derived(getActiveKey(page.url.pathname, wsPrefix));
|
||||
|
||||
let workspaceOpen = $state(false);
|
||||
let youOpen = $state(false);
|
||||
let captureOpen = $state(false);
|
||||
|
||||
// "Workspace" owns all in-workspace navigation except Activity (its own
|
||||
// slot), so light it whenever the active page is workspace content.
|
||||
let onWorkspaceContent = $derived(!!activeKey && activeKey !== 'activity');
|
||||
|
||||
// Tap toggles: a second tap on the active slot closes its surface. Opening
|
||||
// any of the three surfaces (Workspace / You / Search) closes the others.
|
||||
function toggleWorkspace() {
|
||||
youOpen = false;
|
||||
uiStore.closeSearch();
|
||||
workspaceOpen = !workspaceOpen;
|
||||
}
|
||||
function toggleYou() {
|
||||
workspaceOpen = false;
|
||||
uiStore.closeSearch();
|
||||
youOpen = !youOpen;
|
||||
}
|
||||
function toggleSearch() {
|
||||
if (uiStore.searchOpen) {
|
||||
uiStore.closeSearch();
|
||||
} else {
|
||||
workspaceOpen = false;
|
||||
youOpen = false;
|
||||
uiStore.openSearch();
|
||||
uiStore.onNavigate();
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the .main-content reflow only while shown (mobile). app.css owns
|
||||
// the media-gated padding rule.
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.classList.toggle('has-bottom-nav', uiStore.isMobile);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (typeof document !== 'undefined') document.body.classList.remove('has-bottom-nav');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if uiStore.isMobile && wsPrefix}
|
||||
<nav class="bottom-nav" aria-label="Primary">
|
||||
<button
|
||||
class="bn-item"
|
||||
class:active={onWorkspaceContent || workspaceOpen}
|
||||
type="button"
|
||||
onclick={toggleWorkspace}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={workspaceOpen}
|
||||
>
|
||||
<span class="bn-avatar" style:background={avatarColor(wsName)} aria-hidden="true">
|
||||
{avatarInitial(wsName)}
|
||||
</span>
|
||||
<span class="bn-label">Workspace</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="bn-item"
|
||||
class:active={uiStore.searchOpen}
|
||||
type="button"
|
||||
onclick={toggleSearch}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={uiStore.searchOpen}
|
||||
>
|
||||
<span class="bn-icon" aria-hidden="true">🔍</span>
|
||||
<span class="bn-label">Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="bn-item bn-capture"
|
||||
type="button"
|
||||
onclick={() => (captureOpen = true)}
|
||||
aria-label="Quick capture"
|
||||
>
|
||||
<span class="bn-capture-plus" aria-hidden="true">+</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="bn-item"
|
||||
class:active={activeKey === 'activity'}
|
||||
href={`${wsPrefix}/activity`}
|
||||
onclick={() => uiStore.onNavigate()}
|
||||
>
|
||||
<span class="bn-icon" aria-hidden="true">📋</span>
|
||||
<span class="bn-label">Activity</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="bn-item"
|
||||
class:active={youOpen}
|
||||
type="button"
|
||||
onclick={toggleYou}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={youOpen}
|
||||
>
|
||||
<span class="bn-icon" aria-hidden="true">👤</span>
|
||||
<span class="bn-label">You</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<WorkspaceSheet open={workspaceOpen} onclose={() => (workspaceOpen = false)} />
|
||||
<YouSheet open={youOpen} onclose={() => (youOpen = false)} />
|
||||
|
||||
<QuickCaptureSheet
|
||||
open={captureOpen}
|
||||
onclose={() => (captureOpen = false)}
|
||||
{wsSlug}
|
||||
{wsPrefix}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40; /* above content; sheets dock above at 45/46 but never cover this bar */
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.bn-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-ui);
|
||||
min-width: 0;
|
||||
}
|
||||
.bn-item.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bn-icon {
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
.bn-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.bn-item.active .bn-avatar {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-blue) 55%, transparent);
|
||||
}
|
||||
.bn-label {
|
||||
font-size: 0.68em;
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Center capture action — a raised pill so it reads as the primary action. */
|
||||
.bn-capture {
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
}
|
||||
.bn-capture-plus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
font-size: 1.4em;
|
||||
line-height: 1;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<!--
|
||||
DockedSheet — a bottom sheet that docks ABOVE the mobile bottom nav
|
||||
(PLAN-1694). Unlike common/BottomSheet (a full-screen z-50 overlay that
|
||||
covers everything), this anchors its bottom edge at the top of the nav bar,
|
||||
so the BottomNav stays visible + tappable and the originating slot stays
|
||||
lit. ~2/3 viewport height, grab handle, slide-up, swipe-down / tap-out /
|
||||
Escape to dismiss.
|
||||
|
||||
Used by WorkspaceSheet and YouSheet — the two purpose-designed mobile nav
|
||||
surfaces. Pure shell; callers provide the content via the `children` snippet.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
let {
|
||||
open,
|
||||
onclose,
|
||||
label = 'Menu',
|
||||
children
|
||||
}: {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
label?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
// Swipe-down-to-dismiss: track the drag offset and apply it as a transform
|
||||
// on the panel; release past the threshold closes, otherwise it snaps back.
|
||||
let dragY = $state(0);
|
||||
let dragging = $state(false);
|
||||
let startY = 0;
|
||||
const DISMISS_PX = 90;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
startY = e.touches[0].clientY;
|
||||
dragging = true;
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!dragging) return;
|
||||
dragY = Math.max(0, e.touches[0].clientY - startY);
|
||||
}
|
||||
function onTouchEnd() {
|
||||
dragging = false;
|
||||
if (dragY > DISMISS_PX) {
|
||||
onclose();
|
||||
}
|
||||
dragY = 0;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (open && e.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="ds-backdrop" onclick={onclose} transition:fade={{ duration: 160 }}></div>
|
||||
<div
|
||||
class="ds-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
style:transform={dragY ? `translateY(${dragY}px)` : undefined}
|
||||
style:transition={dragging ? 'none' : undefined}
|
||||
transition:fly={{ y: 360, duration: 240, easing: cubicOut }}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="ds-grip"
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchmove={onTouchMove}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
<span class="ds-handle" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="ds-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.ds-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Stop above the nav so it's never covered (and stays tappable). */
|
||||
bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 45;
|
||||
}
|
||||
.ds-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 46;
|
||||
max-height: 66vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.45);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.ds-grip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) 0;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.ds-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
}
|
||||
.ds-content {
|
||||
overflow-y: auto;
|
||||
padding: 0 0 var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
MobileContextBar — slim contextual top bar for detail screens (PLAN-1694
|
||||
Phase 2, TASK-1700).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
|
||||
// Count in-app navigations so goBack() knows whether history.back() stays
|
||||
// inside Pad. history.length is unreliable — a deep link opened from
|
||||
// another site/app still has prior history (Codex review, PLAN-1694).
|
||||
let inAppNavs = $state(0);
|
||||
afterNavigate((nav) => {
|
||||
if (nav.type !== 'enter') inAppNavs += 1;
|
||||
});
|
||||
|
||||
function humanize(seg: string | undefined): string {
|
||||
if (!seg) return '';
|
||||
let s = seg;
|
||||
try {
|
||||
s = decodeURIComponent(seg);
|
||||
} catch {
|
||||
// leave as-is on malformed escape sequences
|
||||
}
|
||||
return s.replace(/[-_]/g, ' ');
|
||||
}
|
||||
|
||||
let wsSlug = $derived(workspaceStore.current?.slug);
|
||||
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.
|
||||
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);
|
||||
|
||||
// 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).
|
||||
let parentHref = $derived.by(() => {
|
||||
const parts = page.url.pathname.split('/');
|
||||
parts.pop();
|
||||
return parts.join('/') || wsPrefix || '/';
|
||||
});
|
||||
|
||||
// Prefer the page's own title; fall back to the section, then a humanized
|
||||
// last path segment so pages that don't wire titleStore (e.g. /tags/[tag],
|
||||
// playbook detail) still show context instead of a blank bar.
|
||||
let title = $derived(titleStore.item ?? titleStore.section ?? humanize(segments.at(-1)));
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.classList.toggle('has-context-bar', show);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (typeof document !== 'undefined') document.body.classList.remove('has-context-bar');
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
// history.back() restores the list's scroll/filter state when we got
|
||||
// here via in-app navigation; fall back to the computed parent on a
|
||||
// deep link (no in-app history to pop). (Codex review, PLAN-1694.)
|
||||
if (inAppNavs > 0 && typeof history !== 'undefined') {
|
||||
history.back();
|
||||
} else {
|
||||
goto(parentHref);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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>
|
||||
<h1 class="cb-title">{title}</h1>
|
||||
<span class="cb-spacer" aria-hidden="true"></span>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.context-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40; /* same band as the bottom nav; opposite edge */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: calc(var(--context-bar-height) + env(safe-area-inset-top, 0px));
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-left: var(--space-2);
|
||||
padding-right: var(--space-2);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cb-back {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cb-back:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.cb-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<!--
|
||||
QuickCaptureSheet — the mobile BottomNav's center "+" action (PLAN-1694,
|
||||
TASK-1698). A self-contained capture flow: pick a collection, type a title,
|
||||
create. Deliberately does NOT reuse the Sidebar's quickAdd overlay
|
||||
(uiStore.requestQuickAdd) — that path only works while the Sidebar is
|
||||
mounted and renders a desktop-ish overlay (Codex review of PLAN-1694). The
|
||||
create logic mirrors Sidebar.svelte's submitQuickAdd so behaviour (default
|
||||
status, content template, navigate to the new item) stays consistent.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { parseSchema, parseSettings, itemUrlId } from '$lib/types';
|
||||
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
|
||||
|
||||
let {
|
||||
open,
|
||||
onclose,
|
||||
wsSlug,
|
||||
wsPrefix
|
||||
}: {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
wsSlug: string | undefined;
|
||||
wsPrefix: string;
|
||||
} = $props();
|
||||
|
||||
// Agent/system collections (conventions, playbooks) are structured forms,
|
||||
// not quick-capture targets — mirror the Sidebar's regularCollections split.
|
||||
const agentSlugs = ['conventions', 'playbooks'];
|
||||
let collections = $derived(
|
||||
collectionStore.collections.filter((c) => !agentSlugs.includes(c.slug))
|
||||
);
|
||||
|
||||
let selectedSlug = $state('');
|
||||
let title = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
// Default to tasks (or the first collection) when the sheet opens. Also
|
||||
// re-default if the remembered slug is no longer a valid collection (e.g.
|
||||
// the workspace changed since last open) — otherwise submit() would
|
||||
// silently no-op on a stale slug (Codex review, PLAN-1694).
|
||||
$effect(() => {
|
||||
if (!open || !collections.length) return;
|
||||
if (!selectedSlug || !collections.some((c) => c.slug === selectedSlug)) {
|
||||
selectedSlug = (collections.find((c) => c.slug === 'tasks') ?? collections[0]).slug;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
title = '';
|
||||
onclose();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!wsSlug || !selectedSlug || !title.trim() || submitting) return;
|
||||
const coll = collections.find((c) => c.slug === selectedSlug);
|
||||
if (!coll) return;
|
||||
submitting = true;
|
||||
const t = title.trim();
|
||||
try {
|
||||
const schema = parseSchema(coll);
|
||||
const settings = parseSettings(coll);
|
||||
const defaultFields: Record<string, any> = {};
|
||||
const statusField = schema.fields.find((f) => f.key === 'status');
|
||||
if (statusField?.options?.length) {
|
||||
defaultFields.status = statusField.options[0];
|
||||
}
|
||||
const item = await api.items.create(wsSlug, coll.slug, {
|
||||
title: t,
|
||||
content: settings.content_template || '',
|
||||
fields: JSON.stringify(defaultFields),
|
||||
source: 'web'
|
||||
});
|
||||
title = '';
|
||||
onclose();
|
||||
uiStore.onNavigate();
|
||||
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
|
||||
} catch (err: any) {
|
||||
if (isPlanLimitError(err)) {
|
||||
toastStore.show(
|
||||
planLimitMessage(err) + ' Upgrade to Pro',
|
||||
'error',
|
||||
6000,
|
||||
'/console/billing'
|
||||
);
|
||||
} else {
|
||||
toastStore.show(err?.message || 'Failed to create item', 'error');
|
||||
}
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function autofocus(node: HTMLElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<BottomSheet {open} onclose={close} title="Quick capture">
|
||||
<div class="capture">
|
||||
<select class="capture-collection" bind:value={selectedSlug} aria-label="Collection">
|
||||
{#each collections as c (c.id)}
|
||||
<option value={c.slug}>{c.icon} {c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<textarea
|
||||
class="capture-title"
|
||||
bind:value={title}
|
||||
use:autofocus
|
||||
{onkeydown}
|
||||
placeholder="What needs doing?"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="capture-actions">
|
||||
<button class="capture-cancel" type="button" onclick={close}>Cancel</button>
|
||||
<button
|
||||
class="capture-submit"
|
||||
type="button"
|
||||
onclick={submit}
|
||||
disabled={!title.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<style>
|
||||
.capture {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-5) var(--space-4);
|
||||
}
|
||||
.capture-collection {
|
||||
appearance: none;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 0.95em;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.capture-title {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
font-size: 1em;
|
||||
font-family: var(--font-ui);
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.capture-title:focus,
|
||||
.capture-collection:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
.capture-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.capture-cancel,
|
||||
.capture-submit {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.capture-cancel {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.capture-submit {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
.capture-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
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';
|
||||
|
||||
let { open, onclose }: { open: boolean; onclose: () => void } = $props();
|
||||
|
||||
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
|
||||
// slot); Settings dropped for guests.
|
||||
let navDestinations = $derived(
|
||||
getPrimaryDestinations(wsPrefix)
|
||||
.filter((d) => d.key !== 'activity')
|
||||
.filter((d) => !(d.guestHidden && isGuest))
|
||||
);
|
||||
|
||||
const agentSlugs = ['conventions', 'playbooks'];
|
||||
let regularCollections = $derived(
|
||||
collectionStore.collections.filter((c) => !agentSlugs.includes(c.slug))
|
||||
);
|
||||
let agentCollections = $derived(
|
||||
collectionStore.collections.filter((c) => agentSlugs.includes(c.slug))
|
||||
);
|
||||
|
||||
let switching = $state(false);
|
||||
|
||||
// Close on navigation (the switcher card navigates on select).
|
||||
afterNavigate((nav) => {
|
||||
if (nav.type !== 'enter' && open) onclose();
|
||||
});
|
||||
|
||||
function navigate(href: string) {
|
||||
onclose();
|
||||
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">
|
||||
{#each navDestinations as dest (dest.key)}
|
||||
<button
|
||||
class="tile"
|
||||
class:active={activeKey === dest.key}
|
||||
type="button"
|
||||
onclick={() => navigate(dest.href)}
|
||||
>
|
||||
<span class="tile-icon" aria-hidden="true">{dest.icon}</span>
|
||||
<span class="tile-label">{dest.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Collections -->
|
||||
{#if regularCollections.length || agentCollections.length}
|
||||
<div class="ws-label">Collections</div>
|
||||
<div class="ws-collections">
|
||||
{#each regularCollections as coll (coll.id)}
|
||||
<button
|
||||
class="coll"
|
||||
class:active={activeKey === `collection:${coll.slug}`}
|
||||
type="button"
|
||||
onclick={() => navigate(`${wsPrefix}/${coll.slug}`)}
|
||||
>
|
||||
<span class="coll-icon" aria-hidden="true">{coll.icon}</span>
|
||||
<span class="coll-name">{coll.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each agentCollections as coll (coll.id)}
|
||||
<button
|
||||
class="coll"
|
||||
class:active={activeKey === `collection:${coll.slug}`}
|
||||
type="button"
|
||||
onclick={() => navigate(`${wsPrefix}/${coll.slug}`)}
|
||||
>
|
||||
<span class="coll-icon" aria-hidden="true">{coll.icon}</span>
|
||||
<span class="coll-name">{coll.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DockedSheet>
|
||||
|
||||
<style>
|
||||
.ws {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Navigate tile grid */
|
||||
.ws-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3) var(--space-1);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tile:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.tile.active {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--accent-blue) 12%, var(--bg-primary));
|
||||
}
|
||||
.tile-icon {
|
||||
font-size: 1.3em;
|
||||
line-height: 1;
|
||||
}
|
||||
.tile-label {
|
||||
font-size: 0.78em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Collections */
|
||||
.ws-collections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.coll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.coll:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.coll.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.coll-icon {
|
||||
font-size: 1.05em;
|
||||
width: 1.4em;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!--
|
||||
YouSheet — the mobile "You" slot surface (PLAN-1694, TASK-1701).
|
||||
|
||||
A docked sheet (anchored above the nav) for ACCOUNT concerns only — the
|
||||
workspace nav now lives in WorkspaceSheet. Profile header, theme toggle, and
|
||||
the account actions carried over from the retired TopBar user menu
|
||||
(Settings/Workspaces/Billing/Admin/Connect/Resources/Sign out).
|
||||
|
||||
Purpose-designed rows (no reused BottomSheet / dropdown). Resources reuse the
|
||||
UserMenuResources link list (pure content) restyled to match.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { avatarColor, avatarInitial } from '$lib/utils/avatar';
|
||||
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
|
||||
import UserMenuResources from '$lib/components/layout/UserMenuResources.svelte';
|
||||
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
|
||||
|
||||
let { open, onclose }: { open: boolean; onclose: () => void } = $props();
|
||||
|
||||
let wsSlug = $derived(workspaceStore.current?.slug);
|
||||
let userName = $derived(authStore.user?.name ?? '');
|
||||
let userEmail = $derived(authStore.user?.email ?? '');
|
||||
|
||||
let connectOpen = $state(false);
|
||||
|
||||
// Theme toggle — mirrors the app's mechanism (key 'pad-theme', data-theme).
|
||||
let currentTheme = $state<'dark' | 'light'>('dark');
|
||||
onMount(() => {
|
||||
const fromDom = document.documentElement.getAttribute('data-theme');
|
||||
let fromLs: string | null = null;
|
||||
try {
|
||||
fromLs = localStorage.getItem('pad-theme');
|
||||
} catch {
|
||||
fromLs = null;
|
||||
}
|
||||
currentTheme = (fromDom || fromLs) === 'light' ? 'light' : 'dark';
|
||||
});
|
||||
function toggleTheme() {
|
||||
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
try {
|
||||
localStorage.setItem('pad-theme', currentTheme);
|
||||
} catch {
|
||||
// storage unavailable; in-memory toggle still applies.
|
||||
}
|
||||
}
|
||||
|
||||
// Close on navigation (console links leave the workspace).
|
||||
afterNavigate((nav) => {
|
||||
if (nav.type !== 'enter' && open) onclose();
|
||||
});
|
||||
|
||||
function go(href: string) {
|
||||
onclose();
|
||||
goto(href);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await api.auth.logout();
|
||||
} finally {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DockedSheet {open} {onclose} label="Account">
|
||||
<div class="you">
|
||||
{#if authStore.user}
|
||||
<div class="profile">
|
||||
<span class="profile-avatar" style:background={avatarColor(userName || userEmail)}>
|
||||
{avatarInitial(userName || userEmail)}
|
||||
</span>
|
||||
<span class="profile-meta">
|
||||
<span class="profile-name">{userName}</span>
|
||||
<span class="profile-email">{userEmail}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rows">
|
||||
<!-- Theme toggle -->
|
||||
<button class="row" type="button" onclick={toggleTheme}>
|
||||
<span class="row-icon" aria-hidden="true">{currentTheme === 'dark' ? '🌙' : '☀️'}</span>
|
||||
<span class="row-label">Dark mode</span>
|
||||
<span class="toggle" class:on={currentTheme === 'dark'} aria-hidden="true">
|
||||
<span class="knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<a class="row" href="/console/settings" onclick={onclose}>
|
||||
<span class="row-icon" aria-hidden="true">⚙️</span>
|
||||
<span class="row-label">Account settings</span>
|
||||
<span class="row-chev" aria-hidden="true">›</span>
|
||||
</a>
|
||||
<a class="row" href="/console" onclick={onclose}>
|
||||
<span class="row-icon" aria-hidden="true">🗂️</span>
|
||||
<span class="row-label">Workspaces</span>
|
||||
<span class="row-chev" aria-hidden="true">›</span>
|
||||
</a>
|
||||
{#if authStore.cloudMode}
|
||||
<a class="row" href="/console/billing" onclick={onclose}>
|
||||
<span class="row-icon" aria-hidden="true">💳</span>
|
||||
<span class="row-label">Billing</span>
|
||||
<span class="row-chev" aria-hidden="true">›</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if authStore.user?.role === 'admin'}
|
||||
<a class="row" href="/console/admin" onclick={onclose}>
|
||||
<span class="row-icon" aria-hidden="true">🛡️</span>
|
||||
<span class="row-label">Admin</span>
|
||||
<span class="row-chev" aria-hidden="true">›</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if wsSlug}
|
||||
<button
|
||||
class="row"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
onclose();
|
||||
connectOpen = true;
|
||||
}}
|
||||
>
|
||||
<span class="row-icon" aria-hidden="true">🔌</span>
|
||||
<span class="row-label">Connect a project…</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Resources (external links) -->
|
||||
<div class="resources user-dropdown">
|
||||
<UserMenuResources cloudMode={authStore.cloudMode} {onclose} />
|
||||
</div>
|
||||
|
||||
<button class="row signout" type="button" onclick={handleLogout}>
|
||||
<span class="row-icon" aria-hidden="true">↩︎</span>
|
||||
<span class="row-label">Sign out</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</DockedSheet>
|
||||
|
||||
{#if wsSlug}
|
||||
<ConnectWorkspaceModal
|
||||
bind:open={connectOpen}
|
||||
serverUrl={typeof window !== 'undefined' ? window.location.origin : ''}
|
||||
workspaceSlug={wsSlug}
|
||||
workspaceName={workspaceStore.current?.name ?? ''}
|
||||
mcpPublicUrl={authStore.mcpPublicUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.you {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-1) var(--space-4);
|
||||
}
|
||||
.profile-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.profile-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.profile-email {
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.98em;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.row-icon {
|
||||
font-size: 1.05em;
|
||||
width: 1.5em;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.row-chev {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.signout {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.signout:hover {
|
||||
color: var(--accent-orange);
|
||||
background: color-mix(in srgb, var(--accent-orange) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Theme toggle pill */
|
||||
.toggle {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
position: relative;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.toggle.on {
|
||||
background: var(--accent-blue);
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.toggle.on .knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Resources — reuse UserMenuResources content, restyle to match the sheet. */
|
||||
.resources {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.resources :global(.dropdown-divider) {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
.resources :global(.resources-label) {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.resources :global(.dropdown-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95em;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.resources :global(.dropdown-item):hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -55,6 +55,26 @@
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let inputEl = $state<HTMLInputElement>();
|
||||
|
||||
// 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<string | null>(null);
|
||||
let filterStatus = $state<string | null>(null);
|
||||
@@ -682,7 +702,26 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={() => uiStore.closeSearch()}>
|
||||
<div class="palette" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
|
||||
<div
|
||||
class="palette"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={handleKeydown}
|
||||
style:transform={dragY ? `translateY(${dragY}px)` : undefined}
|
||||
style:transition={dragging ? 'none' : undefined}
|
||||
>
|
||||
<!--
|
||||
Mobile grab handle (PLAN-1694) — matches the Workspace/You docked
|
||||
sheets. Swipe down to dismiss; display:none on desktop.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="cp-grip"
|
||||
ontouchstart={gripStart}
|
||||
ontouchmove={gripMove}
|
||||
ontouchend={gripEnd}
|
||||
>
|
||||
<span class="cp-handle" aria-hidden="true"></span>
|
||||
</div>
|
||||
<!-- Search input -->
|
||||
<div class="search-row">
|
||||
<svg
|
||||
@@ -1015,6 +1054,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Mobile-only grab handle; see the ≤768px block below. */
|
||||
.cp-grip {
|
||||
display: none;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1435,26 +1478,58 @@
|
||||
tap target since the full-screen palette leaves no overlay to
|
||||
tap-outside.
|
||||
*/
|
||||
/*
|
||||
Mobile: dock the palette ABOVE the bottom nav as a sheet that matches
|
||||
the Workspace/You docked sheets (PLAN-1694) — rounded top, grab handle,
|
||||
a backdrop that stops above the nav (so the bar stays visible/tappable),
|
||||
swipe-down / tap-out to dismiss. Replaces the previous full-screen
|
||||
takeover. Desktop is byte-identical above this rule.
|
||||
|
||||
Keyboard handling is preserved: the input stays at the top of the sheet
|
||||
and `.results` is the sole scroll target, so the on-screen keyboard
|
||||
rises below the input. `min-height` keeps the input clear of the
|
||||
keyboard even when the sheet is otherwise empty (just-opened state).
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.overlay {
|
||||
padding-top: 0;
|
||||
/*
|
||||
Belt-and-braces with the JS body-scroll lock: even if iOS
|
||||
somehow drags the body, the overlay itself can't scroll its
|
||||
own contents. The palette below is height: 100dvh so it
|
||||
exactly fills the overlay; nothing should ever overflow.
|
||||
*/
|
||||
align-items: flex-end;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/*
|
||||
Only lift above the bottom nav when it's actually present (in a
|
||||
workspace — BottomNav toggles body.has-bottom-nav). On the
|
||||
non-workspace picker the sheet sits flush to the bottom.
|
||||
*/
|
||||
:global(body.has-bottom-nav) .overlay {
|
||||
bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.palette {
|
||||
max-width: none;
|
||||
max-height: 100dvh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 58dvh;
|
||||
max-height: 80dvh;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.cp-grip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) 0;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.cp-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
}
|
||||
.search-input {
|
||||
/* iOS Safari skips its focus-zoom only when font-size ≥ 16px. */
|
||||
@@ -1463,21 +1538,10 @@
|
||||
.search-hint {
|
||||
display: none;
|
||||
}
|
||||
/* Dismiss via grab-handle swipe + tap-outside now — no X (matches sheets). */
|
||||
.mobile-close {
|
||||
display: flex;
|
||||
display: none;
|
||||
}
|
||||
/*
|
||||
Pin .results as the SOLE scroll target inside the palette so
|
||||
the search-row stays at the top regardless of result-list
|
||||
length. `flex: 1; min-height: 0` lets the flex item shrink
|
||||
below its content size (without min-height: 0, an item with
|
||||
overflow: auto in a flex column refuses to shrink and the
|
||||
whole palette ends up taller than 100dvh — which is what made
|
||||
the input scroll off-screen in the first reported bug).
|
||||
`overscroll-behavior: contain` prevents bounce-scroll from
|
||||
chaining out to the body, which on iOS would re-trigger the
|
||||
scroll-to-input quirk we're trying to suppress.
|
||||
*/
|
||||
.results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Shared nav-destinations source.
|
||||
*
|
||||
* Single source of truth for the workspace's primary navigation destinations
|
||||
* and active-state logic, consumed by BOTH the desktop Sidebar
|
||||
* (`components/layout/Sidebar.svelte`) and the mobile BottomNav + "More" sheet
|
||||
* (`components/layout/BottomNav.svelte`). Keeping it here prevents the two nav
|
||||
* surfaces from drifting apart.
|
||||
*
|
||||
* These are PURE helpers — no store access, no reactivity. Callers (the Svelte
|
||||
* components) own `wsPrefix` (from `workspaceStore`), the dynamic collection
|
||||
* list (from `collectionStore`), and guest filtering. See DR-1 / DR-3 on
|
||||
* PLAN-1694.
|
||||
*/
|
||||
|
||||
export type NavKey =
|
||||
| 'dashboard'
|
||||
| 'insights'
|
||||
| 'roles'
|
||||
| 'activity'
|
||||
| 'starred'
|
||||
| 'tags'
|
||||
| 'settings';
|
||||
|
||||
export interface NavDestination {
|
||||
key: NavKey;
|
||||
/** Built from `wsPrefix` (e.g. `/dave/docapp/insights`). */
|
||||
href: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
/** Hidden on guest (shared) workspaces — mirrors the Sidebar's guest gating. */
|
||||
guestHidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path segments directly under `wsPrefix` that are NOT user collections.
|
||||
* Keep in sync with the reserved routes in the workspace `[username]/[workspace]`
|
||||
* tree. Used by {@link getActiveKey} to avoid mis-flagging a reserved page as a
|
||||
* collection.
|
||||
*/
|
||||
export const RESERVED_SLUGS = [
|
||||
'settings',
|
||||
'new',
|
||||
'library',
|
||||
'activity',
|
||||
'starred',
|
||||
'tags',
|
||||
'roles',
|
||||
'insights',
|
||||
''
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The static primary destinations, in Sidebar order. Dynamic collections are
|
||||
* appended by the caller (from `collectionStore`). `settings` carries
|
||||
* `guestHidden` so callers can drop it on shared workspaces.
|
||||
*/
|
||||
export function getPrimaryDestinations(wsPrefix: string): NavDestination[] {
|
||||
return [
|
||||
{ key: 'dashboard', href: wsPrefix, icon: '📊', label: 'Dashboard' },
|
||||
{ key: 'insights', href: `${wsPrefix}/insights`, icon: '📈', label: 'Insights' },
|
||||
{ key: 'roles', href: `${wsPrefix}/roles`, icon: '🎭', label: 'Roles' },
|
||||
{ key: 'activity', href: `${wsPrefix}/activity`, icon: '📋', label: 'Activity' },
|
||||
{ key: 'starred', href: `${wsPrefix}/starred`, icon: '⭐', label: 'Starred' },
|
||||
{ key: 'tags', href: `${wsPrefix}/tags`, icon: '🏷', label: 'Tags' },
|
||||
{ key: 'settings', href: `${wsPrefix}/settings`, icon: '⚙', label: 'Settings', guestHidden: true }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The active nav key for the current path, or `collection:<slug>` for a
|
||||
* collection page, or `null` when nothing matches. Mirrors the per-route
|
||||
* `$derived` checks the Sidebar used to inline (Sidebar.svelte:32-53).
|
||||
*/
|
||||
export function getActiveKey(pathname: string, wsPrefix: string): string | null {
|
||||
if (!wsPrefix) return null;
|
||||
if (pathname === wsPrefix) return 'dashboard';
|
||||
if (pathname === `${wsPrefix}/insights`) return 'insights';
|
||||
if (pathname === `${wsPrefix}/roles`) return 'roles';
|
||||
if (pathname === `${wsPrefix}/activity`) return 'activity';
|
||||
if (pathname === `${wsPrefix}/starred`) return 'starred';
|
||||
if (pathname === `${wsPrefix}/tags` || pathname.startsWith(`${wsPrefix}/tags/`)) return 'tags';
|
||||
|
||||
const prefix = `${wsPrefix}/`;
|
||||
if (pathname.startsWith(prefix)) {
|
||||
const slug = pathname.slice(prefix.length).split('/')[0];
|
||||
if (!(RESERVED_SLUGS as readonly string[]).includes(slug)) return `collection:${slug}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The active collection slug for the current path, or `null`. */
|
||||
export function getActiveCollectionSlug(pathname: string, wsPrefix: string): string | null {
|
||||
const key = getActiveKey(pathname, wsPrefix);
|
||||
return key && key.startsWith('collection:') ? key.slice('collection:'.length) : null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Deterministic avatar color + initial for workspace/user avatars.
|
||||
*
|
||||
* Same hash + palette the TopBar used inline (wsColor), lifted to a shared
|
||||
* util so the mobile Workspace/You sheets render identical avatars (PLAN-1694).
|
||||
*/
|
||||
|
||||
const PALETTE = [
|
||||
'#4a9eff',
|
||||
'#4ade80',
|
||||
'#a78bfa',
|
||||
'#fbbf24',
|
||||
'#22d3ee',
|
||||
'#fb923c',
|
||||
'#f472b6',
|
||||
'#34d399'
|
||||
];
|
||||
|
||||
/** Stable color for a name/label, picked from the shared palette. */
|
||||
export function avatarColor(name: string | undefined | null): string {
|
||||
const s = name ?? '';
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = s.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||
}
|
||||
|
||||
/** First character, uppercased, for the avatar glyph. */
|
||||
export function avatarInitial(name: string | undefined | null): string {
|
||||
return (name ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||
}
|
||||
@@ -36,6 +36,20 @@
|
||||
let isSharePage = $derived(page.url.pathname.startsWith('/s/'));
|
||||
let isConsolePage = $derived(page.url.pathname.startsWith('/console'));
|
||||
|
||||
// Inside a workspace route ([username]/[workspace]/…) the mobile chrome is
|
||||
// the BottomNav + YouSheet + contextual bar (PLAN-1694 Phase 2-3), so the
|
||||
// global mobile <TopBar /> is retired there. It's kept for non-workspace
|
||||
// shell pages (the workspace picker / user home) which have no BottomNav.
|
||||
let inWorkspace = $derived(!!page.params.workspace);
|
||||
let showMobileTopbar = $derived(uiStore.isMobile && !inWorkspace);
|
||||
|
||||
// `body.has-mobile-topbar` gates the .app-layout top padding in app.css so
|
||||
// the offset only applies when the fixed mobile TopBar is actually rendered.
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.classList.toggle('has-mobile-topbar', showMobileTopbar);
|
||||
});
|
||||
|
||||
// Register the 403-purge handler ONCE at app bootstrap (PLAN-1343
|
||||
// / TASK-1360). The API client's request() path invokes this on
|
||||
// GET/HEAD 403 for workspace-scoped endpoints; we drop the
|
||||
@@ -204,8 +218,13 @@
|
||||
<ToastContainer />
|
||||
<OpenChildrenDialog />
|
||||
{:else}
|
||||
{#if uiStore.isMobile}
|
||||
{#if showMobileTopbar}
|
||||
<!--
|
||||
Inside a workspace the mobile header is retired in favour of BottomNav
|
||||
+ YouSheet + contextual bar (PLAN-1694 Phase 2-3); this is gated on
|
||||
`!inWorkspace` so it only serves non-workspace shell pages (the
|
||||
workspace picker / user home), which have no BottomNav.
|
||||
|
||||
Mobile chrome: a single, always-rendered <TopBar mobile /> (IDEA-1121 /
|
||||
TASK-1122). The previous design rendered TopBar only when the sidebar
|
||||
was open and patched in a slim hamburger-only `.mobile-header` inside
|
||||
@@ -301,7 +320,13 @@
|
||||
fixed positioning, so the fix lives here on the layout container.
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.app-layout {
|
||||
/*
|
||||
Only offset for the mobile TopBar when it's actually rendered — i.e.
|
||||
on non-workspace shell pages. Inside a workspace the TopBar is retired
|
||||
(PLAN-1694 Phase 2-3) so the layout runs full-height; the contextual
|
||||
detail bar handles its own offset via body.has-context-bar in app.css.
|
||||
*/
|
||||
:global(body.has-mobile-topbar) .app-layout {
|
||||
padding-top: var(--topbar-height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { starredStore } from '$lib/stores/starred.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import ConnectBanner from '$lib/components/ConnectBanner.svelte';
|
||||
import BottomNav from '$lib/components/layout/BottomNav.svelte';
|
||||
import MobileContextBar from '$lib/components/layout/MobileContextBar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -197,4 +199,8 @@
|
||||
workspaceName={workspaceStore.current?.name ?? ''}
|
||||
/>
|
||||
|
||||
<MobileContextBar />
|
||||
|
||||
{@render children()}
|
||||
|
||||
<BottomNav />
|
||||
|
||||
Reference in New Issue
Block a user