diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json
index 5036702ce..60e97a641 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json
@@ -34,5 +34,47 @@
"who": "Who",
"events": "Events",
"automations": "Automationen",
- "playground": "Playground"
+ "playground": "Playground",
+ "kontext": "Web-Kontext",
+ "news": "News",
+ "news-research": "News-Recherche",
+ "articles": "Artikel",
+ "research-lab": "Recherche-Labor",
+ "drink": "Trinken",
+ "recipes": "Rezepte",
+ "stretch": "Dehnen",
+ "mail": "E-Mail",
+ "meditate": "Meditation",
+ "mood": "Stimmung",
+ "sleep": "Schlaf",
+ "myday": "Mein Tag",
+ "activity": "Aktivität",
+ "companion": "Begleiter",
+ "ai-missions": "KI-Missionen",
+ "ai-agents": "KI-Agenten",
+ "ai-workbench": "KI-Werkbank",
+ "rituals": "Rituale",
+ "ai-policy": "KI-Richtlinien",
+ "ai-insights": "KI-Einblicke",
+ "ai-health": "KI-Gesundheit",
+ "goals": "Ziele",
+ "credits": "Credits & Abo",
+ "spiral": "Mana Spirale",
+ "settings": "Einstellungen",
+ "themes": "Designs",
+ "profile": "Profil",
+ "admin": "Admin",
+ "complexity": "Komplexität",
+ "api-keys": "API-Schlüssel",
+ "wishes": "Wünsche",
+ "help": "Hilfe",
+ "wetter": "Wetter",
+ "feedback": "Feedback",
+ "wardrobe": "Kleiderschrank",
+ "library": "Bibliothek",
+ "spaces": "Bereiche",
+ "website": "Website",
+ "quiz": "Quiz",
+ "guides": "Anleitungen",
+ "comic": "Comic"
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json
index 10c8d9868..838b0d1eb 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json
@@ -34,5 +34,47 @@
"who": "Who",
"events": "Events",
"automations": "Automations",
- "playground": "Playground"
+ "playground": "Playground",
+ "kontext": "Web Context",
+ "news": "News",
+ "news-research": "News Research",
+ "articles": "Articles",
+ "research-lab": "Research Lab",
+ "drink": "Drinks",
+ "recipes": "Recipes",
+ "stretch": "Stretch",
+ "mail": "Mail",
+ "meditate": "Meditate",
+ "mood": "Mood",
+ "sleep": "Sleep",
+ "myday": "My Day",
+ "activity": "Activity",
+ "companion": "Companion",
+ "ai-missions": "AI Missions",
+ "ai-agents": "AI Agents",
+ "ai-workbench": "AI Workbench",
+ "rituals": "Rituals",
+ "ai-policy": "AI Policy",
+ "ai-insights": "AI Insights",
+ "ai-health": "AI Health",
+ "goals": "Goals",
+ "credits": "Credits & Subscription",
+ "spiral": "Mana Spiral",
+ "settings": "Settings",
+ "themes": "Themes",
+ "profile": "Profile",
+ "admin": "Admin",
+ "complexity": "Complexity",
+ "api-keys": "API Keys",
+ "wishes": "Wishes",
+ "help": "Help",
+ "wetter": "Weather",
+ "feedback": "Feedback",
+ "wardrobe": "Wardrobe",
+ "library": "Library",
+ "spaces": "Spaces",
+ "website": "Website",
+ "quiz": "Quiz",
+ "guides": "Guides",
+ "comic": "Comic"
}
diff --git a/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte b/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte
new file mode 100644
index 000000000..dacc6112b
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts b/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts
new file mode 100644
index 000000000..c44a3e03d
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts
@@ -0,0 +1,210 @@
+/**
+ * Panel generation client. Composes a reference-based image-edit call
+ * against `/api/v1/picture/generate-with-reference` using the story's
+ * fixed `characterMediaIds` plus the story-wide style-prefix, then
+ * persists the result into `picture.images` with `comicStoryId` +
+ * `comicPanelIndex` back-refs and appends the panel to the story via
+ * `comicStoriesStore.appendPanel`.
+ *
+ * Same HTTP shape as `wardrobe/api/try-on.ts` — Comics reuse the
+ * endpoint verbatim. Only difference: character refs come from the
+ * story row (not reactively from useImageByPrimary), and the result
+ * goes through appendPanel into the story's ordered panel list.
+ *
+ * Plan: docs/plans/comic-module.md M2.
+ */
+
+import { getManaApiUrl } from '$lib/api/config';
+import { authStore } from '$lib/stores/auth.svelte';
+import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
+import { comicStoriesStore } from '../stores/stories.svelte';
+import { composePanelPrompt } from '../styles';
+import type { ComicPanelMeta, ComicStory } from '../types';
+
+/**
+ * Panel size. 1024×1024 is the comic-default — square panels compose
+ * into a strip or grid cleanly. 1024×1536 is available for verticaly-
+ * oriented "Webtoon"-style long shots. The backend supports more but
+ * M2 keeps the picker small.
+ */
+export type PanelSize = '1024x1024' | '1024x1536';
+
+export interface RunPanelGenerateParams {
+ story: ComicStory;
+ panelPrompt: string;
+ caption?: string;
+ dialogue?: string;
+ /** Tags the panel with the module-entry it was seeded from (M4 AI-
+ * Storyboard). Ignored in M2 single-panel flow. */
+ sourceInput?: ComicPanelMeta['sourceInput'];
+ quality?: 'low' | 'medium' | 'high';
+ size?: PanelSize;
+}
+
+export interface RunPanelGenerateResult {
+ imageId: string;
+ imageUrl: string;
+ prompt: string;
+ model: string;
+ panelIndex: number;
+}
+
+function dimsForSize(size: PanelSize): { width: number; height: number } {
+ if (size === '1024x1536') return { width: 1024, height: 1536 };
+ return { width: 1024, height: 1024 };
+}
+
+/**
+ * Shared low-level POST. Mirrors wardrobe's callGenerateWithReference
+ * so the error matrix stays identical across the two consumers of
+ * this endpoint.
+ */
+async function callGenerateWithReference(opts: {
+ prompt: string;
+ referenceMediaIds: string[];
+ quality: 'low' | 'medium' | 'high';
+ size: PanelSize;
+}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> {
+ const token = await authStore.getValidToken();
+ const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ prompt: opts.prompt,
+ referenceMediaIds: opts.referenceMediaIds,
+ model: 'openai/gpt-image-2',
+ quality: opts.quality,
+ size: opts.size,
+ n: 1,
+ }),
+ });
+
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ detail?: string;
+ required?: number;
+ missing?: string[];
+ };
+ if (res.status === 402) {
+ throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
+ }
+ if (res.status === 404) {
+ throw new Error(
+ 'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — prüfe, ob Face/Body in diesem Space existieren.'
+ );
+ }
+ const label = body.error ?? `Panel-Generierung fehlgeschlagen (${res.status})`;
+ throw new Error(body.detail ? `${label}: ${body.detail}` : label);
+ }
+
+ const data = (await res.json()) as {
+ images?: Array<{ imageUrl: string; mediaId?: string }>;
+ imageUrl?: string;
+ mediaId?: string;
+ prompt: string;
+ model: string;
+ };
+ const first =
+ (data.images && data.images[0]) ??
+ (data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null);
+ if (!first?.imageUrl || !first.mediaId) {
+ throw new Error('Keine Bilder zurückgegeben');
+ }
+ return {
+ imageUrl: first.imageUrl,
+ mediaId: first.mediaId,
+ prompt: data.prompt,
+ model: data.model,
+ };
+}
+
+/**
+ * Generate one panel for a story. The story provides the fixed
+ * reference-image list (face + optional body + optional garments —
+ * chosen once at story-create time); this call only adds the panel
+ * prompt + caption + dialogue on top of the story's style prefix.
+ */
+export async function runPanelGenerate(
+ params: RunPanelGenerateParams
+): Promise {
+ const { story, panelPrompt, caption, dialogue, sourceInput } = params;
+
+ if (story.characterMediaIds.length === 0) {
+ throw new Error('Story hat keine Character-Referenz — bitte Face-Ref hinterlegen.');
+ }
+ if (!panelPrompt.trim()) {
+ throw new Error('Panel-Prompt ist leer.');
+ }
+
+ // Style-prefix + panelPrompt + caption/dialog hints, composed in
+ // styles.ts. The backend never sees the style enum — it only sees
+ // the final prompt string.
+ const composedPrompt = composePanelPrompt({
+ style: story.style,
+ panelPrompt,
+ caption,
+ dialogue,
+ });
+
+ const effectiveSize: PanelSize =
+ params.size ?? (story.style === 'webtoon' ? '1024x1536' : '1024x1024');
+ const effectiveQuality = params.quality ?? 'medium';
+
+ // Cap at 8 references (server limit). If the story somehow has more
+ // in its characterMediaIds (shouldn't — UI caps at ~5), truncate and
+ // warn. Face-ref is [0] by convention.
+ const referenceMediaIds = story.characterMediaIds.slice(0, 8);
+
+ const result = await callGenerateWithReference({
+ prompt: composedPrompt,
+ referenceMediaIds,
+ quality: effectiveQuality,
+ size: effectiveSize,
+ });
+
+ const now = new Date().toISOString();
+ const localImageId = crypto.randomUUID();
+ const dims = dimsForSize(effectiveSize);
+ const panelIndex = story.panelImageIds.length; // zero-based
+
+ await imagesStore.insert({
+ id: localImageId,
+ prompt: result.prompt,
+ negativePrompt: null,
+ model: result.model,
+ publicUrl: result.imageUrl,
+ storagePath: result.mediaId,
+ filename: `comic-panel-${story.id}-${panelIndex + 1}.png`,
+ format: 'png',
+ width: dims.width,
+ height: dims.height,
+ visibility: 'private',
+ isFavorite: false,
+ downloadCount: 0,
+ generationMode: 'reference',
+ referenceImageIds: referenceMediaIds,
+ comicStoryId: story.id,
+ comicPanelIndex: panelIndex,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await comicStoriesStore.appendPanel(story.id, localImageId, {
+ caption: caption?.trim() || undefined,
+ dialogue: dialogue?.trim() || undefined,
+ promptUsed: composedPrompt,
+ sourceInput,
+ });
+
+ return {
+ imageId: localImageId,
+ imageUrl: result.imageUrl,
+ prompt: result.prompt,
+ model: result.model,
+ panelIndex,
+ };
+}
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte
new file mode 100644
index 000000000..8277104b4
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+ Protagonist
+
+
+ Dein Gesicht ist Pflicht. Body-Ref und bis zu {MAX_GARMENTS} Kostüm-Fotos sind optional.
+
+
+
+
+
+
+ {#if face?.publicUrl}
+

+ {:else}
+
+
+ Face fehlt
+
+ {/if}
+
Face
+
+
+
+
+ {#if body?.publicUrl}
+
+ {:else}
+
+
+ Body fehlt
+
+ {/if}
+
Body
+
+
+
+ {#each garmentPicks as g (g.id)}
+ {@const mediaId = g.mediaIds[0]}
+
+
+ {#if mediaId}
+

+ {/if}
+
+
+
+ {g.name}
+
+
+ {/each}
+
+
+ {#if canAddGarment}
+
+
+
+ {garmentIdsInValue.length}/{MAX_GARMENTS}
+
+
+ {/if}
+
+
+
+ {#if showGarmentPicker}
+
+
+
Kostüm aus dem Schrank wählen
+
+
+ {#if availableGarments.length === 0}
+
+ Keine weiteren Kleidungsstücke verfügbar — lade welche in /wardrobe hoch.
+
+ {:else}
+
+ {#each availableGarments as g (g.id)}
+ {@const mediaId = g.mediaIds[0]}
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+ {#if !hasFace}
+
+ Kein Gesichtsbild in diesem Space. Lade eins in
+
Profil → Bilder
+ hoch — ohne Face-Ref kein Comic.
+
+ {:else if !hasBody}
+
+ Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen
+ soll.
+
+ {/if}
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte
new file mode 100644
index 000000000..2457ade9d
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+ {#if image?.publicUrl}
+

+ {:else if image$.loading}
+
+ Lädt…
+
+ {:else}
+
+ Panel nicht gefunden
+
+ {/if}
+
+
+ #{panelIndex + 1}
+
+
+ {#if onRemove}
+
+ {/if}
+
+
+ {#if meta?.caption || meta?.dialogue}
+
+ {#if meta.caption}
+
{meta.caption}
+ {/if}
+ {#if meta.dialogue}
+
„{meta.dialogue}"
+ {/if}
+
+ {/if}
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte
new file mode 100644
index 000000000..8ff80002a
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+ {#if atCap}
+
+ Hart-Limit von {MAX_PANELS_PER_STORY} Panels erreicht. Ältere Panels entfernen oder neue Story anlegen.
+
+ {:else if warn}
+
+ Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz mit gpt-image-2 spürbar
+ schwerer.
+
+ {/if}
+
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte
new file mode 100644
index 000000000..668c70806
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte
@@ -0,0 +1,40 @@
+
+
+
+{#if panelImageIds.length === 0}
+
+
Noch keine Panels.
+
+ Klick unten auf + Panel, um die erste Szene zu
+ generieren.
+
+
+{:else}
+
+ {#each panelImageIds as panelId, index (panelId)}
+
onRemove(panelId) : undefined}
+ />
+ {/each}
+
+{/if}
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte
new file mode 100644
index 000000000..eef824a89
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte
@@ -0,0 +1,68 @@
+
+
+
+
+
+ {#if cover?.publicUrl}
+

+ {:else}
+
+
+ Noch kein Panel
+
+ {/if}
+
+
+
+ {STYLE_LABELS[story.style].de}
+
+
+ {#if story.isFavorite}
+
+
+
+ {/if}
+
+
+
+
{story.title}
+
+ {panelCount}
+ {panelCount === 1 ? 'Panel' : 'Panels'}
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte
new file mode 100644
index 000000000..d7b80cbff
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte
@@ -0,0 +1,142 @@
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte
new file mode 100644
index 000000000..5db300a9b
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte
@@ -0,0 +1,46 @@
+
+
+
+
+ {#each STYLE_ORDER as style (style)}
+
+ {/each}
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/index.ts b/apps/mana/apps/web/src/lib/modules/comic/index.ts
index 3e10abb70..0f723ad01 100644
--- a/apps/mana/apps/web/src/lib/modules/comic/index.ts
+++ b/apps/mana/apps/web/src/lib/modules/comic/index.ts
@@ -15,6 +15,7 @@ export {
useStory,
useStoryPanels,
useStoriesByInput,
+ usePanelImage,
} from './queries';
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
export { STYLE_PREFIXES, composePanelPrompt } from './styles';
diff --git a/apps/mana/apps/web/src/lib/modules/comic/queries.ts b/apps/mana/apps/web/src/lib/modules/comic/queries.ts
index 50ae9caba..2b215f53b 100644
--- a/apps/mana/apps/web/src/lib/modules/comic/queries.ts
+++ b/apps/mana/apps/web/src/lib/modules/comic/queries.ts
@@ -44,6 +44,26 @@ export function useStoriesByStyle(style: ComicStyle) {
}, [] as ComicStory[]);
}
+/**
+ * Load a single picture.images row by id — used for panel rendering
+ * (cover on StoryCard, thumbnails on PanelStrip, full-size on
+ * PanelCard). Lives here (not in picture/queries) because it's
+ * comic-specific convenience; picture's own queries don't need a
+ * single-image hook today.
+ */
+export function usePanelImage(imageId: string | null) {
+ return useLiveQueryWithDefault(async () => {
+ if (!imageId) return null;
+ const locals = await scopedForModule('picture', 'images')
+ .and((row) => row.id === imageId)
+ .toArray();
+ const [local] = locals;
+ if (!local || local.deletedAt) return null;
+ const [decrypted] = await decryptRecords('images', [local]);
+ return toImage(decrypted);
+ }, null);
+}
+
/** A single story by id, live-updating. Null while loading / missing. */
export function useStory(id: string | null) {
return useLiveQueryWithDefault(async () => {
diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte
new file mode 100644
index 000000000..778eea7fd
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+ {#if !story}
+ {#if story$.loading}
+
Lädt…
+ {:else}
+
+
Story nicht gefunden.
+
Gelöscht oder in einem anderen Space.
+
+ {/if}
+ {:else}
+
+
+
+
+ {#if story.description}
+
{story.description}
+ {/if}
+
+ {#if story.storyContext}
+
+ Kontext:
+ {story.storyContext}
+
+ {/if}
+
+
+
+
+
+
Panels
+ {#if !showEditor && !story.isArchived}
+
+ {/if}
+
+
+
+
+ {#if showEditor && !story.isArchived}
+
(showEditor = false)}
+ onGenerated={() => {
+ // Keep the editor open for rapid iteration — the user
+ // usually wants to generate 3–5 panels in a row. Reset
+ // happens inside PanelEditor on success.
+ }}
+ />
+ {/if}
+
+
+
+
+
+
+
+
+ {#if story.isArchived}
+
+ Archivierte Story — keine Panel-Generierung möglich, bis
+ wieder aktiviert.
+
+ {/if}
+ {/if}
+
diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte
new file mode 100644
index 000000000..64f445e53
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+ {#if !hasFace && !face$.loading}
+
+
+
+
+
Lade erst dein Gesichtsbild hoch
+
+ Ohne Face-Ref im aktiven Space kann kein Comic-Panel generiert werden. Hochladen in
+ Profil → Bilder.
+
+
+
+
+ {/if}
+
+ {#if stories.length > 0}
+
+ {#each stories as story (story.id)}
+
+ {/each}
+
+ {:else if !stories$.loading}
+
+
Noch keine Comics.
+
+ Starte deine erste Geschichte — aus einem Gedanken, einem Tagebuch-Eintrag oder einfach
+ einer Idee.
+
+
+
+ Erste Story anlegen
+
+
+ {/if}
+
diff --git a/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte
new file mode 100644
index 000000000..7be93a212
--- /dev/null
+++ b/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte
@@ -0,0 +1,12 @@
+
+
+
+ Comic · Mana
+
+
+
+
+
diff --git a/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte
new file mode 100644
index 000000000..4937a9d72
--- /dev/null
+++ b/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte
@@ -0,0 +1,20 @@
+
+
+
+ Comic · Mana
+
+
+
+
+ {#key id}
+
+ {/key}
+
diff --git a/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte
new file mode 100644
index 000000000..27ea01c45
--- /dev/null
+++ b/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte
@@ -0,0 +1,20 @@
+
+
+
+ Neuer Comic · Mana
+
+
+
+
+