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" type="button"
{disabled} {disabled}
onclick={toggleBody} onclick={toggleBody}
class="relative h-20 w-20 overflow-hidden rounded-md border transition-colors class="relative h-20 w-20 overflow-hidden rounded-md border transition-all active:translate-y-px
{bodyInValue ? 'border-primary/50' : 'border-border opacity-50 hover:opacity-100'}" {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} aria-pressed={bodyInValue}
title={bodyInValue ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'} title={bodyInValue ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'}
> >
@ -208,7 +210,10 @@
type="button" type="button"
{disabled} {disabled}
onclick={() => (showGarmentPicker = !showGarmentPicker)} 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} aria-expanded={showGarmentPicker}
> >
<Plus size={16} /> <Plus size={16} />

View file

@ -3,6 +3,11 @@
story-create time, fixed afterward (restyling = new story). Each story-create time, fixed afterward (restyling = new story). Each
tile carries a short "what this looks like" hint so the user can tile carries a short "what this looks like" hint so the user can
pick without having to memorise the preset mapping in styles.ts. 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"> <script lang="ts">
import { STYLE_ORDER, STYLE_LABELS } from '../constants'; import { STYLE_ORDER, STYLE_LABELS } from '../constants';
@ -27,20 +32,76 @@
}; };
</script> </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)} {#each STYLE_ORDER as style (style)}
<button <button
type="button" type="button"
class="option"
class:active={value === style}
role="radio"
aria-checked={value === style}
{disabled} {disabled}
onclick={() => onChange(style)} 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> <span class="label">{STYLE_LABELS[style].de}</span>
<div class="mt-0.5 text-xs text-muted-foreground">{HINTS[style]}</div> <span class="hint">{HINTS[style]}</span>
</button> </button>
{/each} {/each}
</div> </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) { if (input.characterMediaIds.length === 0) {
throw new Error('Story needs at least one character reference image'); 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 = { const newLocal: LocalComicStory = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: input.title, title: input.title,
description: input.description ?? null, description: input.description ?? null,
style: input.style, style: input.style,
characterMediaIds: input.characterMediaIds, characterMediaIds: [...input.characterMediaIds],
storyContext: input.storyContext ?? null, storyContext: input.storyContext ?? null,
panelImageIds: [], panelImageIds: [],
panelMeta: {}, panelMeta: {},
tags: input.tags ?? [], tags: input.tags ? [...input.tags] : [],
isFavorite: input.isFavorite ?? false, isFavorite: input.isFavorite ?? false,
visibility: defaultVisibilityFor(getActiveSpace()?.type), visibility: defaultVisibilityFor(getActiveSpace()?.type),
}; };
@ -64,7 +69,15 @@ export const comicStoriesStore = {
Pick<LocalComicStory, 'title' | 'description' | 'storyContext' | 'tags' | 'characterMediaIds'> Pick<LocalComicStory, 'title' | 'description' | 'storyContext' | 'tags' | 'characterMediaIds'>
> >
): Promise<void> { ): 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 encryptRecord('comicStories', wrapped);
await comicStoriesTable.update(id, { await comicStoriesTable.update(id, {
...wrapped, ...wrapped,