diff --git a/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte b/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte
index 61f2d30ae..8c1e86868 100644
--- a/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte
+++ b/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte
@@ -3,7 +3,7 @@
import { isToday, isPast, startOfDay, addDays, subHours, format } from 'date-fns';
import { t } from 'svelte-i18n';
import type { Task } from '@todo/shared';
- import { X, Circle, Minus, DotsSixVertical } from '@manacore/shared-icons';
+ import { X, Circle, Minus, DotsSixVertical, CornersOut, CornersIn } from '@manacore/shared-icons';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
@@ -11,12 +11,22 @@
interface Props {
pageId: string;
title?: string;
+ maximized?: boolean;
onClose: () => void;
onMinimize?: () => void;
+ onMaximize?: () => void;
onRename?: (name: string) => void;
}
- let { pageId, title: customTitle, onClose, onMinimize, onRename }: Props = $props();
+ let {
+ pageId,
+ title: customTitle,
+ maximized = false,
+ onClose,
+ onMinimize,
+ onMaximize,
+ onRename,
+ }: Props = $props();
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
@@ -175,7 +185,7 @@
}
-
+
@@ -204,6 +214,19 @@
{/if}
+ {#if onMaximize}
+
+ {/if}
@@ -295,6 +318,42 @@
0 0 0 1px rgba(255, 255, 255, 0.06);
}
+ .todo-page.maximized {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ width: 100% !important;
+ min-height: 100vh;
+ border-radius: 0;
+ box-shadow: none;
+ animation: fadeInScale 0.2s ease-out;
+ align-items: center;
+ }
+
+ .todo-page.maximized .page-header,
+ .todo-page.maximized .page-body {
+ width: 100%;
+ max-width: 720px;
+ }
+
+ .todo-page.maximized .page-header {
+ margin: 0 auto;
+ }
+
+ .todo-page.maximized .page-body {
+ margin: 0 auto;
+ }
+ @keyframes fadeInScale {
+ from {
+ opacity: 0.8;
+ transform: scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
.drag-handle-bar {
display: flex;
justify-content: center;
diff --git a/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts b/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts
index 2ff6c1e47..387ac1fed 100644
--- a/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts
+++ b/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts
@@ -1,8 +1,8 @@
/**
- * Minimized pages context — layout owns the state, page reads/writes via context.
+ * Minimized pages context — shared between layout and page via Svelte context.
*
- * Layout calls `createMinimizedPagesContext()` + `setContext`.
- * Page calls `getContext('minimizedPages')` to get the same object.
+ * Layout creates the context, renders MinimizedTabs using its reactive state.
+ * Page registers callbacks (restore/remove/togglePicker) and syncs its openPages.
*/
import type { MinimizedPage } from '@manacore/shared-ui';
@@ -18,16 +18,39 @@ export const PAGE_META: Record = {
};
export interface MinimizedPagesContext {
+ /** Reactive list of minimized pages (read by layout for rendering) */
readonly pages: MinimizedPage[];
+ /** Whether there are any minimized pages */
readonly hasPages: boolean;
- /** Called by page to sync its openPages state */
+ /** Sync open pages state from page component */
sync(openPages: { id: string; minimized: boolean }[]): void;
- /** Called by page on unmount */
+ /** Clear all pages (called on page unmount) */
clear(): void;
+ /** Restore a minimized page — delegates to registered callback */
+ restore(pageId: string): void;
+ /** Remove a page — delegates to registered callback */
+ remove(pageId: string): void;
+ /** Maximize a minimized page — delegates to registered callback */
+ maximize(pageId: string): void;
+ /** Toggle page picker — delegates to registered callback */
+ togglePicker(): void;
+ /** Page registers its handlers here */
+ registerHandlers(handlers: {
+ restore: (id: string) => void;
+ remove: (id: string) => void;
+ maximize: (id: string) => void;
+ togglePicker: () => void;
+ }): void;
}
export function createMinimizedPagesContext(): MinimizedPagesContext {
let pages = $state([]);
+ let handlers = $state<{
+ restore: (id: string) => void;
+ remove: (id: string) => void;
+ maximize: (id: string) => void;
+ togglePicker: () => void;
+ } | null>(null);
return {
get pages() {
@@ -36,7 +59,7 @@ export function createMinimizedPagesContext(): MinimizedPagesContext {
get hasPages() {
return pages.length > 0;
},
- sync(openPages: { id: string; minimized: boolean }[]) {
+ sync(openPages) {
pages = openPages
.filter((p) => p.minimized)
.map((p) => {
@@ -46,6 +69,22 @@ export function createMinimizedPagesContext(): MinimizedPagesContext {
},
clear() {
pages = [];
+ handlers = null;
+ },
+ restore(pageId) {
+ handlers?.restore(pageId);
+ },
+ remove(pageId) {
+ handlers?.remove(pageId);
+ },
+ maximize(pageId) {
+ handlers?.maximize(pageId);
+ },
+ togglePicker() {
+ handlers?.togglePicker();
+ },
+ registerHandlers(h) {
+ handlers = h;
},
};
}
diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte
index 950e7f6e6..d3f5cfd05 100644
--- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte
+++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte
@@ -9,7 +9,9 @@
ImmersiveModeToggle,
BottomStack,
MinimizedTabs,
+ NotificationBar,
} from '@manacore/shared-ui';
+ import type { BottomNotification } from '@manacore/shared-ui';
import {
SplitPaneContainer,
setSplitPanelContext,
@@ -52,14 +54,18 @@
SessionExpiredBanner,
AuthGate,
GuestWelcomeModal,
- GuestRegistrationNudge,
+ startGuestSession,
+ shouldShowGuestNudge,
+ dismissGuestNudge,
} from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
+ import { UserPlus } from '@manacore/shared-icons';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { todoStore, taskCollection } from '$lib/data/local-store';
import type { LocalBoardView } from '$lib/data/local-store';
import { useAllTasks, useAllBoardViews } from '$lib/data/task-queries';
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
+ import { List, X } from '@manacore/shared-icons';
import { createMinimizedPagesContext } from '$lib/stores/minimized-pages.svelte';
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
@@ -119,6 +125,48 @@
},
});
+ // ── Bottom notifications ────────────────────────────────
+ let bottomNotifications = $state([]);
+ let guestNudgeInterval: ReturnType | null = null;
+
+ function initGuestNudge() {
+ if (authStore.isAuthenticated) return;
+ startGuestSession('todo');
+
+ function checkNudge() {
+ if (shouldShowGuestNudge('todo', 3)) {
+ bottomNotifications = [
+ {
+ id: 'guest-nudge',
+ message:
+ ($locale || 'de') === 'de'
+ ? 'Gefällt es dir? Sichere deine Daten geräteübergreifend.'
+ : 'Like what you see? Save your data across devices.',
+ type: 'info' as const,
+ action: {
+ label: ($locale || 'de') === 'de' ? 'Konto erstellen' : 'Create account',
+ icon: UserPlus,
+ onClick: () => {
+ dismissGuestNudge('todo');
+ bottomNotifications = [];
+ goto('/register');
+ },
+ },
+ dismissible: true,
+ onDismiss: () => {
+ dismissGuestNudge('todo');
+ bottomNotifications = bottomNotifications.filter((n) => n.id !== 'guest-nudge');
+ },
+ },
+ ];
+ if (guestNudgeInterval) clearInterval(guestNudgeInterval);
+ }
+ }
+
+ checkNudge();
+ guestNudgeInterval = setInterval(checkNudge, 30_000);
+ }
+
// Guest welcome modal state
let showGuestWelcome = $state(false);
@@ -200,12 +248,16 @@
}
}
+ // Bottom bar visibility (controlled by FAB)
+ let isBottomBarsVisible = $state(true);
+
// FilterStrip visibility (toggle via Filter button in PillNav)
let isFilterStripVisible = $derived(!todoSettings.filterStripCollapsed);
// BottomStack computes these offsets automatically
let pillNavOffset = $state('0px');
let tagStripOffset = $state('72px');
+ let fabOffset = $state('20px');
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@@ -314,6 +366,10 @@
}
}
+ function handleBottomBarsToggle() {
+ isBottomBarsVisible = !isBottomBarsVisible;
+ }
+
function handleToggleTheme() {
theme.toggleMode();
}
@@ -377,6 +433,9 @@
// Show guest welcome modal on first visit
initGuestWelcome();
+ // Start guest registration nudge timer
+ initGuestNudge();
+
// Tags and projects are now loaded reactively via useLiveQuery — no fetch needed
// User settings need auth
@@ -415,10 +474,11 @@
{#if !todoSettings.immersiveModeEnabled}
minimizedPages.maximize(id)}
onAdd={() => minimizedPages.togglePicker()}
/>
+ {#snippet top()}
+
+ {/snippet}
@@ -444,52 +507,71 @@
deferSearch={true}
locale={$locale || 'de'}
appIcon="todo"
+ hasFabRight={true}
bottomOffset="16px"
/>
{/if}
-
-
+
+ {#if isBottomBarsVisible}
+
-
- {#if isFilterStripVisible}
-
+
+ {#if isFilterStripVisible}
+
+ {/if}
{/if}
+
+
+
- {:else}
- goto('/register')}
- locale={($locale || 'de') === 'de' ? 'de' : 'en'}
- delayMinutes={3}
- />
{/if}
@@ -620,4 +695,42 @@
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
}
+
+ /* FAB to toggle bottom bars — next to QuickInputBar */
+ .pillnav-fab {
+ position: fixed;
+ bottom: calc(var(--fab-bottom, 20px) + env(safe-area-inset-bottom, 0px));
+ left: calc(50% + 350px + 0.75rem);
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--color-surface-elevated-2);
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-xl);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1002;
+ transition: all 0.2s ease;
+ }
+
+ @media (max-width: 900px) {
+ .pillnav-fab {
+ left: auto;
+ right: 1rem;
+ }
+ }
+
+ .pillnav-fab:hover {
+ transform: scale(1.05);
+ }
+
+ .pillnav-fab:active {
+ transform: scale(0.95);
+ }
+
+ .pillnav-fab :global(svg) {
+ color: var(--color-foreground);
+ }
diff --git a/apps/todo/apps/web/src/routes/(app)/+page.svelte b/apps/todo/apps/web/src/routes/(app)/+page.svelte
index c02e44d95..c6b1d68ed 100644
--- a/apps/todo/apps/web/src/routes/(app)/+page.svelte
+++ b/apps/todo/apps/web/src/routes/(app)/+page.svelte
@@ -6,50 +6,38 @@
import { Plus } from '@manacore/shared-icons';
import PagePicker from '$lib/components/pages/PagePicker.svelte';
import TodoPage from '$lib/components/pages/TodoPage.svelte';
- import { minimizedPagesStore } from '$lib/stores/minimized-pages.svelte';
+ import type { MinimizedPagesContext } from '$lib/stores/minimized-pages.svelte';
// Get active view from layout context
const activeViewCtx: { readonly value: LocalBoardView | null } = getContext('activeView');
+ const minimizedPages: MinimizedPagesContext = getContext('minimizedPages');
let activeView = $derived(activeViewCtx.value);
let pageTitle = $derived(activeView?.name ?? 'Aufgaben');
// ── Pages ───────────────────────────────────────────────
let showPagePicker = $state(false);
- let openPages = $state<{ id: string; minimized: boolean; customTitle?: string }[]>([
- { id: 'todo', minimized: false },
- ]);
+ let openPages = $state<
+ { id: string; minimized: boolean; maximized?: boolean; customTitle?: string }[]
+ >([{ id: 'todo', minimized: false }]);
let expandedPages = $derived(openPages.filter((p) => !p.minimized));
- // Sync minimized pages to shared store so layout can render tabs
+ // Sync minimized pages to layout via context
$effect(() => {
- minimizedPagesStore.set(openPages);
+ minimizedPages.sync(openPages);
});
- onDestroy(() => minimizedPagesStore.clear());
- // Listen for events from layout's minimized tab bar
- function onRestorePage(e: Event) {
- handleRestorePage((e as CustomEvent).detail);
- }
- function onRemovePage(e: Event) {
- handleRemovePage((e as CustomEvent).detail);
- }
- function onTogglePagePicker() {
- togglePagePicker();
- }
-
- $effect(() => {
- window.addEventListener('restore-page', onRestorePage);
- window.addEventListener('remove-page', onRemovePage);
- window.addEventListener('toggle-page-picker', onTogglePagePicker);
- return () => {
- window.removeEventListener('restore-page', onRestorePage);
- window.removeEventListener('remove-page', onRemovePage);
- window.removeEventListener('toggle-page-picker', onTogglePagePicker);
- };
+ // Register handlers so layout can delegate tab actions back to us
+ minimizedPages.registerHandlers({
+ restore: (id) => handleRestorePage(id),
+ remove: (id) => handleRemovePage(id),
+ maximize: (id) => handleMaximizePage(id),
+ togglePicker: () => togglePagePicker(),
});
+ onDestroy(() => minimizedPages.clear());
+
function handleAddPage(pageId: string) {
if (!openPages.some((p) => p.id === pageId)) {
openPages = [...openPages, { id: pageId, minimized: false }];
@@ -75,6 +63,12 @@
openPages = openPages.map((p) => (p.id === pageId ? { ...p, customTitle: name } : p));
}
+ function handleMaximizePage(pageId: string) {
+ openPages = openPages.map((p) =>
+ p.id === pageId ? { ...p, maximized: !p.maximized, minimized: false } : p
+ );
+ }
+
// ── Page drag reorder ───────────────────────────────────
let dragPageId = $state(null);
@@ -193,15 +187,37 @@
handleRemovePage(page.id)}
onMinimize={() => handleMinimizePage(page.id)}
+ onMaximize={() => handleMaximizePage(page.id)}
onRename={(name) => handleRenamePage(page.id, name)}
/>
{/each}
-
- {#if showPagePicker}
+
+ {#if expandedPages.length === 0}
+
+
+ {#if showPagePicker}
+
(showPagePicker = false)}
+ activePageIds={openPages.map((p) => p.id)}
+ />
+ {:else}
+
+ {/if}
+
+ {:else if showPagePicker}
+ /**
+ * BottomStack — offset coordinator for the fixed bottom bar stack.
+ *
+ * Stack order (bottom → top):
+ * QuickInputBar (always at bottom, fixed offset)
+ * → PillNav (above input bar)
+ * → TagStrip (above PillNav)
+ * → children (e.g. MinimizedTabs)
+ * → top (e.g. NotificationBar)
+ *
+ * Computes and exposes offsets for each layer so apps don't
+ * need manual pixel arithmetic. Renders "middle" and "top"
+ * content at the correct positions.
+ */
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ /** Height of the QuickInputBar in px (default: 72) */
+ inputBarHeight?: number;
+ /** Is PillNav currently visible? */
+ pillNavVisible?: boolean;
+ /** Height of PillNav in px (default: 68) */
+ pillNavHeight?: number;
+ /** Is TagStrip currently visible? */
+ tagStripVisible?: boolean;
+ /** Height of TagStrip in px (default: 50) */
+ tagStripHeight?: number;
+ /** Content rendered above TagStrip (e.g. MinimizedTabs) */
+ children?: Snippet;
+ /** Content rendered at the very top (e.g. NotificationBar) */
+ top?: Snippet;
+ /** Computed bottom offset for PillNav (bind this) */
+ pillNavOffset?: string;
+ /** Computed bottom offset for TagStrip (bind this) */
+ tagStripOffset?: string;
+ /** Computed bottom offset for FAB (bind this) */
+ fabOffset?: string;
+ }
+
+ let {
+ inputBarHeight = 72,
+ pillNavVisible = false,
+ pillNavHeight = 68,
+ tagStripVisible = false,
+ tagStripHeight = 50,
+ children,
+ top,
+ pillNavOffset = $bindable('0px'),
+ tagStripOffset = $bindable('72px'),
+ fabOffset = $bindable('20px'),
+ }: Props = $props();
+
+ const BASE = 16;
+
+ // PillNav sits above the InputBar
+ let pillNavBottom = $derived(inputBarHeight);
+
+ // TagStrip sits above PillNav (only when PillNav is visible)
+ let tagStripBottom = $derived(inputBarHeight + (pillNavVisible ? pillNavHeight : 0));
+
+ // Middle content sits above all fixed bars
+ let aboveFixedBars = $derived(
+ inputBarHeight +
+ (pillNavVisible ? pillNavHeight : 0) +
+ (pillNavVisible && tagStripVisible ? tagStripHeight : 0)
+ );
+
+ // Measure middle and top content heights
+ let middleHeight = $state(0);
+ let topHeight = $state(0);
+
+ // Top content sits above middle
+ let topBottom = $derived(aboveFixedBars + middleHeight);
+
+ // FAB should be above everything
+ let fabBottom = $derived(BASE + 4 + aboveFixedBars + middleHeight + topHeight);
+
+ // Sync bindable outputs
+ $effect(() => {
+ pillNavOffset = `${pillNavBottom}px`;
+ });
+ $effect(() => {
+ tagStripOffset = `${tagStripBottom}px`;
+ });
+ $effect(() => {
+ fabOffset = `${fabBottom}px`;
+ });
+
+
+{#if children}
+
+ {@render children()}
+
+{/if}
+
+{#if top}
+
+ {@render top()}
+
+{/if}
+
+
diff --git a/packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte b/packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte
new file mode 100644
index 000000000..f17e4621a
--- /dev/null
+++ b/packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte
@@ -0,0 +1,174 @@
+
+
+{#if pages.length > 0}
+
+ {#each pages as pg (pg.id)}
+
onRestore(pg.id)}
+ onkeydown={(e) => e.key === 'Enter' && onRestore(pg.id)}
+ >
+
+
{pg.title}
+
+
+ {#if onMaximize}
+
+ {/if}
+
+
+
+ {/each}
+
+
+{/if}
+
+
diff --git a/packages/shared-ui/src/bottom-stack/NotificationBar.svelte b/packages/shared-ui/src/bottom-stack/NotificationBar.svelte
new file mode 100644
index 000000000..3926b26fb
--- /dev/null
+++ b/packages/shared-ui/src/bottom-stack/NotificationBar.svelte
@@ -0,0 +1,149 @@
+
+
+{#if active}
+
+
{active.message}
+
+ {#if active.action}
+
+ {/if}
+ {#if active.dismissible !== false}
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/packages/shared-ui/src/bottom-stack/index.ts b/packages/shared-ui/src/bottom-stack/index.ts
new file mode 100644
index 000000000..b0b589f01
--- /dev/null
+++ b/packages/shared-ui/src/bottom-stack/index.ts
@@ -0,0 +1,4 @@
+export { default as BottomStack } from './BottomStack.svelte';
+export { default as MinimizedTabs } from './MinimizedTabs.svelte';
+export { default as NotificationBar } from './NotificationBar.svelte';
+export type { MinimizedPage, MinimizedTabsCallbacks, BottomNotification } from './types';
diff --git a/packages/shared-ui/src/bottom-stack/types.ts b/packages/shared-ui/src/bottom-stack/types.ts
new file mode 100644
index 000000000..e14eb0e52
--- /dev/null
+++ b/packages/shared-ui/src/bottom-stack/types.ts
@@ -0,0 +1,20 @@
+export interface MinimizedPage {
+ id: string;
+ title: string;
+ color: string;
+}
+
+export interface MinimizedTabsCallbacks {
+ restore: (pageId: string) => void;
+ remove: (pageId: string) => void;
+ add: () => void;
+}
+
+export interface BottomNotification {
+ id: string;
+ message: string;
+ type: 'info' | 'warning' | 'error';
+ action?: { label: string; icon?: any; onClick: () => void };
+ dismissible?: boolean;
+ onDismiss?: () => void;
+}