fix(comic): DataCloneError beim Anlegen + stärkere Hover-States

Zwei Bugs beim ersten Anlegen einer Comic-Story:

1. **DataCloneError "[object Array] could not be cloned"** beim
   `comicStoriesTable.add(newLocal)`. Ursache: StoryForm deklariert
   `characterMediaIds`/`tags` als `$state<string[]>([])` und reicht
   die Proxies direkt an comicStoriesStore.createStory() durch.
   Dexie/IndexedDB's structured-clone refuseiert Svelte-5-State-
   Proxies — `tags`+`panelMeta` werden vorher von encryptRecord zu
   Ciphertext-Strings gewandelt, aber `characterMediaIds` (und
   `panelImageIds`/`tags` falls plaintext) bleiben Arrays und die
   schmieren als Proxy in den IDB-Write rein.

   Fix: Arrays beim Store-Eintritt mit `[...arr]` snapshotten.
   Greift jetzt auch in updateStory() für die gleichen Felder
   (zukünftiges StoryForm-Edit-Mode wäre sonst denselben Bug
   wert). Wardrobe hat das Problem latent auch, fix dort folgt
   wenn jemand auf gleiche Stelle stößt — die Comics-Lösung
   isoliert.

2. **Kein Button-Feel auf der Create-Seite.** StylePicker und
   CharacterPicker hatten zu schwache Tailwind-Hover-Klassen
   (`hover:bg-muted` only) — User las die Tiles nicht klar als
   klickbar.

   StylePicker komplett auf scoped CSS umgezogen (Pattern wie
   PanelModelPicker / wardrobe TryOnModelPicker): hover ändert
   border-color (border-primary/50) UND background (primary/5)
   UND fügt einen schwachen Schatten hinzu, plus active:translate-y-px
   für Touch-Feedback. Active-Tile bekommt klaren primary-Border +
   primary/8-bg + Schatten. Focus-visible mit Outline für
   Keyboard-Nav. role="radiogroup" + aria-checked statt aria-pressed
   für korrekte Semantik.

   CharacterPicker: Body-Ref-Toggle bekommt jetzt hover:border-primary/50
   + hover:shadow-sm und beim aktiven State einen leichten primary-
   Schatten. Add-Garment-Button kriegt hover:border-primary/50 +
   hover:bg-primary/5 + hover:shadow-sm.

5 Encryption-Tests weiter grün. check für comic-Files clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 13:39:47 +02:00
parent 75c366bff4
commit 32147194fd
3 changed files with 93 additions and 14 deletions

View file

@ -142,8 +142,10 @@
type="button"
{disabled}
onclick={toggleBody}
class="relative h-20 w-20 overflow-hidden rounded-md border transition-colors
{bodyInValue ? 'border-primary/50' : 'border-border opacity-50 hover:opacity-100'}"
class="relative h-20 w-20 overflow-hidden rounded-md border transition-all active:translate-y-px
{bodyInValue
? 'border-primary shadow-sm shadow-primary/20'
: 'border-border opacity-60 hover:border-primary/50 hover:opacity-100 hover:shadow-sm'}"
aria-pressed={bodyInValue}
title={bodyInValue ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'}
>
@ -208,7 +210,10 @@
type="button"
{disabled}
onclick={() => (showGarmentPicker = !showGarmentPicker)}
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-background text-muted-foreground transition-all hover:border-primary/50 hover:bg-primary/5 hover:text-foreground hover:shadow-sm active:translate-y-px"
class:!border-primary={showGarmentPicker}
class:!bg-primary={showGarmentPicker}
class:bg-opacity-10={showGarmentPicker}
aria-expanded={showGarmentPicker}
>
<Plus size={16} />

View file

@ -3,6 +3,11 @@
story-create time, fixed afterward (restyling = new story). Each
tile carries a short "what this looks like" hint so the user can
pick without having to memorise the preset mapping in styles.ts.
Markup pattern matches `PanelModelPicker` / wardrobe's
`TryOnModelPicker` so the create-flow has a coherent "click these
cards" affordance — both border AND background shift on hover so
the tiles read clearly as buttons.
-->
<script lang="ts">
import { STYLE_ORDER, STYLE_LABELS } from '../constants';
@ -27,20 +32,76 @@
};
</script>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="picker" role="radiogroup" aria-label="Comic-Stil">
{#each STYLE_ORDER as style (style)}
<button
type="button"
class="option"
class:active={value === style}
role="radio"
aria-checked={value === style}
{disabled}
onclick={() => onChange(style)}
class="rounded-lg border px-3 py-2.5 text-left transition-colors
{value === style
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-foreground hover:bg-muted'}"
aria-pressed={value === style}
>
<div class="text-sm font-medium">{STYLE_LABELS[style].de}</div>
<div class="mt-0.5 text-xs text-muted-foreground">{HINTS[style]}</div>
<span class="label">{STYLE_LABELS[style].de}</span>
<span class="hint">{HINTS[style]}</span>
</button>
{/each}
</div>
<style>
.picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem;
}
.option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background) / 0.5);
border-radius: 0.5rem;
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color 0.15s,
background-color 0.15s,
box-shadow 0.15s,
transform 0.05s;
}
.option:hover:not([disabled]):not(.active) {
border-color: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-primary) / 0.05);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
}
.option:active:not([disabled]) {
transform: translateY(1px);
}
.option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
box-shadow: 0 1px 3px hsl(var(--color-primary) / 0.15);
}
.option:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.option:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.label {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.35;
}
</style>

View file

@ -35,16 +35,21 @@ export const comicStoriesStore = {
if (input.characterMediaIds.length === 0) {
throw new Error('Story needs at least one character reference image');
}
// Spread incoming arrays to break Svelte 5 $state proxies — the
// caller (StoryForm) declares `characterMediaIds`/`tags` as
// `$state<string[]>([])` and passes them directly. IndexedDB's
// structured-clone refuses to clone proxies, so without this
// `comicStoriesTable.add(...)` throws DataCloneError.
const newLocal: LocalComicStory = {
id: crypto.randomUUID(),
title: input.title,
description: input.description ?? null,
style: input.style,
characterMediaIds: input.characterMediaIds,
characterMediaIds: [...input.characterMediaIds],
storyContext: input.storyContext ?? null,
panelImageIds: [],
panelMeta: {},
tags: input.tags ?? [],
tags: input.tags ? [...input.tags] : [],
isFavorite: input.isFavorite ?? false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
@ -64,7 +69,15 @@ export const comicStoriesStore = {
Pick<LocalComicStory, 'title' | 'description' | 'storyContext' | 'tags' | 'characterMediaIds'>
>
): Promise<void> {
const wrapped = { ...patch } as Record<string, unknown>;
// Same proxy-breaking copy as createStory: any array on the patch
// might be a $state proxy if the caller is a Svelte 5 component.
const wrapped: Record<string, unknown> = { ...patch };
if (Array.isArray(wrapped.characterMediaIds)) {
wrapped.characterMediaIds = [...(wrapped.characterMediaIds as string[])];
}
if (Array.isArray(wrapped.tags)) {
wrapped.tags = [...(wrapped.tags as string[])];
}
await encryptRecord('comicStories', wrapped);
await comicStoriesTable.update(id, {
...wrapped,