feat(workbench): render scene header left of the first page

New SceneHeader component: big scene name (clamp 2.75rem–4.5rem
responsive) plus a muted description underneath, or an italic
"Beschreibung hinzufügen…" placeholder when empty. The whole block
is a button — clicking it opens the existing scene edit dialog,
now pulling double duty for both name and description.

Wired through PageCarousel's new leading snippet from the previous
commit, so the header scrolls with the track and stays anchored to
the visual start of the carousel without needing a second scroll
container.

SceneRenameDialog grows a description textarea (maxlength 240,
3 rows, vertically resizable) and onSubmit now passes (name,
description). The caller translates an empty description to null
so the DB column reflects "no description set" rather than an
empty string — keeps WorkbenchScene.description truthy checks
honest.

handleEditActiveScene resolves the currently-active scene and
opens the dialog pre-filled; used by the SceneHeader click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 19:42:20 +02:00
parent 8f3ffefdf1
commit db8e681120
3 changed files with 133 additions and 7 deletions

View file

@ -0,0 +1,87 @@
<!--
SceneHeader — large title + description shown left of the first page
in the workbench carousel. Part of the scroll track, so it slides away
as the user moves right (intentional: it's an intro block, not chrome).
Click opens the existing rename dialog for editing name + description.
-->
<script lang="ts">
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
interface Props {
scene: WorkbenchScene | null;
onEdit: () => void;
}
const { scene, onEdit }: Props = $props();
</script>
{#if scene}
<button
type="button"
class="scene-header"
onclick={onEdit}
aria-label="Szene bearbeiten"
title="Klicken, um Name und Beschreibung zu bearbeiten"
>
<h1 class="scene-name">{scene.name}</h1>
<p class="scene-desc" class:placeholder={!scene.description}>
{scene.description || 'Beschreibung hinzufügen…'}
</p>
</button>
{/if}
<style>
.scene-header {
width: 420px;
max-width: 60vw;
padding: 2rem 2.5rem 2rem 0;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.75rem;
color: hsl(var(--color-foreground));
transition: opacity 0.2s;
}
.scene-header:hover {
opacity: 0.85;
}
.scene-header:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 0.5rem;
border-radius: 0.5rem;
}
.scene-name {
margin: 0;
font-size: clamp(2.75rem, 5vw, 4.5rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.02em;
color: hsl(var(--color-foreground));
}
.scene-desc {
margin: 0;
font-size: 1rem;
line-height: 1.45;
color: hsl(var(--color-muted-foreground));
max-width: 32ch;
}
.scene-desc.placeholder {
opacity: 0.55;
font-style: italic;
}
@media (max-width: 639px) {
.scene-header {
width: 280px;
padding: 1.25rem 1.5rem 1.25rem 0;
gap: 0.5rem;
}
.scene-desc {
font-size: 0.875rem;
}
}
</style>

View file

@ -6,8 +6,9 @@
show: boolean;
title: string;
initialName?: string;
initialDescription?: string;
confirmLabel?: string;
onSubmit: (name: string) => void | Promise<void>;
onSubmit: (name: string, description: string) => void | Promise<void>;
onCancel: () => void;
}
@ -15,18 +16,21 @@
show,
title,
initialName = '',
initialDescription = '',
confirmLabel = 'Speichern',
onSubmit,
onCancel,
}: Props = $props();
let name = $state('');
let description = $state('');
let pending = $state(false);
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
if (show) {
name = initialName;
description = initialDescription;
queueMicrotask(() => inputEl?.focus());
}
});
@ -36,7 +40,7 @@
if (pending || !name.trim()) return;
pending = true;
try {
await onSubmit(name.trim());
await onSubmit(name.trim(), description);
} finally {
pending = false;
}
@ -75,6 +79,16 @@
required
/>
</label>
<label class="srd-field">
<span class="srd-label">Beschreibung</span>
<textarea
class="srd-input srd-textarea"
maxlength="240"
rows="3"
placeholder="Wofür ist diese Szene gedacht?"
bind:value={description}
></textarea>
</label>
<div class="srd-actions">
<button
type="button"
@ -153,6 +167,12 @@
.srd-input:focus {
border-color: hsl(var(--color-primary));
}
.srd-textarea {
resize: vertical;
min-height: 4rem;
font-family: inherit;
line-height: 1.45;
}
.srd-actions {
display: flex;
justify-content: flex-end;

View file

@ -3,6 +3,7 @@
import AppPagePicker from '$lib/components/workbench/AppPagePicker.svelte';
import SceneAppBar from '$lib/components/workbench/SceneAppBar.svelte';
import SceneRenameDialog from '$lib/components/workbench/scenes/SceneRenameDialog.svelte';
import SceneHeader from '$lib/components/workbench/scenes/SceneHeader.svelte';
import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte';
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
import { getApp, getAppByDragType, isAppAccessible } from '$lib/app-registry';
@ -269,21 +270,35 @@
}
// ── Scene CRUD dialogs ──────────────────────────────────
type SceneDialogMode = { kind: 'rename'; id: string; name: string };
type SceneDialogMode = {
kind: 'rename';
id: string;
name: string;
description: string;
};
let sceneDialog = $state<SceneDialogMode | null>(null);
let sceneToDelete = $state<{ id: string; name: string } | null>(null);
function handleRequestRename(id: string) {
const scene = scenes.find((s) => s.id === id);
if (!scene) return;
sceneDialog = { kind: 'rename', id, name: scene.name };
sceneDialog = {
kind: 'rename',
id,
name: scene.name,
description: scene.description ?? '',
};
}
async function handleSubmitSceneDialog(name: string) {
async function handleSubmitSceneDialog(name: string, description: string) {
const mode = sceneDialog;
if (!mode) return;
await workbenchScenesStore.renameScene(mode.id, name);
await workbenchScenesStore.renameScene(mode.id, name, description.trim() || null);
sceneDialog = null;
}
function handleEditActiveScene() {
const active = workbenchScenesStore.activeScene;
if (active) handleRequestRename(active.id);
}
function handleDuplicateScene(id: string) {
workbenchScenesStore.duplicateScene(id);
}
@ -336,6 +351,9 @@
userTier={authStore.user?.tier}
/>
{/snippet}
{#snippet leading()}
<SceneHeader scene={workbenchScenesStore.activeScene} onEdit={handleEditActiveScene} />
{/snippet}
</PageCarousel>
<ContextMenu
@ -348,8 +366,9 @@
<SceneRenameDialog
show={sceneDialog !== null}
title="Szene umbenennen"
title="Szene bearbeiten"
initialName={sceneDialog?.name ?? ''}
initialDescription={sceneDialog?.description ?? ''}
confirmLabel="Speichern"
onSubmit={handleSubmitSceneDialog}
onCancel={() => (sceneDialog = null)}