feat(workbench): dynamic page height + tighter bottom-stack spacing

PageShell cards now fill the available viewport between the workbench
top padding and the bottom chrome instead of using a static 60vh.
Height is calculated via two CSS vars published by the layout <main>:

  height: calc(100dvh - var(--bottom-chrome-height) - var(--workbench-reserved-y))

--bottom-chrome-height reacts to pill-nav collapse, tag strip toggle
and bottom-bar mount state. --workbench-reserved-y (2.5rem) folds the
wrapper padding + buffer into a single non-chrome offset. dvh handles
Safari's retractable address bar. Inline height from resize-drag still
overrides as before.

Bottom-stack bars now use a uniform `gap: 0.25rem` instead of ad-hoc
per-child padding-bottom, giving consistent 4px spacing between all
bars. Wrapper vertical padding reduced from py-4/py-8 to py-2/py-3
and main's bottom buffer from +32px to +8px — cards gain ~72px of
usable vertical space on a typical viewport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 14:15:47 +02:00
parent 68c2442419
commit 474ba93d70
2 changed files with 262 additions and 143 deletions

View file

@ -243,7 +243,19 @@
THEME_DEFINITIONS to change the texture for a whole theme. */
.page-shell {
flex: 0 0 auto;
min-height: 60vh;
/* 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(100dvh - var(--bottom-chrome-height, 80px) - var(--workbench-reserved-y, 2.5rem));
min-height: 320px;
max-width: calc(100vw - 2rem);
background-color: hsl(var(--color-card));
background-image: var(--paper-texture, none);

View file

@ -13,6 +13,7 @@
import { locale, _ } from 'svelte-i18n';
import {
PillNavigation,
PillDropdownBar,
TagStrip,
DragPreview,
ActionZone,
@ -21,6 +22,7 @@
import type {
PillNavItem,
PillDropdownItem,
PillBarConfig,
SpotlightAction,
ContentSearcher,
ContextMenuItem,
@ -330,9 +332,47 @@
isTagStripVisible = !isTagStripVisible;
}
// ── QuickInputBar visibility (toggled by the "search" pill) ──
let isQuickInputVisible = $state(true);
function handleQuickInputToggle() {
isQuickInputVisible = !isQuickInputVisible;
}
// ── Workbench tab bar visibility (toggled by the "tabs" pill) ──
// Controls whether the page-injected bottomBar (SceneAppBar on /) is rendered.
let isBottomBarVisible = $state(true);
function handleBottomBarToggle() {
isBottomBarVisible = !isBottomBarVisible;
}
// ── Dropdown-as-bar ──────────────────────────────────────
// Theme / AI tier / Sync / User-menu dropdowns are surfaced as
// bars in the bottom stack instead of floating popovers. PillNavigation
// calls handleOpenBar with a PillBarConfig (or null to close); we
// render the items via PillDropdownBar just above the PillNav.
let activeBar = $state<PillBarConfig | null>(null);
function handleOpenBar(config: PillBarConfig | null) {
activeBar = config;
}
function closeActiveBar() {
activeBar = null;
}
// ── Fullscreen mode (press "f" to toggle) ───────────────
// Hides the entire bottom-stack (pill nav, QuickInputBar, TagStrip,
// notifications, bottom bar) so only the routed page content
// (e.g. workbench pages) is visible. Esc exits.
let isFullscreen = $state(false);
// Bottom chrome height: calculated from state, not measured (avoids reflow loop)
const bottomChromeHeight = $derived(
(isCollapsed ? 0 : 80) + (isTagStripVisible ? 44 : 0) + 72 + (bottomBarStore.component ? 36 : 0)
isFullscreen
? 0
: (isCollapsed ? 0 : 80) +
(activeBar ? 56 : 0) +
(isTagStripVisible ? 44 : 0) +
(isQuickInputVisible ? 72 : 0) +
(isBottomBarVisible && bottomBarStore.component ? 36 : 0)
);
// ── DnD context ─────────────────────────────────────────
@ -379,10 +419,26 @@
href: '/',
label: $_('nav.tags'),
icon: 'tag',
iconOnly: true,
onClick: handleTagStripToggle,
active: isTagStripVisible,
},
{ href: '/', label: $_('nav.home'), icon: 'home', onContextMenu: makeNavContextMenu('/') },
{
href: '/',
label: 'Suche',
icon: 'search',
iconOnly: true,
onClick: handleQuickInputToggle,
active: isQuickInputVisible,
},
{
href: '/',
label: 'Workbench-Tabs',
icon: 'columns',
iconOnly: true,
onClick: handleBottomBarToggle,
active: isBottomBarVisible,
},
]);
let isAdmin = $derived(authStore.user?.role === 'admin');
@ -401,6 +457,21 @@
showShortcuts = !showShortcuts;
return;
}
if (
(event.key === 'f' || event.key === 'F') &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
event.preventDefault();
isFullscreen = !isFullscreen;
return;
}
if (event.key === 'Escape' && isFullscreen) {
event.preventDefault();
isFullscreen = false;
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
@ -670,10 +741,14 @@
{/if}
<div class="min-h-screen bg-background">
<!-- Bottom Stack: all fixed-bottom elements in one flex container -->
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
Hidden entirely when fullscreen mode is active (press "f"). -->
{#if !isFullscreen}
<div class="bottom-stack" style:--bottom-chrome-height="{bottomChromeHeight}px">
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs) -->
{#if bottomBarStore.component}
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs).
Gated by isBottomBarVisible so the "workbench tabs" pill can
toggle it without unmounting the owning page. -->
{#if isBottomBarVisible && bottomBarStore.component}
{@const BarComponent = bottomBarStore.component}
<BarComponent {...bottomBarStore.props} />
{/if}
@ -735,7 +810,8 @@
<SuggestionToast />
</div>
<!-- QuickInputBar with inline nav toggle -->
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
{#if isQuickInputVisible}
<QuickInputBar
onSearch={inputBarAdapter.onSearch}
onSelect={inputBarAdapter.onSelect}
@ -765,6 +841,7 @@
</button>
{/snippet}
</QuickInputBar>
{/if}
<!-- TagStrip (between QuickInputBar and PillNav) -->
{#if isTagStripVisible}
@ -784,8 +861,23 @@
/>
{/if}
<!-- Dropdown-as-bar: shows the items of the currently opened
PillNavigation dropdown (theme / AI / sync / user) as
horizontal pills directly above the PillNav. -->
{#if activeBar}
<PillDropdownBar
items={activeBar.items}
label={activeBar.label}
icon={activeBar.icon}
onClose={closeActiveBar}
positioning="static"
/>
{/if}
<!-- PillNav (bottom of stack) -->
<PillNavigation
onOpenBar={handleOpenBar}
activeBarId={activeBar?.id ?? null}
items={navItems}
currentPath={$page.url.pathname}
appName="Mana"
@ -830,13 +922,22 @@
positioning="static"
/>
</div>
{/if}
<!-- DnD: floating preview -->
<DragPreview />
<!-- Main content -->
<main style="padding-bottom: {bottomChromeHeight + 32}px">
<div class="mx-auto max-w-7xl px-3 py-4 sm:px-6 sm:py-8 lg:px-8">
<!-- Main content.
Publish layout offsets as CSS variables so descendants (esp.
PageShell 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. -->
<main
style="padding-bottom: {bottomChromeHeight +
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 2.5rem;"
>
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if routeBlocked && routeAppId}
<!-- Per-route tier gate. The wrapping AuthGate only fires
onMount + only for authenticated users, so this is the
@ -948,6 +1049,9 @@
display: flex;
flex-direction: column;
align-items: stretch;
/* Uniform small gap between bars instead of each wrapper
providing its own ad-hoc padding-bottom. */
gap: 0.25rem;
pointer-events: none;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@ -992,6 +1096,9 @@
.bottom-stack-notification {
display: flex;
justify-content: center;
padding: 0 1rem 0.5rem;
/* Only horizontal padding — vertical spacing comes from the
parent .bottom-stack `gap`, so all bars are evenly spaced
regardless of how many children they nest. */
padding: 0 1rem;
}
</style>