refactor(shell): unify card + route chrome into ModuleShell + RoutePage

Replaces the old PageShell (workbench-only) with a single ModuleShell that
serves both carousel cards (variant=card, width-sized, window actions) and
sub-routes (variant=fill, fills main area, optional back button). RoutePage
wraps ModuleShell with auto-metadata lookup from the app-registry so every
(app)/*/+page.svelte can stay a three-liner.

Drops the dead onMinimize prop-drilling that was declared on PageShell but
never rendered — TodoPage/ContactPage callers cleaned up too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 00:36:54 +02:00
parent 4c2fbece56
commit 13b785b33f
11 changed files with 245 additions and 101 deletions

View file

@ -1,8 +1,8 @@
/**
* Per-module help content description, features, tips.
*
* Rendered inline in the page-body when the user clicks the help (?)
* icon in the PageShell header. Keyed by appId.
* Rendered inline in the shell body when the user clicks the help (?)
* icon in the ModuleShell header. Keyed by appId.
*/
export interface ModuleHelp {

View file

@ -1,4 +1,3 @@
export { default as PageShell } from './PageShell.svelte';
export { default as PageCarousel } from './PageCarousel.svelte';
export type { CarouselPage } from './types';

View file

@ -1,7 +1,23 @@
<!--
PageShell — Shared card wrapper for pages in a carousel.
Provides: header, five preset widths, maximized mode.
Used by workbench (AppPage) and todo (TodoPage).
ModuleShell — Canonical card shell for every Mana module surface.
Replaces the old PageShell + AppPage split. A single component serves
both rendering paths:
variant="card" — width-sized, sits in a PageCarousel next to siblings.
Window actions (close / move / resize / maximize)
belong here. This is what the homepage and the
legacy per-module carousels (e.g. /todo) use.
variant="fill" — fills the main content area. Sub-routes
(/picture, /picture/generate, /library, …) use
this. Back-button replaces the close button when
a backHref is provided. No carousel window
actions.
Visual chrome (paper-texture, soft border, rounded corners, shadow,
header bar) is identical across both variants so the homepage and
sub-routes read as the same system.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
@ -13,52 +29,74 @@
CaretLeft,
CaretRight,
ArrowsOutLineHorizontal,
ArrowLeft,
Question,
} from '@mana/shared-icons';
import type { Snippet, Component } from 'svelte';
import { PAGE_WIDTH_PRESETS, nearestPresetIndex } from './width-presets';
import { PAGE_WIDTH_PRESETS, nearestPresetIndex } from '../page-carousel/width-presets';
interface Props {
widthPx: number;
// Layout mode
variant?: 'card' | 'fill';
/** Required for variant="card" unless maximized. */
widthPx?: number;
maximized?: boolean;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: (widthPx: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
onHelp?: () => void;
helpOpen?: boolean;
// Default header
// Header content
title?: string;
titleHref?: string;
color?: string;
icon?: Component;
// Card-mode actions (carousel window chrome)
onClose?: () => void;
onMaximize?: () => void;
onResize?: (widthPx: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
// Fill-mode actions (route navigation)
/** If provided, renders a back arrow in the header that navigates to this URL. */
backHref?: string;
/** Custom back handler. Takes precedence over backHref — use for history.back() or custom logic. */
onBack?: () => void;
// Shared
onHelp?: () => void;
helpOpen?: boolean;
onContextMenu?: (e: MouseEvent) => void;
// Snippet overrides
// Snippets
header_left?: Snippet;
badge?: Snippet;
/** Renders to the right of the title, before the window actions.
* Use for view-specific controls (e.g. credit badge on /picture/generate). */
actions?: Snippet;
toolbar?: Snippet;
children: Snippet;
}
let {
variant = 'card',
widthPx,
maximized = false,
title = '',
titleHref,
color = '#6B7280',
icon: IconComponent,
onClose,
onMaximize,
onResize,
onMoveLeft,
onMoveRight,
backHref,
onBack,
onHelp,
helpOpen = false,
onContextMenu,
title = '',
titleHref,
color = '#6B7280',
icon: IconComponent,
header_left,
badge,
actions,
toolbar,
children,
}: Props = $props();
@ -79,7 +117,7 @@
let widthMenuOpen = $state(false);
let widthBtnEl = $state<HTMLButtonElement | null>(null);
const activePresetIdx = $derived(nearestPresetIndex(widthPx));
const activePresetIdx = $derived(typeof widthPx === 'number' ? nearestPresetIndex(widthPx) : 0);
function selectWidth(px: number) {
widthMenuOpen = false;
@ -93,12 +131,33 @@
widthBtnEl?.focus();
}
}
// ── Derived layout style ────────────────────────────────
// Card: width is whatever the carousel drag-resizes it to.
// Fill: width is 100% of the main container; layout's max-w-7xl caps it.
const widthStyle = $derived(
variant === 'card' ? `width: ${maximized ? '100%' : `${widthPx ?? 480}px`};` : 'width: 100%;'
);
const showCardActions = $derived(variant === 'card');
const showBackButton = $derived(variant === 'fill' && (backHref || onBack));
</script>
<div class="page-shell" class:maximized style="width: {maximized ? '100%' : `${widthPx}px`};">
<!-- Header with window actions -->
<div class="page-header" oncontextmenu={onContextMenu} role="banner">
<div class="module-shell" class:maximized class:fill={variant === 'fill'} style={widthStyle}>
<!-- Header with window actions / back button -->
<div class="shell-header" oncontextmenu={onContextMenu} role="banner">
<div class="header-left">
{#if showBackButton}
{#if onBack}
<button class="back-btn" onclick={onBack} title="Zurück">
<ArrowLeft size={16} weight="bold" />
</button>
{:else if backHref}
<a class="back-btn" href={backHref} title="Zurück">
<ArrowLeft size={16} weight="bold" />
</a>
{/if}
{/if}
{#if header_left}
{@render header_left()}
{:else}
@ -111,7 +170,7 @@
{/if}
{#if titleHref}
<a
class="page-title page-title-link"
class="shell-title shell-title-link"
href={titleHref}
target="_blank"
rel="noopener noreferrer"
@ -121,14 +180,18 @@
{title}
</a>
{:else}
<span class="page-title">{title}</span>
<span class="shell-title">{title}</span>
{/if}
{/if}
{#if badge}
{@render badge()}
{/if}
</div>
<div class="window-actions">
{#if actions}
{@render actions()}
{/if}
{#if onHelp}
<button
class="window-btn"
@ -142,7 +205,7 @@
<Question size={22} weight="bold" />
</button>
{/if}
{#if onMoveLeft}
{#if showCardActions && onMoveLeft}
<button
class="window-btn"
onclick={(e) => {
@ -154,7 +217,7 @@
<CaretLeft size={24} weight="bold" />
</button>
{/if}
{#if onMoveRight}
{#if showCardActions && onMoveRight}
<button
class="window-btn"
onclick={(e) => {
@ -166,7 +229,7 @@
<CaretRight size={24} weight="bold" />
</button>
{/if}
{#if onResize && !maximized}
{#if showCardActions && onResize && !maximized}
<div class="width-picker-wrapper">
<button
bind:this={widthBtnEl}
@ -222,16 +285,18 @@
/>{/if}
</button>
{/if}
<button
class="window-btn window-btn-close"
onclick={(e) => {
e.stopPropagation();
onClose();
}}
title={$_('common.close')}
>
<X size={24} weight="bold" />
</button>
{#if showCardActions && onClose}
<button
class="window-btn window-btn-close"
onclick={(e) => {
e.stopPropagation();
onClose();
}}
title={$_('common.close')}
>
<X size={24} weight="bold" />
</button>
{/if}
</div>
</div>
@ -241,32 +306,21 @@
{/if}
<!-- Body -->
<div class="page-body">
<div class="shell-body">
{@render children()}
</div>
</div>
<style>
/* P5: theme-token migration. The workbench paper card now follows the
active theme variant, incl. a per-theme paper-grain texture applied
via background-blend-mode (more robust than a ::before overlay +
mix-blend-mode + opacity combo, which had invisibility issues in
dark mode and stacking-context quirks). CSS vars come from
applyThemeToDocument() in @mana/shared-theme — swap one line in
THEME_DEFINITIONS to change the texture for a whole theme. */
.page-shell {
/* Paper card: active-theme-aware texture applied via background-blend-mode.
CSS vars come from applyThemeToDocument() in @mana/shared-theme.
See git log of PageShell.svelte for the blend-mode rationale — the
::before overlay pattern failed in dark mode due to stacking-context
quirks. */
.module-shell {
flex: 0 0 auto;
/* Default page height fills the viewport between the workbench
top padding and the bottom chrome (pill nav + tag strip +
bottom bar). Two CSS vars cascade from the layout's <main>:
- --bottom-chrome-height reacts to pill-nav collapse, tag
strip visibility and bottom-bar mount state
- --workbench-reserved-y collapses the py-* wrapper padding
plus a small buffer into a single "non-chrome vertical"
number so this calc doesn't have to mirror DOM padding
`dvh` accounts for mobile Safari's retractable address bar.
An inline `height: {px}px` style from the resize-drag prop
overrides this value (same specificity rule as before). */
/* Height calc uses layout-supplied CSS vars (--bottom-chrome-height,
--workbench-reserved-y) from routes/(app)/+layout.svelte's <main>. */
height: calc(100dvh - var(--bottom-chrome-height, 80px) - var(--workbench-reserved-y, 2.5rem));
min-height: 320px;
max-width: calc(100vw - 2rem);
@ -275,9 +329,6 @@
background-size: var(--paper-size, 240px 240px);
background-repeat: repeat;
background-blend-mode: var(--paper-blend-mode, multiply);
/* Soft black border — 12% in light mode, bumped to 28% in dark
mode (see :global(.dark) override below) where a low-alpha
black would otherwise vanish into the background. */
border: 2px solid hsl(0 0% 0% / 0.12);
border-radius: 1.25rem;
box-shadow:
@ -289,20 +340,23 @@
overflow: hidden;
position: relative;
}
/* Fill variant: no fade-in-from-side (that was a carousel affordance),
and max-width drops since the layout already caps at max-w-7xl. */
.module-shell.fill {
animation: fadeIn 0.2s ease-out;
max-width: none;
}
/* Dark-mode border needs more alpha to stay visible against the
dark card background. */
:global(.dark) .page-shell {
:global(.dark) .module-shell {
border-color: hsl(0 0% 0% / 0.28);
}
/* A11y: users who asked for reduced transparency/contrast drop the
texture entirely — solid card only. */
@media (prefers-contrast: more) {
.page-shell {
.module-shell {
background-image: none;
}
}
.page-shell.maximized {
.module-shell.maximized {
position: fixed;
inset: 0;
z-index: 95;
@ -335,9 +389,23 @@
transform: translateX(0);
}
}
/* Fill variant enters from below, not from the side. */
.module-shell.fill {
animation-name: fadeInUp;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Header */
.page-header {
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
@ -351,6 +419,25 @@
min-width: 0;
overflow: hidden;
}
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
flex-shrink: 0;
}
.back-btn:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
color: hsl(var(--color-foreground));
}
.header-icon {
flex-shrink: 0;
display: flex;
@ -363,7 +450,7 @@
border-radius: 9999px;
flex-shrink: 0;
}
.page-title {
.shell-title {
font-size: 0.95rem;
font-weight: 600;
color: hsl(var(--color-foreground));
@ -373,12 +460,12 @@
overflow: hidden;
text-overflow: ellipsis;
}
a.page-title-link {
a.shell-title-link {
text-decoration: none;
cursor: pointer;
transition: color 0.15s;
}
a.page-title-link:hover {
a.shell-title-link:hover {
color: hsl(var(--color-primary));
text-decoration: underline;
}
@ -403,36 +490,35 @@
transition: all 0.15s;
}
.window-btn:hover {
background: hsl(var(--color-surface-hover));
background: hsl(var(--color-surface-hover, var(--color-muted)));
color: hsl(var(--color-foreground));
}
.window-btn-close:hover {
background: hsl(var(--color-error) / 0.15);
color: hsl(var(--color-error));
}
.window-btn-active {
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
}
/* Body */
.page-body {
.shell-body {
flex: 1;
overflow-y: auto;
min-height: 200px;
}
.maximized .page-header {
.maximized .shell-header {
max-width: 48rem;
margin-inline: auto;
width: 100%;
}
.maximized .page-body {
.maximized .shell-body {
max-width: 48rem;
margin-inline: auto;
width: 100%;
}
.window-btn-active {
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
}
/* Width picker */
.width-picker-wrapper {
position: relative;

View file

@ -0,0 +1,59 @@
<!--
RoutePage — auto-metadata wrapper around ModuleShell for sub-route pages.
Looks up title / icon / color from the app-registry by appId so every
(app)/{appId}/+page.svelte can stay three lines:
<RoutePage appId="library">
<ListView />
</RoutePage>
For custom titles (sub-views like /picture/generate), custom actions,
or a back button, the caller can override or use ModuleShell directly.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { getApp } from '$lib/app-registry';
import { ModuleShell } from './index';
interface Props {
/** App descriptor id from the registry (matches apps.ts `id:`). */
appId: string;
/** Override the registry title (useful for sub-views). */
title?: string;
/** Back button target. When set, header shows a back arrow instead of nothing. */
backHref?: string;
onBack?: () => void;
/** Right-side header slot for view-specific controls. */
actions?: Snippet;
/** Toolbar slot rendered below the header, above the body. */
toolbar?: Snippet;
children: Snippet;
}
let {
appId,
title: titleOverride,
backHref,
onBack,
actions,
toolbar,
children,
}: Props = $props();
const app = $derived(getApp(appId));
const resolvedTitle = $derived(titleOverride ?? app?.name ?? appId);
</script>
<ModuleShell
variant="fill"
title={resolvedTitle}
icon={app?.icon}
color={app?.color}
{backHref}
{onBack}
{actions}
{toolbar}
>
{@render children()}
</ModuleShell>

View file

@ -0,0 +1,2 @@
export { default as ModuleShell } from './ModuleShell.svelte';
export { default as RoutePage } from './RoutePage.svelte';

View file

@ -6,7 +6,7 @@
<script lang="ts">
import { X, CaretUp, CaretDown, ArrowLeft, SpinnerGap } from '@mana/shared-icons';
import { _ } from 'svelte-i18n';
import { PageShell } from '$lib/components/page-carousel';
import { ModuleShell } from '$lib/components/shell';
import { getApp, getAppByDragType, canDrop, executeDrop } from '$lib/app-registry';
import type { Component } from 'svelte';
import { dropTarget } from '@mana/shared-ui/dnd';
@ -295,8 +295,8 @@
canDrop: (p) => canDrop(p.type, appId),
}}
>
<!-- Base: PageShell with list view (always visible) -->
<PageShell
<!-- Base: ModuleShell with list view (always visible) -->
<ModuleShell
{widthPx}
{maximized}
title={appName}
@ -351,7 +351,7 @@
<SpinnerGap size={24} class="spinner" />
</div>
{/if}
</PageShell>
</ModuleShell>
<!-- Overlay: Detail view floating above -->
{#if overlay?.component || closing}
@ -409,12 +409,12 @@
.app-page-wrapper {
position: relative;
}
:global(.app-page-wrapper.mana-drop-target-hover) :global(.page-shell) {
:global(.app-page-wrapper.mana-drop-target-hover) :global(.module-shell) {
outline: 2px solid hsl(var(--color-primary) / 0.5);
outline-offset: -2px;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.15);
}
:global(.app-page-wrapper.mana-drop-target-success) :global(.page-shell) {
:global(.app-page-wrapper.mana-drop-target-success) :global(.module-shell) {
outline: 2px solid hsl(var(--color-success) / 0.5);
outline-offset: -2px;
}

View file

@ -1,6 +1,6 @@
<!--
AI Missions app — workbench card.
Renders inside AppPage, which provides the PageShell + window chrome.
Renders inside AppPage, which provides the ModuleShell + window chrome.
Master-detail inline (list ↔ create ↔ detail) in a single panel.
-->
<script lang="ts">

View file

@ -90,7 +90,7 @@
flex-direction: column;
gap: 1rem;
/* Innen-Padding als Single-Source-of-Truth. In Workbench-Karten */
/* hat PageShell's `.page-body` null padding — ohne das hier würde */
/* hat ModuleShell's `.shell-body` null padding — ohne das hier würde */
/* der QuickAdd-Input direkt am Card-Rand kleben. Im Route-Kontext */
/* liegt dieses Padding innerhalb des (app)-Layout-Wrappers und */
/* ergibt insgesamt ein ruhig gespaciedes Bild. */

View file

@ -1,6 +1,6 @@
<!--
ContactPage — A single page in the contacts carousel.
Shows a filtered/sorted contact list inside a PageShell.
Shows a filtered/sorted contact list inside a ModuleShell.
-->
<script lang="ts">
import { isToday, differenceInDays, startOfDay, setYear } from 'date-fns';
@ -19,7 +19,7 @@
MapPin,
Clock,
} from '@mana/shared-icons';
import { PageShell } from '$lib/components/page-carousel';
import { ModuleShell } from '$lib/components/shell';
import type { Contact } from '../../types';
import { SELF_CONTACT_ID } from '../../collections';
import {
@ -211,14 +211,13 @@
}
</script>
<PageShell
<ModuleShell
{widthPx}
{maximized}
title={meta.title}
color={meta.color}
icon={meta.icon}
{onClose}
{onMinimize}
{onMaximize}
{onResize}
>
@ -252,7 +251,7 @@
{/each}
{/if}
</div>
</PageShell>
</ModuleShell>
{#snippet profileCard(contact: Contact)}
<button

View file

@ -18,7 +18,7 @@
Leaf,
Heart,
} from '@mana/shared-icons';
import { PageShell } from '$lib/components/page-carousel';
import { ModuleShell } from '$lib/components/shell';
interface Props {
pageId: string;
@ -223,13 +223,12 @@
}
</script>
<PageShell
<ModuleShell
{widthPx}
{maximized}
color={displayColor}
icon={IconComponent}
{onClose}
{onMinimize}
{onMaximize}
{onResize}
>
@ -310,7 +309,7 @@
</div>
{/if}
</div>
</PageShell>
</ModuleShell>
<style>
.header-icon {

View file

@ -1001,7 +1001,7 @@
<!-- Main content.
Publish layout offsets as CSS variables so descendants (esp.
PageShell in the carousel) can compute their available
ModuleShell in the carousel) can compute their available
height against viewport + bottom chrome without prop
drilling. `--workbench-top-offset` must match the vertical
padding on the inner max-w-7xl wrapper below. -->