From 4d6e6e61b4d172865d0fc8b70c7de1e52a406feb Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 00:53:42 +0200 Subject: [PATCH] feat(mana-web): keyboard shortcuts for workbench + nav bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1–9 scroll to the Nth open app on the workbench homepage; 0 opens the app picker. - q/w/e toggle the bottom bars (workbench tabs / search / tags); r opens the user-menu PillDropdownBar (expanding the PillNav first if needed); t toggles the PillNav visibility. Adds a `data-user-menu-trigger` hook on the user pill so the layout can drive the menu bar programmatically without duplicating its config. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/routes/(app)/+layout.svelte | 49 +++++-- .../apps/web/src/routes/(app)/+page.svelte | 29 ++++ .../src/navigation/PillNavigation.svelte | 133 ++++++++++++++---- 3 files changed, 174 insertions(+), 37 deletions(-) diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 0c8ed1193..93b1cb9b4 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import type { Component, Snippet } from 'svelte'; - import { onDestroy, setContext } from 'svelte'; + import { onDestroy, setContext, tick } from 'svelte'; import { createReminderScheduler } from '@mana/shared-stores'; import { todoReminderSource } from '$lib/modules/todo/reminder-source'; import { startEventStore, stopEventStore } from '$lib/data/events/event-store'; @@ -261,7 +261,7 @@ const bottomChromeHeight = $derived( isFullscreen ? 0 - : (isCollapsed ? 0 : 80) + + : (isCollapsed ? 0 : 56) + (activeBar ? 64 : 0) + (isTagStripVisible ? 64 : 0) + (isQuickInputVisible ? 64 : 0) + @@ -311,7 +311,7 @@ { href: '/', label: 'Workbench-Tabs', - icon: 'columns', + icon: 'tabs', iconOnly: true, onClick: handleBottomBarToggle, active: isBottomBarVisible, @@ -372,6 +372,40 @@ const route = navRoutes[num - 1]; if (route) goto(route); } + return; + } + if (event.ctrlKey || event.metaKey || event.altKey) return; + switch (event.key) { + case 'q': + case 'Q': + event.preventDefault(); + handleBottomBarToggle(); + return; + case 'w': + case 'W': + event.preventDefault(); + handleQuickInputToggle(); + return; + case 'e': + case 'E': + event.preventDefault(); + handleTagStripToggle(); + return; + case 'r': + case 'R': + event.preventDefault(); + (async () => { + if (isCollapsed) handleCollapsedChange(false); + await tick(); + document.querySelector('[data-user-menu-trigger]')?.click(); + })(); + return; + case 't': + case 'T': + event.preventDefault(); + if (!isCollapsed) closeAllBars(); + handleCollapsedChange(!isCollapsed); + return; } } @@ -872,15 +906,6 @@ {/if} {/snippet} - {#snippet rightAction()} - - {/snippet} {/if} diff --git a/apps/mana/apps/web/src/routes/(app)/+page.svelte b/apps/mana/apps/web/src/routes/(app)/+page.svelte index 3279481ee..b2c54c5af 100644 --- a/apps/mana/apps/web/src/routes/(app)/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+page.svelte @@ -120,6 +120,35 @@ if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } + // ── Keyboard shortcuts 1-9 / 0 ───────────────────────── + // 1-9 scroll to the Nth open app in the active scene. + // 0 opens the new-app picker (which scrolls itself into view). + onMount(() => { + function onKeydown(e: KeyboardEvent) { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement | null; + if (target) { + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) + return; + } + if (e.key === '0') { + e.preventDefault(); + showPicker = true; + return; + } + if (e.key >= '1' && e.key <= '9') { + const idx = Number(e.key) - 1; + const page = carouselPages[idx]; + if (!page) return; + e.preventDefault(); + scrollToPage(page.id); + } + } + window.addEventListener('keydown', onKeydown); + return () => window.removeEventListener('keydown', onKeydown); + }); + // ── Register SceneAppBar in the layout's bottom-stack ─── // Split into two effects so prop churn (carouselPages re-deriving on // every openApps change) doesn't re-write barComponent. The first diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index 8a7d8fdd3..642164fc4 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -68,6 +68,7 @@ SignOut, Sparkle, Spiral, + Cards, Sun, Tag, Target, @@ -103,6 +104,7 @@ plus: Plus, columns: Columns, kanban: Columns, + tabs: Cards, mic: Microphone, calendar: CalendarBlank, folder: Folder, @@ -543,6 +545,70 @@ function isActive(path: string) { return currentPath === path; } + + // User-menu bar — rendered when barMode is active. Short list: settings, + // light/dark/system toggle, theme button. + const userMenuBarItems = $derived.by(() => { + const out: PillDropdownItem[] = []; + if (settingsHref) { + out.push({ + id: 'settings', + label: 'Einstellungen', + icon: 'settings', + onClick: () => { + window.location.href = settingsHref; + }, + }); + } + if (onThemeModeChange) { + out.push( + { + id: 'mode-light', + label: 'Hell', + icon: 'sun', + group: 'theme-mode', + active: themeMode === 'light', + onClick: () => onThemeModeChange('light'), + }, + { + id: 'mode-dark', + label: 'Dunkel', + icon: 'moon', + group: 'theme-mode', + active: themeMode === 'dark', + onClick: () => onThemeModeChange('dark'), + }, + { + id: 'mode-system', + label: 'System', + icon: 'settings', + group: 'theme-mode', + active: themeMode === 'system', + onClick: () => onThemeModeChange('system'), + } + ); + } + if (themesHref) { + out.push({ + id: 'themes', + label: 'Theme', + icon: 'palette', + onClick: () => { + window.location.href = themesHref; + }, + }); + } + if (onLogout && showLogout && userEmail) { + out.push({ + id: 'logout', + label: 'Logout', + icon: 'logout', + danger: true, + onClick: () => onLogout(), + }); + } + return out; + }); {#if !(externalCollapsed ?? false)} @@ -555,7 +621,7 @@ aria-label={ariaLabel} >
- + {#if showAppSwitcher && appItems.length > 0} - {:else} - - {#if logo} - {@render logo()} - {:else} - {appName} - {/if} - {/if} @@ -605,7 +663,7 @@ {#if element.icon} {#if phosphorIcons[element.icon]} {@const IconComponent = phosphorIcons[element.icon]} - + {/if} {/if} {element.label} @@ -636,7 +694,7 @@ {@html item.iconSvg} {:else if phosphorIcons[item.icon]} {@const IconComponent = phosphorIcons[item.icon]} - + {/if} {/if} {#if !item.iconOnly} @@ -664,7 +722,7 @@ {@html item.iconSvg} {:else if phosphorIcons[item.icon]} {@const IconComponent = phosphorIcons[item.icon]} - + {/if} {/if} {#if !item.iconOnly} @@ -703,7 +761,7 @@ {#if element.icon} {#if phosphorIcons[element.icon]} {@const IconComponent = phosphorIcons[element.icon]} - + {/if} {/if} {element.label} @@ -726,7 +784,7 @@ class:active={activeBarId === 'sync'} title={currentSyncLabel} > - + {currentSyncLabel} {:else if showSyncStatus && syncStatusItems.length > 0} @@ -739,7 +797,26 @@ {/if} - {#if userEmail || loginHref} + {#if (userEmail || loginHref) && barMode} + {@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel} + {@const userBarConfig = { + id: 'user', + label: userLabel, + icon: 'user', + items: userMenuBarItems, + }} + + {:else if userEmail || loginHref} {@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel} {:else if onLogout && showLogout} {/if} @@ -806,7 +884,11 @@ left: 0; right: 0; z-index: 1000; - padding: 1rem 0 calc(env(safe-area-inset-bottom, 0px) + 0.75rem); + /* Unified bar height (see bottomChromeHeight in (app)/+layout.svelte). */ + height: calc(56px + env(safe-area-inset-bottom, 0px)); + padding-bottom: env(safe-area-inset-bottom, 0px); + display: flex; + align-items: center; pointer-events: none; /* Container query context */ container-type: inline-size; @@ -864,8 +946,8 @@ } .pill-icon { - width: 1.25rem; - height: 1.25rem; + width: 1.5rem; + height: 1.5rem; } } @@ -874,7 +956,8 @@ display: flex; align-items: center; gap: 0.375rem; - padding: 0.5rem 0.875rem; + padding: 0 0.875rem; + height: 36px; border-radius: 9999px; font-size: 0.875rem; font-weight: 500; @@ -955,8 +1038,8 @@ } .pill-icon { - width: 1rem; - height: 1rem; + width: 1.25rem; + height: 1.25rem; flex-shrink: 0; } @@ -964,10 +1047,10 @@ display: inline; } - /* Icon-only pill: square-ish shape, no label gap */ + /* Icon-only pill: wider than tall so it reads as a pill, not a chip. */ .pill.icon-only { gap: 0; - padding: 0.5rem 0.625rem; + padding: 0 1.125rem; } /* Progress ring on pill (used for download indicator).