feat(workbench): improve fullscreen, scene bar styling, and inline scene creation

- PageShell maximized: fill viewport edge-to-edge (max-width none, z-index 95
  above bottom bar, border none), constrain body/header to 48rem centered, Esc
  exits fullscreen
- SceneAppBar: pill-shaped bar and items, match TagStrip font size (0.9375rem),
  visual group bracket for active scene with separator, tab count on all scenes,
  inline name input for new scenes instead of modal dialog
- SceneRenameDialog: remove icon field, simplify to name-only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 16:45:09 +02:00
parent 971ad37b4a
commit 65160024f7
4 changed files with 242 additions and 138 deletions

View file

@ -5,6 +5,7 @@
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { X, CornersOut, CornersIn, CaretLeft, CaretRight } from '@mana/shared-icons';
import type { Snippet, Component } from 'svelte';
@ -51,6 +52,19 @@
children,
}: Props = $props();
// Escape exits maximized mode
onMount(() => {
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && maximized && onMaximize) {
e.preventDefault();
e.stopPropagation();
onMaximize();
}
}
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
const MIN_WIDTH = 280;
const MAX_WIDTH = 1200;
const MIN_HEIGHT = 200;
@ -298,10 +312,13 @@
.page-shell.maximized {
position: fixed;
inset: 0;
z-index: 50;
z-index: 95;
width: 100% !important;
min-height: 100vh;
max-width: none;
height: 100dvh !important;
min-height: 100dvh;
border-radius: 0;
border: none;
box-shadow: none;
animation: fadeInScale 0.2s ease-out;
}
@ -400,6 +417,16 @@
overflow-y: auto;
min-height: 200px;
}
.maximized .page-header {
max-width: 48rem;
margin-inline: auto;
width: 100%;
}
.maximized .page-body {
max-width: 48rem;
margin-inline: auto;
width: 100%;
}
/* Resize handle */
.resize-handle {

View file

@ -3,7 +3,8 @@
Rendered by the layout's bottom-stack via bottomBarStore.
-->
<script lang="ts">
import { Plus } from '@mana/shared-icons';
import { Plus, Check, X } from '@mana/shared-icons';
import { tick } from 'svelte';
import type { CarouselPage } from '$lib/components/page-carousel/types';
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
@ -12,7 +13,7 @@
activeSceneId: string | null;
pages: CarouselPage[];
onSceneSelect: (id: string) => void;
onSceneCreate: () => void;
onSceneCreate: (name: string) => void;
onSceneContextMenu: (e: MouseEvent, scene: WorkbenchScene) => void;
onAppClick: (id: string) => void;
onAppContextMenu: (e: MouseEvent, id: string) => void;
@ -30,54 +31,119 @@
onAppContextMenu,
onAddApp,
}: Props = $props();
let creating = $state(false);
let newName = $state('');
let inputEl = $state<HTMLInputElement | null>(null);
async function startCreate() {
creating = true;
newName = '';
await tick();
inputEl?.focus();
}
function submitCreate() {
const trimmed = newName.trim();
if (trimmed) {
onSceneCreate(trimmed);
}
creating = false;
newName = '';
}
function cancelCreate() {
creating = false;
newName = '';
}
function handleInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
submitCreate();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelCreate();
}
}
</script>
<div class="scene-app-bar">
{#each scenes as scene (scene.id)}
{@const isActive = scene.id === activeSceneId}
<button
type="button"
class="scene-pill"
class:active={isActive}
onclick={() => onSceneSelect(scene.id)}
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
>
{#if scene.icon}
<span class="scene-icon">{scene.icon}</span>
{/if}
<span class="scene-name">{scene.name}</span>
</button>
<!-- App tabs appear inline right after the active scene pill -->
{#if isActive && pages.length > 0}
{#each pages as p (p.id)}
{@const AppIcon = p.icon}
<!-- Active scene + its app tabs wrapped in a visual group -->
<div class="scene-group">
<button
class="app-tab"
onclick={() => onAppClick(p.id)}
oncontextmenu={(e) => onAppContextMenu(e, p.id)}
type="button"
class="scene-pill active"
onclick={() => onSceneSelect(scene.id)}
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
>
{#if AppIcon}
<span class="app-icon" style="color: {p.color}">
<AppIcon size={12} weight="fill" />
</span>
{:else}
<span class="app-dot" style="background-color: {p.color}"></span>
{/if}
<span class="app-title">{p.title}</span>
<span class="scene-name">{scene.name}</span>
<span class="scene-count">{scene.openApps.length}</span>
</button>
{/each}
<button class="app-add" onclick={onAddApp} title="App hinzufügen">
<Plus size={12} />
<span class="group-sep"></span>
{#each pages as p (p.id)}
{@const AppIcon = p.icon}
<button
class="app-tab"
onclick={() => onAppClick(p.id)}
oncontextmenu={(e) => onAppContextMenu(e, p.id)}
>
{#if AppIcon}
<span class="app-icon" style="color: {p.color}">
<AppIcon size={12} weight="fill" />
</span>
{:else}
<span class="app-dot" style="background-color: {p.color}"></span>
{/if}
<span class="app-title">{p.title}</span>
</button>
{/each}
<button class="app-add" onclick={onAddApp} title="App hinzufügen">
<Plus size={12} />
</button>
</div>
{:else}
<button
type="button"
class="scene-pill"
class:active={isActive}
onclick={() => onSceneSelect(scene.id)}
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
>
<span class="scene-name">{scene.name}</span>
<span class="scene-count">{scene.openApps.length}</span>
</button>
{#if scenes.length > 1}
<span class="bar-sep"></span>
{/if}
{/if}
{/each}
<button type="button" class="scene-add" onclick={onSceneCreate} title="Neue Szene">
<Plus size={12} />
</button>
{#if creating}
<div class="inline-create">
<input
bind:this={inputEl}
class="inline-create-input"
type="text"
maxlength="40"
placeholder="Name…"
bind:value={newName}
onkeydown={handleInputKeydown}
onblur={submitCreate}
/>
<button class="inline-create-btn confirm" onclick={submitCreate} title="Erstellen">
<Check size={14} weight="bold" />
</button>
<button class="inline-create-btn cancel" onclick={cancelCreate} title="Abbrechen">
<X size={14} weight="bold" />
</button>
</div>
{:else}
<button type="button" class="scene-add" onclick={startCreate} title="Neue Szene">
<Plus size={14} />
</button>
{/if}
</div>
<style>
@ -85,15 +151,15 @@
display: flex;
align-items: center;
justify-content: center;
gap: 0.125rem;
padding: 0.3125rem 0.625rem;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
margin: 0 auto;
width: fit-content;
max-width: calc(100vw - 2rem);
pointer-events: auto;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
border-radius: 9999px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow-x: auto;
scrollbar-width: none;
@ -101,15 +167,17 @@
.scene-app-bar::-webkit-scrollbar {
display: none;
}
.bar-sep {
width: 1px;
height: 16px;
background: hsl(var(--color-border));
flex-shrink: 0;
margin: 0 0.25rem;
.scene-group {
display: inline-flex;
align-items: center;
gap: 0.125rem;
background: hsl(var(--color-primary) / 0.1);
border: 1.5px solid hsl(var(--color-primary) / 0.25);
border-radius: 9999px;
padding: 0.1875rem;
}
/* Scene pills — bold group headers */
/* Scene pills */
.scene-pill {
display: inline-flex;
align-items: center;
@ -118,12 +186,10 @@
border: none;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.3125rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
max-width: 140px;
@ -133,19 +199,26 @@
color: hsl(var(--color-foreground));
}
.scene-pill.active {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
box-shadow: inset 0 0 0 1px hsl(var(--color-primary) / 0.25);
}
.scene-icon {
font-size: 0.8125rem;
line-height: 1;
background: transparent;
color: hsl(var(--color-foreground));
box-shadow: none;
}
.scene-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-count {
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.group-sep {
width: 1px;
height: 1.25rem;
background: hsl(var(--color-border));
flex-shrink: 0;
}
.scene-add {
display: inline-flex;
align-items: center;
@ -155,7 +228,7 @@
background: transparent;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem;
border-radius: 0.375rem;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
@ -164,13 +237,63 @@
color: hsl(var(--color-primary));
}
/* Inline create */
.inline-create {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.inline-create-input {
width: 120px;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
border: 1.5px solid hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-card));
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
outline: none;
transition: border-color 0.15s;
}
.inline-create-input:focus {
border-color: hsl(var(--color-primary));
}
.inline-create-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
padding: 0;
}
.inline-create-btn.confirm {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.inline-create-btn.confirm:hover {
background: hsl(var(--color-primary) / 0.25);
}
.inline-create-btn.cancel {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.inline-create-btn.cancel:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
/* App tabs — lighter, inline after active scene */
.app-tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.375rem;
border-radius: 0.3125rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
@ -194,8 +317,8 @@
flex-shrink: 0;
}
.app-title {
font-size: 0.6875rem;
font-weight: 400;
font-size: 0.9375rem;
font-weight: 500;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
@ -207,7 +330,7 @@
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.1875rem;
border-radius: 0.25rem;
border-radius: 9999px;
display: flex;
align-items: center;
transition: all 0.15s;

View file

@ -1,17 +1,13 @@
<!--
SceneRenameDialog — modal for creating or renaming a workbench scene.
Single dialog handles both flows; the parent decides which by passing an
initial name (empty for create, current name for rename) and a title.
SceneRenameDialog — modal for renaming a workbench scene.
-->
<script lang="ts">
interface Props {
show: boolean;
title: string;
initialName?: string;
initialIcon?: string;
confirmLabel?: string;
onSubmit: (name: string, icon: string | undefined) => void | Promise<void>;
onSubmit: (name: string) => void | Promise<void>;
onCancel: () => void;
}
@ -19,23 +15,18 @@
show,
title,
initialName = '',
initialIcon = '',
confirmLabel = 'Speichern',
onSubmit,
onCancel,
}: Props = $props();
let name = $state('');
let icon = $state('');
let pending = $state(false);
let inputEl = $state<HTMLInputElement | null>(null);
// Reset local fields each time the dialog opens with new props.
$effect(() => {
if (show) {
name = initialName;
icon = initialIcon;
// Focus on next tick once the input is mounted.
queueMicrotask(() => inputEl?.focus());
}
});
@ -45,7 +36,7 @@
if (pending || !name.trim()) return;
pending = true;
try {
await onSubmit(name.trim(), icon.trim() || undefined);
await onSubmit(name.trim());
} finally {
pending = false;
}
@ -72,30 +63,18 @@
>
<h3 id="srd-title" class="srd-title">{title}</h3>
<form onsubmit={handleSubmit}>
<div class="srd-fields">
<label class="srd-field srd-field-icon">
<span class="srd-label">Icon</span>
<input
class="srd-input srd-icon-input"
type="text"
maxlength="2"
placeholder="🏠"
bind:value={icon}
/>
</label>
<label class="srd-field srd-field-name">
<span class="srd-label">Name</span>
<input
class="srd-input"
type="text"
maxlength="40"
placeholder="z.B. Deep Work"
bind:this={inputEl}
bind:value={name}
required
/>
</label>
</div>
<label class="srd-field">
<span class="srd-label">Name</span>
<input
class="srd-input"
type="text"
maxlength="40"
placeholder="z.B. Deep Work"
bind:this={inputEl}
bind:value={name}
required
/>
</label>
<div class="srd-actions">
<button
type="button"
@ -150,22 +129,11 @@
color: hsl(var(--color-foreground));
margin: 0 0 1rem;
}
.srd-fields {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.srd-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.srd-field-icon {
flex: 0 0 auto;
width: 64px;
}
.srd-field-name {
flex: 1;
margin-bottom: 1.25rem;
}
.srd-label {
font-size: 0.75rem;
@ -185,10 +153,6 @@
.srd-input:focus {
border-color: hsl(var(--color-primary));
}
.srd-icon-input {
text-align: center;
font-size: 1.25rem;
}
.srd-actions {
display: flex;
justify-content: flex-end;

View file

@ -101,7 +101,7 @@
activeSceneId,
pages: carouselPages,
onSceneSelect: (id: string) => workbenchScenesStore.setActiveScene(id),
onSceneCreate: handleCreateScene,
onSceneCreate: (name: string) => workbenchScenesStore.createScene({ name }),
onSceneContextMenu: handleSceneContextMenu,
onAppClick: scrollToPage,
onAppContextMenu: (e: MouseEvent, id: string) => handleTabContextMenu(e, id),
@ -199,28 +199,19 @@
}
// ── Scene CRUD dialogs ──────────────────────────────────
type SceneDialogMode =
| { kind: 'create' }
| { kind: 'rename'; id: string; name: string; icon?: string };
type SceneDialogMode = { kind: 'rename'; id: string; name: string };
let sceneDialog = $state<SceneDialogMode | null>(null);
let sceneToDelete = $state<{ id: string; name: string } | null>(null);
function handleCreateScene() {
sceneDialog = { kind: 'create' };
}
function handleRequestRename(id: string) {
const scene = scenes.find((s) => s.id === id);
if (!scene) return;
sceneDialog = { kind: 'rename', id, name: scene.name, icon: scene.icon };
sceneDialog = { kind: 'rename', id, name: scene.name };
}
async function handleSubmitSceneDialog(name: string, icon: string | undefined) {
async function handleSubmitSceneDialog(name: string) {
const mode = sceneDialog;
if (!mode) return;
if (mode.kind === 'create') {
await workbenchScenesStore.createScene({ name, icon });
} else {
await workbenchScenesStore.renameScene(mode.id, name, icon);
}
await workbenchScenesStore.renameScene(mode.id, name);
sceneDialog = null;
}
function handleDuplicateScene(id: string) {
@ -287,10 +278,9 @@
<SceneRenameDialog
show={sceneDialog !== null}
title={sceneDialog?.kind === 'rename' ? 'Szene umbenennen' : 'Neue Szene'}
initialName={sceneDialog?.kind === 'rename' ? sceneDialog.name : ''}
initialIcon={sceneDialog?.kind === 'rename' ? (sceneDialog.icon ?? '') : ''}
confirmLabel={sceneDialog?.kind === 'rename' ? 'Speichern' : 'Erstellen'}
title="Szene umbenennen"
initialName={sceneDialog?.name ?? ''}
confirmLabel="Speichern"
onSubmit={handleSubmitSceneDialog}
onCancel={() => (sceneDialog = null)}
/>