From 27c1860f82eeb779ac0f76ab30560230ac611c9e Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 15:29:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(comic):=20M1=20=E2=80=94=20Datenschicht=20?= =?UTF-8?q?+=20Modul-Registrierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Comic-Modul: aus Text-Inputs (Journal / Notes / Writing / Library / Calendar) entsteht ein mehrseitiger Comic, generiert mit gpt-image-2 über die bestehende /picture/generate-with-reference-Route. Plan in docs/plans/comic-module.md (M1–M5 + optional M6–M8). M1 schafft die Datenschicht ohne UI: - Dexie v44 `comicStories` (space-scoped, Indices createdAt/style/ isFavorite/isArchived). Story hält `panelImageIds: string[]` und `panelMeta: Record` — Panels selbst sind picture.images-Rows mit comicStoryId + comicPanelIndex Back-Refs. - Fünf Stil-Presets (comic / manga / cartoon / graphic-novel / webtoon) mit Prompt-Prefix-Templates in styles.ts; composePanelPrompt webt Stil + Panel-Prompt + Caption + Dialog zusammen. Sprechblasen werden von gpt-image-2 direkt ins Bild gerendert — kein SVG-Overlay. - Encryption-Registry-Eintrag: title / description / storyContext / tags / panelMeta als JSON-Blob. Struktur (id, style, character- MediaIds, panelImageIds, Flags, visibility) bleibt plaintext. - Module-Registry registriert appId='comic', verifyMediaOwnership auf der /picture/generate-with-reference-Route akzeptiert jetzt ['me', 'wardrobe', 'comic'] — 'comic'-Slot ist reserviert für M6+ Anchor-/Backdrop-Uploads. - Space-Allowlist: comic in brand (Marken-Storys), club (Vereins- geschichte), family (Kinder-Abenteuer), team (Release-Comics), practice (Patienten-Aufklärung). Personal via '*'-Sentinel. - mana-apps.ts Eintrag mit comic-Icon (Sprechblase + Lightning-Bolt, f97316→dc2626 Gradient). Lokal tier='guest' mit LOCAL TIER PATCH- Comment wie Wardrobe, canonical ist 'beta'. Visibility-System von Anfang an adopted (setVisibility-Methode im Store, unlistedToken-Generierung inklusive). appendPanel() als Vorarbeit für M2 bereits da, ohne Aufrufer. 5 Encryption-Roundtrip-Tests grün (panelMeta nested JSON, leeres panelMeta, partielle panelMeta ohne sourceInput, null-description). pnpm run check + validate:all sauber (207 Dexie-Tabellen klassifiziert, comicStories unter den 106 encrypted). Kein UI, keine Panel-Generierung, keine MCP-Tools — alles M2/M3/M5. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/modules/picture/routes.ts | 15 +- .../apps/web/src/lib/data/crypto/registry.ts | 29 + apps/mana/apps/web/src/lib/data/database.ts | 20 + .../apps/web/src/lib/data/module-registry.ts | 2 + .../web/src/lib/modules/comic/collections.ts | 8 + .../modules/comic/comic-encryption.test.ts | 169 ++++++ .../web/src/lib/modules/comic/constants.ts | 38 ++ .../apps/web/src/lib/modules/comic/index.ts | 20 + .../src/lib/modules/comic/module.config.ts | 6 + .../apps/web/src/lib/modules/comic/queries.ts | 110 ++++ .../modules/comic/stores/stories.svelte.ts | 165 +++++ .../apps/web/src/lib/modules/comic/styles.ts | 53 ++ .../apps/web/src/lib/modules/comic/types.ts | 142 +++++ .../web/src/lib/modules/picture/queries.ts | 2 + .../apps/web/src/lib/modules/picture/types.ts | 18 + docs/plans/comic-module.md | 565 ++++++++++++++++++ packages/shared-branding/src/app-icons.ts | 6 + packages/shared-branding/src/mana-apps.ts | 17 + packages/shared-types/src/spaces.ts | 5 + 19 files changed, 1385 insertions(+), 5 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/comic/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/constants.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/styles.ts create mode 100644 apps/mana/apps/web/src/lib/modules/comic/types.ts create mode 100644 docs/plans/comic-module.md diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts index e1757ac82..8f9f42419 100644 --- a/apps/api/src/modules/picture/routes.ts +++ b/apps/api/src/modules/picture/routes.ts @@ -297,13 +297,18 @@ routes.post('/generate-with-reference', async (c) => { } // Ownership check before we spend credits or burn OpenAI quota. - // References span two upload tags: `me` for face/body portraits - // (profile module) and `wardrobe` for garment photos (wardrobe - // module, M4 try-on flow). Anything outside those two apps is - // treated as not-owned regardless of mana-media's own view. + // References span three upload tags today: + // - `me` — face/body portraits from the profile module + // - `wardrobe` — garment photos (M4 try-on flow) + // - `comic` — comic-specific anchor / backdrop uploads + // (slot reserved for M6+; no writer lands in + // this app today, M1 character refs come from + // me + wardrobe only). + // Anything outside these apps is treated as not-owned regardless of + // mana-media's own view. try { const { verifyMediaOwnership } = await import('../../lib/media'); - await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe']); + await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe', 'comic']); } catch (err) { const e = err as Error & { status?: number; missing?: string[] }; if (e.status === 404) { diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 9e651ab60..bf5e65d4a 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -97,6 +97,7 @@ import type { LocalGeneration, LocalWritingStyle, } from '../../modules/writing/types'; +import type { LocalComicStory } from '../../modules/comic/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -586,6 +587,34 @@ export const ENCRYPTION_REGISTRY: Record = { // it plaintext and revisit if prompts later carry personal data. wardrobeOutfits: entry(['name', 'description', 'tags']), + // ─── Comic (stories + inline panel metadata) ───────────── + // docs/plans/comic-module.md M1. Single space-scoped table. + // + // `title`, `description`, `storyContext`, `tags` are user-typed + // prose and get the same treatment as journal.title / notes.content. + // `panelMeta` is the per-panel sidecar (Record) — aes.ts JSON- + // stringifies the whole blob before wrap, same pattern as + // food.foods / recipes.ingredients / quiz.options. Caption + + // dialogue are prose fragments the user authored; promptUsed is + // the reproduce-key (would-be-convenient for regeneration but + // leaks story content if plaintext); sourceInput FKs are + // low-risk but ship inside the encrypted blob anyway because + // splitting the Record per-field would double the storage cost. + // + // Plaintext (intentional): id, style enum (drives listStories + // filter + per-style prompt-prefix lookup), characterMediaIds + // (FKs to meImages / wardrobeGarments), panelImageIds (ordered + // FKs to picture.images), isFavorite / isArchived / visibility + // fields — all needed by the index or query layer. + comicStories: entry([ + 'title', + 'description', + 'storyContext', + 'tags', + 'panelMeta', + ]), + // Per-agent kontext documents — same schema as kontextDoc but keyed // per agent. Content is free-form markdown. agentKontextDocs: { enabled: true, fields: ['content'] }, diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 4c6244f49..db551a2cf 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1032,6 +1032,26 @@ db.version(43).stores({ writingStyles: 'id, source, isSpaceDefault, isFavorite, updatedAt', }); +// v44 — Comic module (docs/plans/comic-module.md M1). +// Single space-scoped table: each row is a comic story holding an +// ordered `panelImageIds: string[]` pointing at picture.images rows +// generated via /picture/generate-with-reference. No separate panel +// table — the `picture.images` entry IS the panel, with `comicStoryId` +// + `comicPanelIndex` plaintext back-refs (added as type-level fields +// on LocalImage; no schema index needed because the story holds the +// canonical order and loads its panels by id-list, not by scan). +// +// Indices: +// - comicStories.createdAt for "newest first" grid ordering +// - comicStories.style for the style-filter query (M5 MCP listStories) +// - comicStories.isFavorite for the favorites filter +// - comicStories.isArchived for the archive-hide filter +// Gets standard spaceId/authorId/visibility stamping via the Dexie hook +// (NOT in USER_LEVEL_TABLES). +db.version(44).stores({ + comicStories: 'id, createdAt, style, isFavorite, isArchived', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 1d21c544b..cfb88aa02 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -106,6 +106,7 @@ import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { websiteModuleConfig } from '$lib/modules/website/module.config'; import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config'; import { writingModuleConfig } from '$lib/modules/writing/module.config'; +import { comicModuleConfig } from '$lib/modules/comic/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ @@ -168,6 +169,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ websiteModuleConfig, wardrobeModuleConfig, writingModuleConfig, + comicModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/modules/comic/collections.ts b/apps/mana/apps/web/src/lib/modules/comic/collections.ts new file mode 100644 index 000000000..fb7b506bf --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/collections.ts @@ -0,0 +1,8 @@ +/** + * Comic module — Dexie table accessor. + */ + +import { db } from '$lib/data/database'; +import type { LocalComicStory } from './types'; + +export const comicStoriesTable = db.table('comicStories'); diff --git a/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts b/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts new file mode 100644 index 000000000..1a4b8ec73 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts @@ -0,0 +1,169 @@ +/** + * Comic encryption roundtrip test. + * + * `comicStories` ships with `panelMeta: Record` as an encrypted JSON blob via the + * registry entry `entry(['title', 'description', + * 'storyContext', 'tags', 'panelMeta'])`. This test locks in the + * roundtrip contract: every encrypted field recovers its exact value + * after an encrypt→decrypt cycle, the structural fields (id, style, + * characterMediaIds, panelImageIds, booleans, timestamps) stay + * plaintext, and the nested panelMeta object (including its + * sourceInput.module enum and sourceInput.entryId FK) survives + * untouched. + * + * Modeled after notes-encryption.test.ts but uses encryptRecord / + * decryptRecord directly — no Dexie round-trip needed to prove the + * registry contract, and skipping fake-indexeddb keeps the test fast. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { + encryptRecord, + decryptRecord, + generateMasterKey, + MemoryKeyProvider, + setKeyProvider, + isEncrypted, +} from '$lib/data/crypto'; +import { setCurrentUserId } from '$lib/data/current-user'; +import type { ComicPanelMeta, LocalComicStory } from './types'; + +const TABLE = 'comicStories'; + +let provider: MemoryKeyProvider; + +beforeEach(async () => { + const key = await generateMasterKey(); + provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + setCurrentUserId('test-user'); +}); + +afterEach(() => { + provider.setKey(null); + setCurrentUserId(null); +}); + +function makeStory(overrides: Partial = {}): LocalComicStory { + return { + id: 'story-1', + title: 'Bug-Hunt-Frust', + description: 'Ein 4-Panel-Comic zum Sync-Bug vom Dienstag', + style: 'comic', + characterMediaIds: ['me-face-123', 'wardrobe-tee-456'], + storyContext: 'Ich ärgere mich über einen Off-by-one in der LWW-Logik.', + panelImageIds: ['img-a', 'img-b'], + panelMeta: { + 'img-a': { + caption: 'Montag, 9 Uhr.', + dialogue: 'Der Test ist grün.', + promptUsed: 'developer sitting at desk, confident expression', + sourceInput: { module: 'journal', entryId: 'journal-42' }, + }, + 'img-b': { + caption: 'Eine Stunde später...', + dialogue: 'Der Test ist rot. WARUM.', + promptUsed: 'same developer, panicked expression, dark lighting', + }, + }, + tags: ['frust', 'devlog', '2026'], + isFavorite: true, + isArchived: false, + visibility: 'private', + ...overrides, + }; +} + +describe('comicStories encryption registry', () => { + it('encrypts title, description, storyContext, tags, panelMeta; leaves structural fields plaintext', async () => { + const row = makeStory(); + await encryptRecord(TABLE, row as unknown as Record); + + // Encrypted fields are ciphertext + expect(isEncrypted(row.title)).toBe(true); + expect(isEncrypted(row.description)).toBe(true); + expect(isEncrypted(row.storyContext)).toBe(true); + // tags is a string[] — aes.ts JSON-stringifies before wrap, the + // resulting value is still detected as encrypted via isEncrypted. + expect(isEncrypted(row.tags)).toBe(true); + // panelMeta is a nested object — same array-path pattern. + expect(isEncrypted(row.panelMeta)).toBe(true); + + // Nothing user-typed slipped through + expect(String(row.title)).not.toContain('Bug-Hunt'); + expect(String(row.description)).not.toContain('4-Panel'); + expect(String(row.storyContext)).not.toContain('Off-by-one'); + expect(JSON.stringify(row.panelMeta)).not.toContain('grün'); + expect(JSON.stringify(row.panelMeta)).not.toContain('WARUM'); + expect(JSON.stringify(row.tags)).not.toContain('devlog'); + + // Structural fields untouched + expect(row.id).toBe('story-1'); + expect(row.style).toBe('comic'); + expect(row.characterMediaIds).toEqual(['me-face-123', 'wardrobe-tee-456']); + expect(row.panelImageIds).toEqual(['img-a', 'img-b']); + expect(row.isFavorite).toBe(true); + expect(row.isArchived).toBe(false); + expect(row.visibility).toBe('private'); + }); + + it('roundtrips the full panelMeta nested object', async () => { + const row = makeStory(); + const originalMeta: Record = JSON.parse(JSON.stringify(row.panelMeta)); + + await encryptRecord(TABLE, row as unknown as Record); + await decryptRecord(TABLE, row as unknown as Record); + + expect(row.title).toBe('Bug-Hunt-Frust'); + expect(row.description).toBe('Ein 4-Panel-Comic zum Sync-Bug vom Dienstag'); + expect(row.storyContext).toBe('Ich ärgere mich über einen Off-by-one in der LWW-Logik.'); + expect(row.tags).toEqual(['frust', 'devlog', '2026']); + // Nested shape survives intact — caption / dialogue / promptUsed / + // sourceInput (module + entryId) all present and equal. + expect(row.panelMeta).toEqual(originalMeta); + }); + + it('handles an empty panelMeta record (freshly created story with no panels yet)', async () => { + const row = makeStory({ + panelImageIds: [], + panelMeta: {}, + }); + await encryptRecord(TABLE, row as unknown as Record); + // Even the empty object ships encrypted — registry doesn't skip + // empty non-null values. + expect(isEncrypted(row.panelMeta)).toBe(true); + + await decryptRecord(TABLE, row as unknown as Record); + expect(row.panelMeta).toEqual({}); + expect(row.panelImageIds).toEqual([]); + }); + + it('handles a panelMeta entry without sourceInput (manual panel, not AI-Storyboard)', async () => { + const row = makeStory({ + panelMeta: { + 'img-a': { + caption: 'Manuell geschrieben', + promptUsed: 'character looking at sunset', + // no dialogue, no sourceInput + }, + }, + }); + await encryptRecord(TABLE, row as unknown as Record); + await decryptRecord(TABLE, row as unknown as Record); + expect(row.panelMeta['img-a']).toEqual({ + caption: 'Manuell geschrieben', + promptUsed: 'character looking at sunset', + }); + }); + + it('leaves null-valued description unchanged (no crash, no wrap)', async () => { + const row = makeStory({ description: null }); + await encryptRecord(TABLE, row as unknown as Record); + expect(row.description).toBe(null); + await decryptRecord(TABLE, row as unknown as Record); + expect(row.description).toBe(null); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/comic/constants.ts b/apps/mana/apps/web/src/lib/modules/comic/constants.ts new file mode 100644 index 000000000..a3bc13d20 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/constants.ts @@ -0,0 +1,38 @@ +/** + * Comic module — labels, caps, defaults. + */ + +import type { ComicStyle } from './types'; + +export const STYLE_LABELS: Record = { + comic: { de: 'US-Comic', en: 'US-Comic' }, + manga: { de: 'Manga', en: 'Manga' }, + cartoon: { de: 'Cartoon', en: 'Cartoon' }, + 'graphic-novel': { de: 'Graphic Novel', en: 'Graphic Novel' }, + webtoon: { de: 'Webtoon', en: 'Webtoon' }, +}; + +export const STYLE_ORDER: readonly ComicStyle[] = [ + 'comic', + 'manga', + 'cartoon', + 'graphic-novel', + 'webtoon', +] as const; + +/** + * Hard client-side cap on panels per story. gpt-image-2 consistency + * degrades beyond ~8–10 panels even with identical refs; 12 is the + * "long comic" ceiling before restyling. UI warns softly ≥ 8. Plan + * offene-frage #1. + */ +export const MAX_PANELS_PER_STORY = 12; +export const PANEL_COUNT_WARN_THRESHOLD = 8; + +/** + * Default panel count the AI-Storyboard flow (M4) asks Claude to + * generate when no explicit number is chosen. Slider range 2–8 in UI. + */ +export const DEFAULT_STORYBOARD_PANEL_COUNT = 4; +export const MIN_STORYBOARD_PANEL_COUNT = 2; +export const MAX_STORYBOARD_PANEL_COUNT = 8; diff --git a/apps/mana/apps/web/src/lib/modules/comic/index.ts b/apps/mana/apps/web/src/lib/modules/comic/index.ts new file mode 100644 index 000000000..3e10abb70 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/index.ts @@ -0,0 +1,20 @@ +/** + * Comic module — public surface. + * + * Plan: docs/plans/comic-module.md. M1 ships the datenschicht only + * (types, collections, queries, stores, module registration). UI + + * generate-flow follows in M2. + */ + +export * from './types'; +export { comicStoriesTable } from './collections'; +export { comicStoriesStore } from './stores/stories.svelte'; +export { + useAllStories, + useStoriesByStyle, + useStory, + useStoryPanels, + useStoriesByInput, +} 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/module.config.ts b/apps/mana/apps/web/src/lib/modules/comic/module.config.ts new file mode 100644 index 000000000..6fac82d77 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const comicModuleConfig: ModuleConfig = { + appId: 'comic', + tables: [{ name: 'comicStories' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/comic/queries.ts b/apps/mana/apps/web/src/lib/modules/comic/queries.ts new file mode 100644 index 000000000..50ae9caba --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/queries.ts @@ -0,0 +1,110 @@ +/** + * Comic module — read-side queries. + * + * Stories are space-scoped: switching the active space swaps the + * visible pool automatically via `scopedForModule`. Panel history + * lives in `picture.images` filtered by `comicStoryId` — kept on the + * picture side rather than here (decision #1 in the plan: one table + * in this module, panels are picture rows). + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import type { LocalImage, Image } from '$lib/modules/picture/types'; +import { toImage } from '$lib/modules/picture/queries'; +import { toStory, type ComicStory, type ComicStyle, type LocalComicStory } from './types'; + +/** All non-archived, non-deleted stories in the active space, newest first. */ +export function useAllStories() { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule( + 'comic', + 'comicStories' + ).toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('comicStories', visible); + return decrypted.map(toStory); + }, [] as ComicStory[]); +} + +/** Stories filtered by style — used by the style-tabs view in M5 list tool. */ +export function useStoriesByStyle(style: ComicStyle) { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule('comic', 'comicStories') + .and((row) => row.style === style) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('comicStories', visible); + return decrypted.map(toStory); + }, [] as ComicStory[]); +} + +/** A single story by id, live-updating. Null while loading / missing. */ +export function useStory(id: string | null) { + return useLiveQueryWithDefault(async () => { + if (!id) return null; + const locals = await scopedForModule('comic', 'comicStories') + .and((row) => row.id === id) + .toArray(); + const [local] = locals; + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('comicStories', [local]); + return toStory(decrypted); + }, null); +} + +/** + * Every panel rendered for a story, newest first. Pulls from + * `picture.images` filtered by `comicStoryId`. Typically the Detail- + * View uses `story.panelImageIds` directly for ordered rendering; this + * query is for gallery-style "all renders across regenerations" views + * where users want to see panels that were dropped from the story's + * ordered list but not deleted. + */ +export function useStoryPanels(storyId: string | null) { + return useLiveQueryWithDefault(async () => { + if (!storyId) return []; + const locals = await scopedForModule('picture', 'images') + .and((row) => row.comicStoryId === storyId) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('images', visible); + return decrypted.map(toImage); + }, [] as Image[]); +} + +/** + * Stories that were seeded by a given module entry (M4 AI-Storyboard + * back-reference). Matches when *any* panel in the story has a + * `panelMeta[id].sourceInput` pointing at the given {module, entryId}. + * Used for the "Comics zu diesem Journal-Eintrag" cross-reference + * widget that renders on module detail pages. + */ +export function useStoriesByInput( + module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar' | null, + entryId: string | null +) { + return useLiveQueryWithDefault(async () => { + if (!module || !entryId) return []; + const locals = await scopedForModule( + 'comic', + 'comicStories' + ).toArray(); + const visible = locals.filter((row) => !row.deletedAt && !row.isArchived); + const decrypted = await decryptRecords('comicStories', visible); + const stories = decrypted.map(toStory); + return stories.filter((s) => { + const metas = Object.values(s.panelMeta); + return metas.some( + (meta) => meta.sourceInput?.module === module && meta.sourceInput.entryId === entryId + ); + }); + }, [] as ComicStory[]); +} diff --git a/apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts b/apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts new file mode 100644 index 000000000..27b964901 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts @@ -0,0 +1,165 @@ +/** + * Comic stories store — mutation-only service. + * + * A story holds an ordered `panelImageIds: string[]` plus a + * `panelMeta` record keyed by panel id. Panel mutations (append, + * reorder, remove, updateMeta) are the M2+ shape; M1 covers the + * shell: create/update/archive/delete/setVisibility. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; +import { comicStoriesTable } from '../collections'; +import { toStory } from '../types'; +import type { ComicPanelMeta, ComicStory, ComicStyle, LocalComicStory } from '../types'; + +export interface CreateStoryInput { + title: string; + style: ComicStyle; + characterMediaIds: string[]; + description?: string | null; + storyContext?: string | null; + tags?: string[]; + isFavorite?: boolean; +} + +export const comicStoriesStore = { + async createStory(input: CreateStoryInput): Promise { + if (input.characterMediaIds.length === 0) { + throw new Error('Story needs at least one character reference image'); + } + const newLocal: LocalComicStory = { + id: crypto.randomUUID(), + title: input.title, + description: input.description ?? null, + style: input.style, + characterMediaIds: input.characterMediaIds, + storyContext: input.storyContext ?? null, + panelImageIds: [], + panelMeta: {}, + tags: input.tags ?? [], + isFavorite: input.isFavorite ?? false, + visibility: defaultVisibilityFor(getActiveSpace()?.type), + }; + const snapshot = toStory({ ...newLocal }); + await encryptRecord('comicStories', newLocal); + await comicStoriesTable.add(newLocal); + emitDomainEvent('ComicStoryCreated', 'comic', 'comicStories', newLocal.id, { + storyId: newLocal.id, + style: input.style, + }); + return snapshot; + }, + + async updateStory( + id: string, + patch: Partial< + Pick + > + ): Promise { + const wrapped = { ...patch } as Record; + await encryptRecord('comicStories', wrapped); + await comicStoriesTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string): Promise { + const existing = await comicStoriesTable.get(id); + if (!existing) return; + await comicStoriesTable.update(id, { + isFavorite: !existing.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async archiveStory(id: string, archived: boolean): Promise { + await comicStoriesTable.update(id, { + isArchived: archived, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteStory(id: string): Promise { + const nowIso = new Date().toISOString(); + await comicStoriesTable.update(id, { + deletedAt: nowIso, + updatedAt: nowIso, + }); + emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, { + storyId: id, + }); + }, + + /** + * Flip a story's visibility. Comics are a natural share-surface + * (4-panel jokes, work-anecdotes) — marking a story `public` makes + * it eligible for `/embed/comic/:id` in M5. + */ + async setVisibility(id: string, next: VisibilityLevel): Promise { + const existing = await comicStoriesTable.get(id); + if (!existing) throw new Error(`Comic story ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await comicStoriesTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, { + recordId: id, + collection: 'comicStories', + before, + after: next, + }); + }, + + /** + * Append a freshly generated panel to the end of the story. Called + * by `runPanelGenerate` (M2) right after `picture.images` lands the + * new row. `meta` carries the prompt used + optional caption / + * dialogue / sourceInput. + * + * Re-encrypts the whole panelMeta Record because it's one JSON + * blob in the registry — we can't partially update individual keys + * without decrypting first. + */ + async appendPanel(storyId: string, panelImageId: string, meta: ComicPanelMeta): Promise { + const existing = await comicStoriesTable.get(storyId); + if (!existing) throw new Error(`Comic story ${storyId} not found`); + const nextIds = [...(existing.panelImageIds ?? []), panelImageId]; + const nextMeta = { ...(existing.panelMeta ?? {}), [panelImageId]: meta }; + const patch = { + panelImageIds: nextIds, + panelMeta: nextMeta, + } as Record; + await encryptRecord('comicStories', patch); + await comicStoriesTable.update(storyId, { + ...patch, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, { + storyId, + panelImageId, + panelIndex: nextIds.length - 1, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/comic/styles.ts b/apps/mana/apps/web/src/lib/modules/comic/styles.ts new file mode 100644 index 000000000..a82df8601 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/styles.ts @@ -0,0 +1,53 @@ +/** + * Prompt-prefix templates per visual style. The prefix is prepended to + * every panel prompt in `runPanelGenerate` (M2); gpt-image-2 sees the + * composite (stylePrefix + panelPrompt + captionHint + dialogueHint), + * never the enum itself. Keep prefixes short and directive — they're + * spent on every call. + * + * Adding a style = extending `ComicStyle` in types.ts + `STYLE_LABELS` + * in constants.ts + a prefix here. The three stay in lockstep because + * Record forces exhaustive coverage. + */ + +import type { ComicStyle } from './types'; + +export const STYLE_PREFIXES: Record = { + comic: + 'US comic book illustration, bold clean linework, vivid cell-shaded coloring, dramatic lighting, high contrast, comic-panel framing', + manga: + 'Japanese manga illustration, black and white line art with screen tones, dynamic perspective, expressive character design, dramatic motion lines', + cartoon: + 'soft pastel cartoon illustration, rounded friendly shapes, warm saturated colors, Saturday-morning animation style, simple clean backgrounds', + 'graphic-novel': + 'graphic novel illustration, painterly watercolor style, muted atmospheric palette, cinematic composition, moody naturalistic lighting', + webtoon: + 'modern webtoon illustration, clean vertical-scroll framing, bright saturated colors, soft cel-shading, expressive character close-ups', +}; + +/** + * Compose the final gpt-image-2 prompt for a single panel. Caption and + * dialogue (both optional) are rendered directly into the image by + * gpt-image-2 — no SVG overlay. Decision #4 in docs/plans/comic-module.md. + * + * The text-rendering language is whatever the user typed (gpt-image-2 + * handles multiple languages, English is most stable but German works + * for short strings). UI surfaces an English-preferred hint. + */ +export function composePanelPrompt(input: { + style: ComicStyle; + panelPrompt: string; + caption?: string; + dialogue?: string; +}): string { + const parts: string[] = [STYLE_PREFIXES[input.style], input.panelPrompt.trim()]; + const caption = input.caption?.trim(); + const dialogue = input.dialogue?.trim(); + if (caption) { + parts.push(`narration caption at the top reading: "${caption}"`); + } + if (dialogue) { + parts.push(`character speaking in a speech bubble saying: "${dialogue}"`); + } + return parts.join('. '); +} diff --git a/apps/mana/apps/web/src/lib/modules/comic/types.ts b/apps/mana/apps/web/src/lib/modules/comic/types.ts new file mode 100644 index 000000000..c019a0481 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/types.ts @@ -0,0 +1,142 @@ +/** + * Comic module types — one table: + * + * - `comicStories`: a comic story with title, style, fixed character + * reference list, and an ordered `panelImageIds: string[]` pointing + * at `picture.images` rows generated via the reference-edit flow. + * + * Panels themselves live in `picture.images` with `comicStoryId` + + * `comicPanelIndex` plaintext back-refs — see apps/mana/apps/web/src/ + * lib/modules/picture/types.ts. No second table in this module. + * + * Plan: docs/plans/comic-module.md. + */ + +import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; + +// ─── Style ──────────────────────────────────────────────────────── + +/** + * Closed enum of five visual presets. Each preset is mapped to a + * prompt-prefix template in `styles.ts`; the backend never sees the + * enum, only the final composed prompt. Chosen at story-create time + * and fixed — restyling = new story (or regenerate panels one by one). + */ +export type ComicStyle = + | 'comic' // US-Comic: Linework + Cell-Shading, kräftige Farben + | 'manga' // Schwarz/weiß, Screen-Tones, dynamische Perspektiven + | 'cartoon' // Weich, pastellig, Saturday-Morning-Cartoon + | 'graphic-novel' // Realistischer, Aquarell/Painterly, stimmungsvoll + | 'webtoon'; // Vertikal-lesbar, moderne Farbpalette, Soft-Shading + +// ─── Panel-Meta ─────────────────────────────────────────────────── + +/** + * Per-panel sidecar data that sits on the story (keyed by the panel's + * `picture.images.id`). The image itself carries only the rendered + * pixels + structural fields; everything that describes *why* the + * panel exists — user caption, dialogue text, the exact prompt used, + * and optional Cross-Module source ref — lives here. + * + * Whole object is encrypted as one JSON blob via the encryption + * registry (same pattern as food.foods / recipes.ingredients). + */ +export interface ComicPanelMeta { + caption?: string; + dialogue?: string; + /** The final prompt passed to gpt-image-2, stored so a user can + * regenerate or tweak without retyping. */ + promptUsed?: string; + /** Which module-entry, if any, seeded this panel in the AI-Storyboard + * flow (M4). Lets `useStoriesByInput` answer "which comics did I + * make from this journal entry?". Plaintext FKs inside the + * encrypted blob. */ + sourceInput?: { + module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar'; + entryId: string; + }; +} + +// ─── Story ──────────────────────────────────────────────────────── + +export interface LocalComicStory extends BaseRecord { + id: string; + title: string; + description?: string | null; + style: ComicStyle; + /** + * Reference-image IDs passed unchanged to every panel-generate call. + * Minimum: the primary face-ref from meImages. Optional additions: + * body-ref + up to ~3 wardrobe-garment photos for a costume-setup. + * Capped at 8 by the backend (MAX_REFERENCE_IMAGES in the /picture/ + * generate-with-reference endpoint). + */ + characterMediaIds: string[]; + /** + * Free-text briefing the author writes once, surfaced in the + * AI-Storyboard flow (M4) as context Claude sees before suggesting + * panel descriptions. Typical: 1–3 sentences ("Ich ärgere mich über + * einen Bug in unserer Sync-Logik — mach daraus einen 4-Panel- + * Frust-Comic."). + */ + storyContext?: string | null; + /** + * Ordered list of `picture.images.id` — the reading order of the + * comic. Reorder = rewrite this array. Length implicitly bounded + * by `MAX_PANELS_PER_STORY` at the UI layer; the type doesn't + * enforce it. + */ + panelImageIds: string[]; + /** Keyed by panel image id. Encrypted as a whole JSON blob. */ + panelMeta: Record; + tags: string[]; + isFavorite?: boolean; + isArchived?: boolean; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; +} + +export interface ComicStory { + id: string; + title: string; + description?: string; + style: ComicStyle; + characterMediaIds: string[]; + storyContext?: string; + panelImageIds: string[]; + panelMeta: Record; + tags: string[]; + isFavorite?: boolean; + isArchived?: boolean; + visibility: VisibilityLevel; + createdAt: string; + updatedAt: string; +} + +export function toStory(local: LocalComicStory): ComicStory { + return { + id: local.id, + title: local.title, + description: local.description ?? undefined, + style: local.style, + characterMediaIds: local.characterMediaIds ?? [], + storyContext: local.storyContext ?? undefined, + panelImageIds: local.panelImageIds ?? [], + panelMeta: local.panelMeta ?? {}, + tags: local.tags ?? [], + isFavorite: local.isFavorite, + isArchived: local.isArchived, + visibility: local.visibility ?? 'space', + createdAt: local.createdAt ?? '', + updatedAt: local.updatedAt ?? '', + }; +} + +/** Thumbnail / cover panel for a story. `null` for stories without any + * generated panel yet (they render a placeholder in StoryCard). */ +export function storyCoverPanelId(story: Pick): string | null { + return story.panelImageIds[0] ?? null; +} diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index d83c383dd..6e748b786 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -51,6 +51,8 @@ export function toImage(local: LocalImage): Image { generationMode: local.generationMode ?? undefined, wardrobeOutfitId: local.wardrobeOutfitId ?? undefined, wardrobeGarmentId: local.wardrobeGarmentId ?? undefined, + comicStoryId: local.comicStoryId ?? undefined, + comicPanelIndex: local.comicPanelIndex ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/picture/types.ts b/apps/mana/apps/web/src/lib/modules/picture/types.ts index 1f484e3e6..0bb529608 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/types.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/types.ts @@ -60,6 +60,22 @@ export interface LocalImage extends BaseRecord { * `referenceImageIds` containment. */ wardrobeGarmentId?: string | null; + /** + * Back-reference to `comicStories.id` when this image was produced as + * a comic panel (docs/plans/comic-module.md). The canonical reading + * order lives on the story in `panelImageIds`; this field lets the + * Picture-gallery show a "Panel von Comic X" chip without having to + * load every story to check which one owns the image. Plaintext FK. + */ + comicStoryId?: string | null; + /** + * Zero-based reading position inside the owning story at write time. + * Denormalised copy of `panelImageIds.indexOf(imageId)` — used for + * the gallery's "Panel 3" label. Goes stale if the story is + * reordered (M3+); the Detail-View re-reads from `panelImageIds` so + * the canonical order is never wrong even if this drifts. + */ + comicPanelIndex?: number | null; } export interface LocalBoard extends BaseRecord { @@ -131,6 +147,8 @@ export interface Image { generationMode?: ImageGenerationMode; wardrobeOutfitId?: string; wardrobeGarmentId?: string; + comicStoryId?: string; + comicPanelIndex?: number; createdAt: string; updatedAt: string; } diff --git a/docs/plans/comic-module.md b/docs/plans/comic-module.md new file mode 100644 index 000000000..c5a22beed --- /dev/null +++ b/docs/plans/comic-module.md @@ -0,0 +1,565 @@ +# Comic — Module Plan + +## Status (2026-04-24, vor M1) + +**Geplant, noch nichts geshipped.** Dieses Dokument legt Datenmodell, UI und +KI-Integration fest; die Meilensteine M1–M5 bringen das Feature auf +Produktions-Qualität, M6+ sind Ausbau. + +## Ziel + +Ein Nutzer erzeugt aus sich selbst und beliebigen Text-Inputs (Tagebuch, +Notizen, Writing-Drafts, Library-Einträge, Kalender-Events) einen **Comic** +— eine geordnete Folge von Bild-Panels in konsistentem Stil. gpt-image-2 +rendert jedes Panel aus einer Referenz-Komposition (Face-Ref + optionale +Szene) und einem Panel-Prompt; Sprechblasen und Caption-Text werden +direkt ins Bild reinrendered, kein separater Overlay-Layer. + +Kernfragen, die dieser Plan beantwortet: + +1. Wie bilden wir eine Comic-Story im Datenmodell ab — als Liste + geordneter Panel-Referenzen oder als eigenständige Entität? +2. Wie fließt Input aus anderen Modulen (Journal-Eintrag, Notes, + Library-Review, Writing-Draft) in die Panel-Generierung ein? +3. Wie halten wir Character-Konsistenz über Panels hinweg, ohne ein + separates Character-Management-System zu bauen? +4. Wie integrieren wir gpt-image-2 mit den fünf unterschiedlichen + Comic-Stilen (comic/manga/cartoon/graphic-novel/webtoon), ohne pro + Stil einen eigenen Backend-Pfad zu bauen? + +## Abgrenzung + +- **Kein eigener Image-Editor**: Panels sind `picture.images`-Rows wie + alle anderen generierten Bilder. Wer Panel X nachbearbeiten will, + tut das im Picture-Modul (oder generiert neu). Comic verwaltet die + *Reihenfolge und den Story-Kontext*, nicht die einzelnen Pixel. +- **Kein Storyboard-Canvas in M1–M4**: Panels leben in einer geordneten + Liste mit optionaler Caption. Ein Comic-Strip-Canvas mit + Drag-und-Drop-Positionierung (wie Picture-Boards) ist M6+. +- **Keine SVG-Speech-Bubble-Overlays**: Sprechblasen/Captions werden + gpt-image-2 über den Prompt reingekippt, nicht nachträglich über SVG + aufs Bild gelegt. Weniger Kontrolle, einfacher Datenweg, ein + Asset-Export pro Panel. +- **Keine eigene Character-DB**: Character-Referenzen sind + `meImages`-Einträge (Face-Ref, Body-Ref, plus optionale + Costume-Referenzen aus `wardrobe`). Kein neues Konzept + "Comic-Character" als eigene Table. +- **Kein Multi-Character-Crew in M1–M5**: Ein Comic hat *einen* + Protagonisten (der Nutzer oder eine Kostüm-Variante von ihm). Crew + mit mehreren Gesichtern ist M6+ — braucht Konsistenz-Tricks, die + wir nicht auf den MVP-Weg packen wollen. +- **Cross-Link zu `picture`**: Panel-Ergebnisse landen in + `picture.images` wie jede andere Generierung. `LocalImage` bekommt + einen `comicStoryId`-Back-Ref + optional `comicPanelIndex`. +- **Cross-Link zu `me-images`**: Ohne `useImageByPrimary('face-ref')` + kein Comic — identisch zu Wardrobe's Try-On-Flow. + +## Entscheidungen + +### 1. Ein Modul, eine Tabelle + +Im Gegensatz zu Wardrobe (Garments + Outfits) reicht für Comic **eine** +Tabelle: + +- **`comicStories`** — eine Comic-Story mit Titel, Stil, Character-Refs, + Story-Kontext, Panel-Liste (als `panelImageIds: string[]` in Plaintext) + +Kein zweites Table `comicPanels`, weil ein Panel kein eigenständiges +Primitiv ist — es ist ein `picture.images`-Eintrag mit Back-Ref. Das +spart Sync-Volumen, vermeidet FK-Cleanup beim Löschen, und hält die +Panel-Reihenfolge an *einem* Ort (im Story-Record als ID-Array, statt +als `orderIndex`-Feld auf jedem Panel). + +Die zusätzlichen Panel-Metadaten (Caption-Text, Dialogue-Vorschläge vom +AI-Storyboard, Prompt-Varianten) wandern in einen nested-JSON-Feld auf +der Story: + +```typescript +panelMeta: Record +``` + +Das ist denormalisiert-aber-handhabbar: wer eine Story löscht, löscht +automatisch die Meta; wer ein Panel löscht, muss aus `panelImageIds` ++ `panelMeta` den Eintrag rausnehmen. Trivialer Store-Helper. + +### 2. Character-Konsistenz via fixe Referenz-Liste pro Story + +Jede Story speichert bei Erstellung einmal `characterMediaIds: string[]` +— Face-Ref + optional Body-Ref + optional Kostüm-Fotos aus Wardrobe. +Alle Panel-Generierungen übergeben diese Referenz-Liste unverändert an +`/api/v1/picture/generate-with-reference`. gpt-image-2 ist nicht +deterministisch, aber identische Refs + identischer Stil-Preset-Prefix +im Prompt ergeben über 4–8 Panels einen *erkennbaren* Character. + +Kein Feinschliff-Tuning in M1–M5. Wenn sich nach M3 zeigt, dass Panels +auseinanderdriften, adressieren wir das mit einer zusätzlichen +"Anchor-Panel"-Referenz (erstes erzeugtes Panel wird Referenz für alle +folgenden) — das ist M6+. + +### 3. Fünf Stil-Presets, Mapping im Client + +```typescript +export type ComicStyle = + | 'comic' // US-Comic, Linework + Cell-Shading, kräftige Farben + | 'manga' // S/W, Screen-Tones, dynamische Perspektiven + | 'cartoon' // weicher, pastellig, Saturday-Morning-Cartoon + | 'graphic-novel' // realistischer, Aquarell/Painterly, stimmungsvoll + | 'webtoon'; // vertikal-lesbar, moderne Farbpalette, Soft-Shading +``` + +Pro Stil ein Prompt-Prefix-Template im Client (`lib/modules/comic/styles.ts`), +das in jede Panel-Generierung eingewoben wird. Das Backend kennt die +Stile *nicht* — es sieht nur den finalen Prompt. Gleicher Ansatz wie +Wardrobe's `accessoryOnly`-Prompt-Detection. + +Stil wird bei Story-Erstellung gewählt und ist danach fix. Stil-Wechsel += neue Story (oder Panels einzeln neu generieren). + +### 4. Sprechblasen & Captions direkt im Bild + +gpt-image-2 kann Text ins Bild rendern — nicht perfekt, aber für +Comic-Panels akzeptabel. Vorteil: ein einziger Asset-Export pro Panel, +kein zweiter Overlay-Layer, kein extra Canvas-Render-Schritt beim +Teilen/Drucken. Nachteil: Text-Korrekturen erfordern Neu-Generierung +des Panels (= neuer Credit-Call). + +Im Panel-Editor gibt's zwei Freitext-Felder neben dem Prompt: +**"Caption"** (Off-Voice-Erzähltext) und **"Dialog"** (Sprechblasen- +Inhalt). Beide werden in den Prompt eingewoben: `…, caption reading +"[caption]", character saying "[dialog]" in speech bubble, …`. +Deutsch-Text funktioniert; User-Erwartungshaltung aber auf +Englisch-Text einstellen (die Modelle sind auf Englisch stabiler) und +im UI-Hint vermerken. + +Der Nutzer kann Caption und Dialog leer lassen → stummes Panel. + +### 5. Panel-Generierung in drei Modi (evolvierend über M2–M4) + +- **M2 Single-Panel**: User klickt "+ Panel", schreibt Prompt + optional + Caption/Dialog, drückt "Generieren". Kosten: 1 gpt-image-2-Call + (Default `quality='medium'`, 10 Credits). +- **M3 Batch**: User schreibt 2–4 Panel-Prompts im Voraus, drückt + "Alle generieren". Backend bekommt `n=1` pro Panel, aber UI startet + die Calls parallel. Kosten: N × Credits. +- **M4 AI-Storyboard**: User wählt einen Input (Journal-Eintrag, + Notes, Writing-Draft, Library-Review, Calendar-Event), Claude liest + den Text und schlägt 4–6 Panel-Beschreibungen vor (Text-Only, + kein Bild). User bestätigt/editiert, dann läuft Batch-Gen. + Claude-Call läuft client-side über bestehende `@mana/shared-ai` + Helper (kein neuer Service-Pfad nötig). + +### 6. Cross-Modul-Input: lesend, nicht schreibend + +Das Comic-Modul *liest* aus den Stores anderer Module (`journal`, +`notes`, `library`, `writing`, `calendar`), schreibt aber niemals +dorthin zurück. Ein Journal-Eintrag bleibt im Journal, ein +Library-Review bleibt in der Library — Comic merkt sich nur per +`panelMeta[id].sourceInput` dass dieses Panel aus Input X entstanden +ist. Das erlaubt später "zeig mir alle Comics, die aus diesem +Journal-Eintrag entstanden sind" als einfache Query. + +Das Decrypt läuft client-side via `Store.getEntry(id)` → +`decryptRecords(…)` → übergeben an Claude. Keine Server-Side-Decrypts, +keine Key-Grants, kein Mission-Flow nötig — weil der Nutzer selbst +interaktiv am UI steht. + +### 7. Space-scoped Katalog, user-scoped Protagonist + +Wie bei Wardrobe: **`comicStories` sind space-scoped** (Brand kann +Comics über sein Produkt machen, Club über Vereinsgeschichte, Family +über Kinder-Abenteuer, Team über Bühnenproduktion, Practice als +Patienten-Aufklärungs-Comic). **Face-Refs bleiben user-global** aus +`meImages` — wer in einem Brand-Space einen Comic erstellt, ist selbst +der Protagonist. + +Family-Edge-Case: Kinder haben keinen eigenen Account, also auch keine +`meImages`. Wer eine Kinder-Geschichte als Comic machen will, nutzt +entweder ein eigenes Face-Ref ("Opa erzählt aus dem Krieg, gerendert +als Opa") oder das Comic-Modul zeigt den Family-Space-Hinweis (analog +zu Wardrobe): "Protagonist-Rendering nutzt deine eigenen +Referenzbilder." Kein Multi-Subject-Konzept in M1–M5. + +Alle sechs Space-Typen bekommen `comic` in die Allowlist. + +### 8. Visibility-System von Anfang an + +Comics sind ein Format das Nutzer möglicherweise teilen wollen +("mein 4-Panel-Comic zum gestrigen Bug-Report"). Wir adoptieren das +Visibility-System (`shared-privacy`) von M1 an — `visibility`, +`visibilityChangedAt/By`, `unlistedToken`, `` im +Detail-View. Comics mit `visibility='public'` können später via +`/embed/comic/:id` auf Webseiten eingebettet werden (Plan-Punkt von +`visibility-system.md` passt 1:1). + +## Architektur-Überblick + +``` +┌─ Client (SvelteKit) ────────────────────────────────────┐ +│ /comic │ +│ ListView: alle Stories (Cards mit erstem Panel) │ +│ /comic/[id] │ +│ Detail: Story-Meta + Panel-Strip (horizontal) │ +│ "+ Panel" CTA, pro Panel Caption/Dialog-Editor │ +│ /comic/new │ +│ CreateForm: Titel, Stil, Character-Picker, Kontext │ +│ Dexie: comicStories │ +└──────┬──────────────────────────────────────────────────┘ + │ mana-sync (encrypted title/description/panelMeta) + ▼ +┌─ Panel-Generierung (reuses M3 /picture endpoint) ───────┐ +│ POST /api/v1/picture/generate-with-reference │ +│ referenceMediaIds = story.characterMediaIds │ +│ prompt = stylePrefix + panelPrompt + captionHint │ +│ Result → picture.images row │ +│ Client writes: image.comicStoryId = story.id │ +│ image.comicPanelIndex = N │ +│ story.panelImageIds.push(imageId) │ +│ story.panelMeta[imageId] = {...} │ +└─────────────────────────────────────────────────────────┘ + +┌─ AI-Storyboard (M4, client-side Claude) ────────────────┐ +│ User selects input (journal entry / note / …) │ +│ decryptedText = moduleStore.getEntry(id).content │ +│ Claude.suggest({ style, text }) → Panel[] │ +│ User reviews/edits panels │ +│ Batch-Gen via /picture endpoint │ +└─────────────────────────────────────────────────────────┘ + +┌─ MCP / Agent tools ─────────────────────────────────────┐ +│ comic.listStories (read) │ +│ comic.createStory (write) │ +│ comic.generatePanel (write — consumes credits) │ +│ comic.reorderPanels (write) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Datenmodell + +### `LocalComicStory` + +```typescript +export type ComicStyle = + | 'comic' + | 'manga' + | 'cartoon' + | 'graphic-novel' + | 'webtoon'; + +export interface ComicPanelMeta { + caption?: string; // encrypted + dialogue?: string; // encrypted + promptUsed?: string; // encrypted + sourceInput?: { // plaintext refs + module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar'; + entryId: string; + }; +} + +export interface LocalComicStory extends BaseRecord { + id: string; + title: string; // encrypted + description?: string | null; // encrypted + style: ComicStyle; // plaintext enum + /** + * Referenz-Liste die für jedes Panel-Generate identisch übergeben wird. + * Mindestens der primary face-ref aus meImages; optional body-ref + + * bis zu 3 Wardrobe-Garment-Fotos für ein Kostüm-Setup. Cap 8 wie bei + * Wardrobe (MAX_REFERENCE_IMAGES im /generate-with-reference endpoint). + */ + characterMediaIds: string[]; // plaintext FKs + /** + * Kontext den Claude in M4 als Briefing für die Storyboard-Generierung + * sieht. Freitext, typisch 1–3 Sätze ("Ich ärgere mich über einen Bug + * in unserer Sync-Logik — mach daraus einen 4-Panel-Frust-Comic."). + */ + storyContext?: string | null; // encrypted + /** + * Geordnete Liste der Panel-picture.images-IDs. Reihenfolge = Lese- + * reihenfolge. Reorder = neu schreiben. + */ + panelImageIds: string[]; // plaintext FKs + panelMeta: Record; // keyed by panel image id + tags: string[]; // encrypted + isFavorite?: boolean; + isArchived?: boolean; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; +} +``` + +**Encryption-Registry-Eintrag:** `['title', 'description', 'storyContext', +'tags', 'panelMeta']` — `panelMeta` komplett encrypted (JSON-Blob, +der Freitext-Felder enthält). Style-Enum, IDs, Booleans, visibility +bleiben plaintext. + +### Erweiterung auf `picture.images` + +Zwei neue optionale Plaintext-Felder: + +```typescript +// apps/mana/apps/web/src/lib/modules/picture/types.ts +interface LocalImage { + // ... bestehend + wardrobeOutfitId?: string | null; + wardrobeGarmentId?: string | null; + comicStoryId?: string | null; // NEU + comicPanelIndex?: number | null; // NEU — 0-basiert, Lese-Position +} +``` + +Das `comicPanelIndex`-Feld ist redundant mit `story.panelImageIds`, aber +erlaubt der Picture-Galerie-Ansicht, direkt "Panel 3 von Story X" +anzuzeigen ohne die Story zu laden. Plaintext-Zahl, kein +Registry-Change. + +### `verifyMediaOwnership` erweitert + +`apps/api/src/modules/picture/routes.ts:299-318` — die erlaubten Apps +um `'comic'` erweitern, damit Wardrobe-Garments als Kostüm-Referenz in +Comic-Panel-Generierungen verwendet werden können: + +```typescript +verifyMediaOwnership(userId, refIds, ['me', 'wardrobe', 'comic']) +``` + +(`'comic'` für zukünftige comic-eigene Referenz-Uploads wie +Panel-Anker-Bilder in M6+; aktuell leer, aber der Slot ist reserviert.) + +## Modul-Struktur + +``` +apps/mana/apps/web/src/lib/modules/comic/ +├── types.ts # ComicStyle, LocalComicStory, ComicPanelMeta +├── collections.ts # comicStoriesTable +├── queries.ts # useAllStories, useStoryById, useStoriesByInput +├── module.config.ts # { appId: 'comic', tables: ['comicStories'] } +├── styles.ts # STYLE_PREFIXES: Record +├── stores/ +│ └── stories.svelte.ts # createStory, updateStory, appendPanel, +│ # reorderPanels, removePanel, updatePanelMeta, +│ # archive, delete +├── api/ +│ ├── generate-panel.ts # runPanelGenerate({story, prompt, caption, dialogue}) +│ │ # — wraps /picture/generate-with-reference +│ └── storyboard.ts # (M4) suggestPanels({style, sourceText, panelCount}) +│ # — client-side Claude-Call via @mana/shared-ai +├── components/ +│ ├── StoryCard.svelte # Grid tile (Cover = panelImageIds[0]) +│ ├── StoryForm.svelte # Create/edit Sheet (title, style, character, context) +│ ├── StylePicker.svelte # 5 Presets als radio-tiles +│ ├── CharacterPicker.svelte # meImages face-ref auto-select + optional garments +│ ├── PanelStrip.svelte # horizontal scroll, panel thumbnails +│ ├── PanelCard.svelte # einzelnes Panel mit Caption/Dialog-Anzeige +│ ├── PanelEditor.svelte # Prompt + Caption + Dialog + "Generieren"-Button +│ ├── StoryboardSuggester.svelte # (M4) Input-Picker + Claude-Suggestion-Liste +│ └── ReferenceInputPicker.svelte # (M4) wählt Journal/Notes/Library/Writing/Calendar +├── views/ +│ ├── ListView.svelte # Grid aller Stories +│ └── DetailView.svelte # Story-Meta + PanelStrip + "+ Panel" CTA +├── constants.ts # STYLE_LABELS, MAX_PANELS_PER_STORY (default 12) +└── index.ts +``` + +Route-Seiten: + +``` +apps/mana/apps/web/src/routes/(app)/comic/ +├── +page.svelte # → ListView +├── [id]/+page.svelte # → DetailView +└── new/+page.svelte # → StoryForm (create) +``` + +Kein Composer-Route wie bei Wardrobe — Comic-Erstellung ist kurz +(Titel + Stil + Character = 3 Felder), Panel-Editing läuft im +Detail-View als inline-Sheet. + +## Backend + +**Neuer App-Slot `'comic'`** für zukünftige Uploads (Panel-Anker, +Custom-Backgrounds in M6+). In M1 genügt die Registrierung des Slots +in `verifyMediaOwnership` + der App-Allowlist; eigener Upload-Endpoint +ist M1 nicht nötig, weil Panel-Bilder als `picture.images` über den +bestehenden Generate-Flow entstehen. + +**Keine eigene Generate-Route:** `runPanelGenerate()` ruft direkt +`/api/v1/picture/generate-with-reference`, analog zu Wardrobe. Nach +Erfolg schreibt der Client die `comicStoryId` + `comicPanelIndex`- +Back-Refs auf die `picture.images`-Row *und* appendet die imageId auf +`story.panelImageIds` + setzt `story.panelMeta[imageId]`. + +**Cap-Prüfung:** `MAX_REFERENCE_IMAGES=8` (bereits in Wardrobe M1 +gesetzt) deckt Comic ab — Face (1) + Body (1) + bis zu 3 Kostüm-Fotos += 5, mit Puffer für M6+ Anchor-Panel. + +**mana-apps.ts Eintrag:** `packages/shared-branding/src/mana-apps.ts` +bekommt einen neuen Eintrag: + +```typescript +{ + id: 'comic', + name: 'Comic', + description: 'Aus Text wird ein Comic', + icon: 'BookImage' /* oder similar */, + color: '#…' /* TBD, siehe design-ux.md für Palette */, + requiredTier: 'beta', + route: '/comic', +} +``` + +## MCP-Tools (`packages/mana-tool-registry/src/modules/comic.ts`) + +Vier Tools, Pattern 1:1 an `wardrobe.ts` angelehnt: + +- **`comic.listStories({style?, favoriteOnly?, limit?})`** — read, auto. + Pullt via mana-sync `app='comic'`, entschlüsselt `title`+`description`+ + `tags`+`panelMeta`. Filter client-side. +- **`comic.createStory({title, style, characterMediaIds, description?, storyContext?})`** — + write, propose. Validiert dass alle `characterMediaIds` dem User + gehören (`app='me'|'wardrobe'`). Schreibt via `pushInsert`. +- **`comic.generatePanel({storyId, panelPrompt, caption?, dialogue?, sourceInput?})`** — + write (kostet Credits), propose. Liest die Story, composed den finalen + Prompt (stylePrefix + panelPrompt + caption/dialog-Hints), ruft + `/picture/generate-with-reference`, appendet das Ergebnis auf + `panelImageIds` + `panelMeta`. +- **`comic.reorderPanels({storyId, panelImageIds})`** — write, propose. + Validiert Set-Equality (keine neuen/fehlenden IDs), schreibt die neue + Reihenfolge. + +`AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts` bekommt die +vier Tools, `comic` kommt in die `ModuleId`-Union. + +## Milestones + +- **M1 — Datenschicht & Modul-Registrierung** + - [ ] Dexie v43: `comicStories` mit Indices `[createdAt, style, isFavorite, isArchived]` (space-scoped, kein Compound-Index) + - [ ] `types.ts`: `ComicStyle`, `LocalComicStory`, `ComicPanelMeta`, `toStory`-Converter + - [ ] Encryption-Registry-Eintrag für `comicStories` (`title/description/storyContext/tags/panelMeta`) + - [ ] `collections.ts`, `queries.ts` (useAllStories, useStoryById) via `scopedForModule<>` + - [ ] `stores/stories.svelte.ts` mit createStory + archive + delete (Panel-Methoden kommen in M2) + - [ ] `module.config.ts` registriert `appId='comic'` + - [ ] `comic` in alle sechs Space-Typen der Allowlist (`packages/shared-types/src/spaces.ts`) + - [ ] `mana-apps.ts` Eintrag mit `requiredTier: 'beta'` + - [ ] `picture.images.comicStoryId` + `comicPanelIndex` Felder + `toImage`-Converter + - [ ] `verifyMediaOwnership` um `'comic'` erweitern + - [ ] Encryption-Roundtrip-Test für `panelMeta`-JSON (wie library M1 für kind-discriminator) + +- **M2 — Story-CRUD + Single-Panel-Generierung** + - [ ] Route `/comic` → `ListView`, Story-Grid mit `StoryCard` (Cover = `panelImageIds[0]` → mana-media URL, Fallback Placeholder für Stories ohne Panels) + - [ ] Route `/comic/new` → `StoryForm` (Title, `StylePicker` mit 5 Presets, `CharacterPicker` bindet an `useImageByPrimary('face-ref')` + optional body-ref-Add + Wardrobe-Garment-Picker für bis zu 3 Kostüme, optional `storyContext`-Textarea) + - [ ] Route `/comic/[id]` → `DetailView`: Meta-Card + `PanelStrip` (horizontal scroll) + "+ Panel" CTA + - [ ] `PanelEditor` inline-Sheet: Prompt-Textarea, Caption-Freitext, Dialog-Freitext, "Generieren"-Button + - [ ] `api/generate-panel.ts`: `runPanelGenerate({story, prompt, caption, dialogue})` composed den Prompt (`styles.ts` liefert stylePrefix) und ruft `/picture/generate-with-reference` + - [ ] Nach Erfolg: `picture.images.comicStoryId` + `comicPanelIndex` setzen + `story.panelImageIds.push()` + `panelMeta[imageId] = {…}` + - [ ] Panel-Lösch-Button (Dexie-Row der `picture.images` bleibt — nur aus `panelImageIds` und `panelMeta` entfernen; User kann im Picture-Modul final löschen) + - [ ] Non-personal-Space-Hinweis + Empty-State bei fehlenden meImages (Link zu `/profile/me-images`) + - [ ] Visibility-Felder setzbar via `` in DetailView + +- **M3 — Batch-Panel-Generierung** + - [ ] `PanelEditor` unterstützt Multi-Panel-Modus: 2–4 Prompts im Formular, "Alle generieren"-Button + - [ ] Client startet N parallele `/picture/generate-with-reference`-Calls, zeigt Progress-Bar pro Panel + - [ ] Credit-Hinweis zeigt Gesamtkosten vorher (`n × creditsForQuality(medium)`) + - [ ] Retry-UI falls 1 von N fehlschlägt (nur der fehlgeschlagene wird erneut generiert) + - [ ] `comic.generatePanel` MCP-Tool bekommt optional `count?: 1..4`-Parameter (default 1) + +- **M4 — AI-Storyboard aus Cross-Modul-Input** + - [ ] `ReferenceInputPicker`-Komponente: Modul-Tabs (Journal / Notes / Library / Writing / Calendar), pro Tab Live-Query der letzten N Einträge mit Suche + - [ ] Per ausgewähltem Entry: `Store.getEntry(id)` → decrypt content → in Storyboard-Flow reichen + - [ ] `api/storyboard.ts`: `suggestPanels({style, sourceText, panelCount=4})` ruft Claude (via `@mana/shared-ai`, client-side, genau wie AI-Workbench-Planer — kein neuer Service-Pfad), erwartet `Panel[]` als strukturierte Antwort `{prompt, caption, dialogue}` + - [ ] `StoryboardSuggester`-Komponente zeigt Claude-Vorschläge als editierbare Liste (Prompt + Caption + Dialog pro Panel), User kann editieren/löschen/Reihenfolge ändern + - [ ] "Alle generieren"-Button übergibt die bestätigte Panel-Liste an den M3-Batch-Pfad + - [ ] `panelMeta[imageId].sourceInput = {module, entryId}` beim Erzeugen gesetzt + - [ ] `useStoriesByInput({module, entryId})` Query für künftige Cross-Reference-UI ("Comics zu diesem Journal-Eintrag") + +- **M5 — MCP-Tools + Visibility-Polish** + - [ ] `packages/mana-tool-registry/src/modules/comic.ts` mit 4 Tools: listStories, createStory, generatePanel, reorderPanels + - [ ] `'comic'` in `ModuleId`-Union + - [ ] `registerComicTools()` in `registerAllModules()` + - [ ] `AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts` erweitert + - [ ] Propose-Policy für `createStory`/`generatePanel`/`reorderPanels`, auto-Policy für `listStories` + - [ ] `` voll integriert inkl. `unlistedToken`-Generierung, `canEmbedOnWebsite` check für public Comics + - [ ] Embed-Route `/embed/comic/[id]` (public + unlisted) mit Panel-Strip-Render (wie andere Visibility-adoptierte Module) + +- **M6 — Persona-Template "Comic-Autor"** (optional, ~0.5 Tag) + - [ ] Persona-Template: auto-Policy für `comic.listStories` + `journal.list*` + `notes.list*`, propose-Policy für `comic.createStory` + `comic.generatePanel` + - [ ] Seed-Prompt: "Du bist Comic-Autor. Wenn der User dir einen Moment, ein Erlebnis oder eine Idee gibt, schlag ihm einen kurzen Comic vor — Titel, Stil, 4 Panels mit Prompt + Caption + Dialog. Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt." + +- **M7 — Comic-Strip-Canvas** (optional, mehrere Tage) + - [ ] Picture-Boards-Pattern adaptieren für Comic: freie Panel-Positionierung, variable Panel-Größen, Gutter, Speech-Bubble-Overlay (dann doch SVG, opt-in pro Story) + - [ ] Export als einzelnes PNG/PDF-Asset (Panel-Strip → Canvas → Blob) + - [ ] Rechtfertigt sich nur, wenn Nutzer Feedback-Signal senden dass die lineare Liste nicht reicht + +- **M8 — Multi-Character-Crew** (optional, mehrere Tage) + - [ ] Story bekommt `characterCast: CharacterRef[]` statt flaches `characterMediaIds[]` + - [ ] Pro Panel kann der Autor einen oder mehrere Cast-Member auswählen; `referenceMediaIds` wird pro Panel zusammengesetzt + - [ ] Namens-Mapping (Cast-Member bekommt Namen → Dialog kann "Alice sagt:" taggen) + - [ ] Nur starten wenn Single-Character-Flow nach M5-Soak stabil + +## Verschlüsselung + +Alle user-typed Felder verschlüsselt (siehe Registry-Einträge oben). +`panelMeta` als ganzer JSON-Blob verschlüsselt (nicht per-Feld) — einfacher +Roundtrip, gleiche Semantik wie bei Library's kind-spezifischen +Metadaten. + +Bild-Blobs selbst bleiben in mana-media mit Owner-RLS, identisch zu +Picture/Wardrobe/Me-Images. Zero-Knowledge-Nutzer: MCP-Tools fallen +stumm aus (kein MK → `ctx.getMasterKey()` throwt), UI-Flow bleibt +funktional weil die Decrypts client-side passieren. + +## Cross-Modul-Impact + +| Modul | Impact | +|---|---| +| `picture` | Zwei neue optionale Felder auf `LocalImage`: `comicStoryId`, `comicPanelIndex`. Keine Registry-Änderung (beide plaintext). Galerie-View könnte optional ein "Teil von Comic X"-Chip zeigen (M5+ optional). | +| `me-images` | Nichts — Comic konsumiert nur `useImageByPrimary`. | +| `wardrobe` | Nichts — Comic liest Garments als referenzielle `mediaIds`, schreibt nicht zurück. | +| `journal`, `notes`, `library`, `writing`, `calendar` | Nichts — nur lesende Cross-Module-Reads über die Module-Stores. | +| `shared-branding` | Neuer App-Eintrag `comic` (Icon, Farbe, Tier=beta). | +| `shared-types/spaces.ts` | `comic` in alle sechs Space-Typen der Allowlist (`personal`, `brand`, `club`, `family`, `team`, `practice`). | +| `shared-ai/tools/schemas.ts` | 4 neue Einträge im `AI_TOOL_CATALOG`. | +| `mana-tool-registry` | Neues Modul `comic.ts` + `registerComicTools()`. | +| `apps/api/picture/routes.ts` | `verifyMediaOwnership` um `'comic'` erweitern. | + +## Offene Fragen (vor M1 klären) + +1. **Panel-Count-Limit pro Story**: 8? 12? 20? → Empfehlung: hartes + Client-Limit 12 in `constants.ts`, weicher Hinweis ab 8 ("lange Comics + sind mit gpt-image-2 schwer konsistent zu halten"). Erhöhen nach + M5-Soak möglich. +2. **Quality-Default für Panels**: `medium` (10 Credits)? → Ja, wie + Wardrobe. User kann pro Panel overriden (low/medium/high); Batch-Modus + nutzt eine Story-weite Default-Setting. +3. **Stil-Wechsel nachträglich**: erlaubt? → Nein, Stil ist fix nach + Story-Create. Wer wechseln will, dupliziert die Story (M6+ Feature) + oder erstellt neu. +4. **Dialog/Caption Sprache**: User-Sprache oder Englisch? → Default + User-Sprache (Deutsch in unserem primären Markt). UI-Hinweis dass + Englisch stabiler rendert. Kein Auto-Translate in M1–M5. +5. **AI-Storyboard-Panel-Count**: Claude schlägt 4–6 Panels vor, der + User kann mehr/weniger anfordern? → Default 4, Slider 2–8 im UI, Hard-Cap 8. +6. **Panel-Lösch-Semantik**: beim Entfernen aus `panelImageIds` auch die + `picture.images`-Row löschen? → Nein. Row bleibt, nur die + Story-Referenz geht weg. User kann das Panel in der Picture-Galerie + behalten oder dort final löschen. Symmetrisch zu Wardrobe (Try-On- + Bilder überleben eine Outfit-Löschung). + +## Verweise + +- Fundament Picture-Generate-Reference: `apps/api/src/modules/picture/routes.ts:250-430` +- Wardrobe als Modul-Blaupause: `docs/plans/wardrobe-module.md` +- Library als Single-Table-Modul mit Discriminator-Pattern: `docs/plans/library-module.md` +- Writing-Plan für Cross-Modul-Input-Pattern: `docs/plans/writing-module.md` +- Visibility-System: `docs/plans/visibility-system.md`, `packages/shared-privacy/` +- Spaces-Modul-Allowlist: `packages/shared-types/src/spaces.ts` +- Tool-Registry-Pattern: `packages/mana-tool-registry/src/modules/wardrobe.ts` +- Me-Images (Face/Body-Ref-Konzept): `docs/plans/me-images-and-reference-generation.md` diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index cea88f765..c280083ea 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -75,6 +75,11 @@ const calcSvg = ``; +// Comic icon — speech bubble with a lightning-bolt panel marker on +// orange→red gradient. Sits warm between Picture (green) and Wardrobe +// (rose) so the Mana launcher reads as a coherent creative family. +const comicSvg = ``; + // Wardrobe icon — T-shirt on hanger with rose-violet gradient. // Rose/violet to sit between Picture (green) and Calc (pink) without // clashing; the hanger loop sits on the shoulder line so the silhouette @@ -109,6 +114,7 @@ export const APP_ICONS = { mail: svgToDataUrl(mailSvg), inventory: svgToDataUrl(inventorySvg), wardrobe: svgToDataUrl(wardrobeSvg), + comic: svgToDataUrl(comicSvg), questions: svgToDataUrl(questionsSvg), context: svgToDataUrl(contextSvg), citycorners: svgToDataUrl(citycornersSvg), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index f582d2f19..2b4af301c 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -394,6 +394,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'beta', requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release }, + { + id: 'comic', + name: 'Comic', + description: { + de: 'Aus Text wird ein Comic', + en: 'Turn text into comics', + }, + longDescription: { + de: 'Erstelle mehrseitige Comics mit KI. Starte mit einem Tagebuch-Eintrag, einer Notiz oder einem Kalender-Event und generiere Panels in fünf Stilen — Comic, Manga, Cartoon, Graphic Novel oder Webtoon. Du selbst bist der Protagonist.', + en: 'Create multi-panel comics with AI. Start from a journal entry, note, or calendar event and generate panels in five styles — comic, manga, cartoon, graphic novel, or webtoon. You are the protagonist.', + }, + icon: APP_ICONS.comic, + color: '#f97316', + comingSoon: false, + status: 'beta', + requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release + }, { id: 'questions', name: 'Questions', diff --git a/packages/shared-types/src/spaces.ts b/packages/shared-types/src/spaces.ts index 7a6cb708a..c11e615c0 100644 --- a/packages/shared-types/src/spaces.ts +++ b/packages/shared-types/src/spaces.ts @@ -89,6 +89,7 @@ export const SPACE_MODULE_ALLOWLIST: Record