feat(workbench): auto-scroll on scene switch, unify rename to inline

Two tightly coupled UX fixes on the homepage:

1. Switching scenes via SceneAppBar used to leave the carousel
   parked wherever it was before — the new scene's SceneHeader
   appeared off-screen to the left, and the user saw a seemingly
   stale row of cards until they manually scrolled. Now an
   $effect watches activeSceneId, tracks the last seen value, and
   smooth-scrolls the .fokus-track to left=0 on every real change
   (ignoring the initial hydration tick so we don't fight the
   carousel's own centering).

2. Scene rename had two concurrent paths: the SceneHeader
   contenteditable <h1> (live DOM) and a SceneRenameDialog modal
   opened from the scene-pill context menu (reads from the store).
   If a user was mid-edit inline and right-clicked Umbenennen,
   the dialog opened with the pre-edit value and on save clobbered
   the inline change. The modal is gone. The context-menu
   "Umbenennen" entry now switches to the target scene, scrolls
   the carousel to the header, and focuses the contenteditable
   after a 120ms tick so the scroll has time to start. Single
   source of truth, single code path.

SceneRenameDialog.svelte + the sceneDialog state machine in
+page.svelte are removed. ConfirmDialog (used for delete) is
untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 21:34:22 +02:00
parent f06ca2c7c3
commit db959b6f8f
2 changed files with 40 additions and 238 deletions

View file

@ -1,208 +0,0 @@
<!--
SceneRenameDialog — modal for renaming a workbench scene.
-->
<script lang="ts">
interface Props {
show: boolean;
title: string;
initialName?: string;
initialDescription?: string;
confirmLabel?: string;
onSubmit: (name: string, description: string) => void | Promise<void>;
onCancel: () => void;
}
let {
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());
}
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (pending || !name.trim()) return;
pending = true;
try {
await onSubmit(name.trim(), description);
} finally {
pending = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (!show) return;
if (e.key === 'Escape') onCancel();
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if show}
<div class="srd-backdrop" onclick={onCancel} role="presentation" tabindex="-1">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="srd-dialog"
role="dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="srd-title"
onclick={(e) => e.stopPropagation()}
>
<h3 id="srd-title" class="srd-title">{title}</h3>
<form onsubmit={handleSubmit}>
<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>
<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"
class="srd-btn srd-btn-cancel"
onclick={onCancel}
disabled={pending}
>
Abbrechen
</button>
<button type="submit" class="srd-btn srd-btn-primary" disabled={pending || !name.trim()}>
{pending ? '…' : confirmLabel}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.srd-backdrop {
position: fixed;
inset: 0;
background: hsl(0 0% 0% / 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.srd-dialog {
background: hsl(var(--color-card));
border-radius: 0.75rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
max-width: 420px;
width: 100%;
padding: 1.5rem;
animation: srd-pop 0.18s ease-out;
}
@keyframes srd-pop {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.srd-title {
font-size: 1.0625rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 1rem;
}
.srd-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1.25rem;
}
.srd-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.srd-input {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: white;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
outline: none;
transition: border-color 0.15s;
}
.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;
gap: 0.5rem;
}
.srd-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.srd-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.srd-btn-cancel {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.srd-btn-cancel:hover:not(:disabled) {
background: hsl(var(--color-surface-hover));
}
.srd-btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.srd-btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
</style>

View file

@ -2,7 +2,6 @@
import AppPage from '$lib/components/workbench/AppPage.svelte';
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';
@ -120,6 +119,32 @@
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
function scrollCarouselToStart() {
// Reset the horizontal scroll position so the SceneHeader (first
// item in the track) is visible — used after switching scenes so
// the user lands on the new scene's intro rather than wherever
// the previous scene was scrolled to.
const track = document.querySelector<HTMLElement>('.fokus-track');
track?.scrollTo({ left: 0, behavior: 'smooth' });
}
// ── Reset scroll on scene switch ────────────────────────
// Watches activeSceneId. On the first tick (initial mount) we skip
// the scroll because that's just the hydration pass — otherwise we
// would fight the carousel's own centre-the-first-card layout.
let lastSceneId: string | null = null;
$effect(() => {
const id = activeSceneId;
if (lastSceneId === null) {
lastSceneId = id;
return;
}
if (id !== lastSceneId) {
lastSceneId = id;
scrollCarouselToStart();
}
});
// ── Keyboard shortcuts 1-9 / 0 ─────────────────────────
// 1-9 scroll to the Nth open app in the active scene.
// 0 opens the new-app picker (which scrolls itself into view).
@ -269,30 +294,25 @@
}
// ── Scene CRUD dialogs ──────────────────────────────────
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) {
// Unified rename path: scroll the carousel to the scene header
// and focus its contenteditable <h1>. Previously opened a modal
// dialog that read from the store while the live header might
// already hold unsaved typing — the two paths could overwrite
// each other. Inline is the single source of truth now.
const scene = scenes.find((s) => s.id === id);
if (!scene) return;
sceneDialog = {
kind: 'rename',
id,
name: scene.name,
description: scene.description ?? '',
};
}
async function handleSubmitSceneDialog(name: string, description: string) {
const mode = sceneDialog;
if (!mode) return;
await workbenchScenesStore.renameScene(mode.id, name, description.trim() || null);
sceneDialog = null;
if (id !== activeSceneId) workbenchScenesStore.setActiveScene(id);
scrollCarouselToStart();
// Next tick: the header is mounted + the scroll has started;
// querying now lands on the active scene's h1 which
// contenteditable plaintext-only focuses cleanly.
setTimeout(() => {
const h1 = document.querySelector<HTMLElement>('.scene-header .scene-name[contenteditable]');
h1?.focus();
}, 120);
}
function handleDuplicateScene(id: string) {
workbenchScenesStore.duplicateScene(id);
@ -358,16 +378,6 @@
onClose={() => ctxMenu.close()}
/>
<SceneRenameDialog
show={sceneDialog !== null}
title="Szene bearbeiten"
initialName={sceneDialog?.name ?? ''}
initialDescription={sceneDialog?.description ?? ''}
confirmLabel="Speichern"
onSubmit={handleSubmitSceneDialog}
onCancel={() => (sceneDialog = null)}
/>
<ConfirmDialog
show={sceneToDelete !== null}
title="Szene löschen"