From 0f634b25401aa98592de47f3ee9e403c3a4bc2a8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 19:22:35 +0200 Subject: [PATCH] refactor(workbench): replace minimize tabs + scene tabs with unified bottom bar Removes the minimize/restore system entirely (scenes make it redundant) and merges the top-level SceneTabs into a single inline bottom bar that renders inside the layout's bottom-stack. Chrome tab-group style: active scene shows its app tabs inline after it, inactive scenes appear as compact pills. App tabs show module icons instead of color dots, no fullscreen/close buttons (use context menu). Architecture: - New bottomBarStore (svelte $state) lets pages inject a component into the layout's bottom-stack without a Svelte slot mechanism - SceneAppBar component extracted for clean separation - PageCarousel stripped to pure carousel (no scene/bar responsibilities) - bottomChromeHeight accounts for the bar when present (+36px) Removed: minimized field from WorkbenchSceneApp/CarouselPage, Minus button from PageShell, minimizeApp/restoreApp from store, onMinimize/ onRestore from context menu builder, SceneTabs component usage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../page-carousel/PageCarousel.svelte | 181 ++------------ .../components/page-carousel/PageShell.svelte | 16 -- .../src/lib/components/page-carousel/types.ts | 4 +- .../lib/components/workbench/AppPage.svelte | 3 - .../components/workbench/SceneAppBar.svelte | 223 ++++++++++++++++++ .../lib/context-menu/build-context-menu.ts | 27 --- .../web/src/lib/stores/bottom-bar.svelte.ts | 33 +++ .../src/lib/stores/workbench-scenes.svelte.ts | 26 +- .../web/src/lib/types/workbench-scenes.ts | 3 +- .../apps/web/src/routes/(app)/+layout.svelte | 11 +- .../apps/web/src/routes/(app)/+page.svelte | 85 ++++--- 11 files changed, 349 insertions(+), 263 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/workbench/SceneAppBar.svelte create mode 100644 apps/mana/apps/web/src/lib/stores/bottom-bar.svelte.ts diff --git a/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte b/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte index 8f4f346bf..a455ca30a 100644 --- a/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte +++ b/apps/mana/apps/web/src/lib/components/page-carousel/PageCarousel.svelte @@ -1,10 +1,9 @@ diff --git a/apps/mana/apps/web/src/lib/components/page-carousel/PageShell.svelte b/apps/mana/apps/web/src/lib/components/page-carousel/PageShell.svelte index e16083606..142c892fa 100644 --- a/apps/mana/apps/web/src/lib/components/page-carousel/PageShell.svelte +++ b/apps/mana/apps/web/src/lib/components/page-carousel/PageShell.svelte @@ -7,7 +7,6 @@ import { _ } from 'svelte-i18n'; import { X, - Minus, DotsSixVertical, CornersOut, CornersIn, @@ -21,7 +20,6 @@ heightPx?: number; maximized?: boolean; onClose: () => void; - onMinimize?: () => void; onMaximize?: () => void; onResize?: (widthPx: number, heightPx?: number) => void; onMoveLeft?: () => void; @@ -44,7 +42,6 @@ heightPx, maximized = false, onClose, - onMinimize, onMaximize, onResize, onMoveLeft, @@ -146,19 +143,6 @@ {/if}
- {#if onMinimize} - - {/if} {#if onMaximize} + + + {#if isActive && pages.length > 0} + {#each pages as p (p.id)} + {@const AppIcon = p.icon} + + + {/each} + + {#if scenes.length > 1} + + {/if} + {/if} + {/each} + +
+ + diff --git a/apps/mana/apps/web/src/lib/context-menu/build-context-menu.ts b/apps/mana/apps/web/src/lib/context-menu/build-context-menu.ts index 3baf4080b..b4a6d6477 100644 --- a/apps/mana/apps/web/src/lib/context-menu/build-context-menu.ts +++ b/apps/mana/apps/web/src/lib/context-menu/build-context-menu.ts @@ -3,36 +3,28 @@ import type { AppDescriptor, ContextMenuLocation } from '$lib/app-registry/types import { CornersOut, CornersIn, - Minus, CaretLeft, CaretRight, X, ArrowSquareOut, Link, - ArrowLineUp, } from '@mana/shared-icons'; export interface ContextMenuContext { location: ContextMenuLocation; appId: string; app: AppDescriptor; - /** Is the card currently maximized? */ maximized?: boolean; - // Window management callbacks (optional per location) onMaximize?: () => void; - onMinimize?: () => void; - onRestore?: () => void; onClose?: () => void; onMoveLeft?: () => void; onMoveRight?: () => void; - /** Override route (default: /${appId}) */ appRoute?: string; } export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[] { const items: ContextMenuItem[] = []; - // 1. App-specific actions const appActions = (ctx.app.contextMenuActions ?? []).filter( (a) => !a.showIn || a.showIn.includes(ctx.location) ); @@ -51,7 +43,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[ items.push({ id: 'div-app', label: '', type: 'divider' }); } - // 2. Window management (location-dependent) if (ctx.location === 'card') { if (ctx.onMaximize) { items.push({ @@ -61,14 +52,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[ action: ctx.onMaximize, }); } - if (ctx.onMinimize) { - items.push({ - id: 'minimize', - label: 'Minimieren', - icon: Minus, - action: ctx.onMinimize, - }); - } if (ctx.onMoveLeft) { items.push({ id: 'move-left', @@ -89,14 +72,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[ } if (ctx.location === 'tab') { - if (ctx.onRestore) { - items.push({ - id: 'restore', - label: 'Wiederherstellen', - icon: ArrowLineUp, - action: ctx.onRestore, - }); - } if (ctx.onMaximize) { items.push({ id: 'maximize', @@ -108,7 +83,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[ items.push({ id: 'div-window', label: '', type: 'divider' }); } - // 3. Navigation actions (always) const route = ctx.appRoute ?? `/${ctx.appId}`; items.push({ id: 'open-route', @@ -123,7 +97,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[ action: () => navigator.clipboard.writeText(window.location.origin + route), }); - // 4. Close (at the end, danger variant) — only for card/tab if (ctx.location === 'card' || ctx.location === 'tab') { if (ctx.onClose) { items.push({ id: 'div-close', label: '', type: 'divider' }); diff --git a/apps/mana/apps/web/src/lib/stores/bottom-bar.svelte.ts b/apps/mana/apps/web/src/lib/stores/bottom-bar.svelte.ts new file mode 100644 index 000000000..b4433ecc8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/stores/bottom-bar.svelte.ts @@ -0,0 +1,33 @@ +/** + * Bottom Bar Slot — allows pages to inject a component into the layout's + * bottom-stack (above notifications, below the page content). + * + * Usage: + * Page sets: bottomBarStore.set(MyBarComponent, { myProp: value }) + * Layout reads: bottomBarStore.component / bottomBarStore.props + * Page clears: bottomBarStore.clear() (onDestroy) + */ + +import type { Component } from 'svelte'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let barComponent = $state | null>(null); +let barProps = $state>({}); + +export const bottomBarStore = { + get component() { + return barComponent; + }, + get props() { + return barProps; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(component: Component, props: Record = {}) { + barComponent = component; + barProps = props; + }, + clear() { + barComponent = null; + barProps = {}; + }, +}; diff --git a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts index 65b8bb4ee..de26a99dd 100644 --- a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts @@ -26,9 +26,9 @@ const TABLE = 'workbenchScenes'; const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId'; const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ - { appId: 'todo', minimized: false }, - { appId: 'calendar', minimized: false }, - { appId: 'notes', minimized: false }, + { appId: 'todo' }, + { appId: 'calendar' }, + { appId: 'notes' }, ]; // ─── Reactive state ─────────────────────────────────────────── @@ -251,10 +251,8 @@ export const workbenchScenesStore = { async addApp(appId: string) { await patchActiveScene((apps) => { - if (apps.some((a) => a.appId === appId)) { - return apps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a)); - } - return [...apps, { appId, minimized: false }]; + if (apps.some((a) => a.appId === appId)) return apps; + return [...apps, { appId }]; }); }, @@ -262,21 +260,9 @@ export const workbenchScenesStore = { await patchActiveScene((apps) => apps.filter((a) => a.appId !== appId)); }, - async minimizeApp(appId: string) { - await patchActiveScene((apps) => - apps.map((a) => (a.appId === appId ? { ...a, minimized: true } : a)) - ); - }, - - async restoreApp(appId: string) { - await patchActiveScene((apps) => - apps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a)) - ); - }, - async toggleMaximizeApp(appId: string) { await patchActiveScene((apps) => - apps.map((a) => (a.appId === appId ? { ...a, maximized: !a.maximized, minimized: false } : a)) + apps.map((a) => (a.appId === appId ? { ...a, maximized: !a.maximized } : a)) ); }, diff --git a/apps/mana/apps/web/src/lib/types/workbench-scenes.ts b/apps/mana/apps/web/src/lib/types/workbench-scenes.ts index 3e019686e..8212fc84f 100644 --- a/apps/mana/apps/web/src/lib/types/workbench-scenes.ts +++ b/apps/mana/apps/web/src/lib/types/workbench-scenes.ts @@ -2,7 +2,7 @@ * Workbench Scenes — user-defined named layouts of the workbench (homepage). * * Each scene is a named bundle of "open apps" with their window state - * (minimized / maximized / size). Users can switch between scenes to + * (maximized / size). Users can switch between scenes to * quickly change context (e.g. "Home", "Deep Work", "Travel"). * * Scenes are persisted in the unified Mana Dexie database under the @@ -15,7 +15,6 @@ import type { BaseRecord } from '@mana/local-store'; export interface WorkbenchSceneApp { appId: string; - minimized: boolean; maximized?: boolean; widthPx?: number; heightPx?: number; diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index f5b5c5e47..b1c88a375 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -8,6 +8,7 @@ import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte'; import SessionWarning from '$lib/components/SessionWarning.svelte'; import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte'; + import { bottomBarStore } from '$lib/stores/bottom-bar.svelte'; import SuggestionToast from '$lib/components/SuggestionToast.svelte'; import { locale, _ } from 'svelte-i18n'; import { @@ -249,7 +250,9 @@ } // Bottom chrome height: calculated from state, not measured (avoids reflow loop) - const bottomChromeHeight = $derived((isCollapsed ? 0 : 80) + (isTagStripVisible ? 44 : 0) + 72); + const bottomChromeHeight = $derived( + (isCollapsed ? 0 : 80) + (isTagStripVisible ? 44 : 0) + 72 + (bottomBarStore.component ? 36 : 0) + ); // ── DnD context ───────────────────────────────────────── let tagDropHandler = $state<((tagId: string, payload: DragPayload) => void) | null>(null); @@ -602,6 +605,12 @@
+ + {#if bottomBarStore.component} + {@const BarComponent = bottomBarStore.component} + + {/if} + diff --git a/apps/mana/apps/web/src/routes/(app)/+page.svelte b/apps/mana/apps/web/src/routes/(app)/+page.svelte index 2a028a875..fbad97e25 100644 --- a/apps/mana/apps/web/src/routes/(app)/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+page.svelte @@ -1,7 +1,7 @@