feat(writing): M1+M2 — new Ghostwriter module with manual draft CRUD

M1 (skeleton):
- Module `writing` registered: 4 Dexie tables (writingDrafts,
  writingDraftVersions, writingGenerations, writingStyles) in v43,
  encrypted via typed registry entries, space-scoped via the Dexie hook.
- App entry in mana-apps.ts (sky-cyan #0ea5e9, LOCAL TIER PATCH guest),
  fountain-pen icon in app-icons.ts.
- Plan: docs/plans/writing-module.md — 12 milestones, Ghostwriter-first
  with Canvas deferred to M9, Picture-pattern analogue (Draft + Version
  + Generation), 9 preset styles, Space-Kontext-as-default.

M2 (manual CRUD):
- drafts store: createDraft (atomic draft + initial v1), updateBriefing,
  setStatus, toggleFavorite, deleteDraft (cascade soft-delete versions),
  updateVersionContent (live edit), createCheckpointVersion,
  restoreVersion (pointer flip, non-destructive), setVisibility.
- styles store: createStyle, updateStyle, upsertExtractedPrinciples,
  setSpaceDefault (exclusive flip), deleteStyle.
- queries: useAllDrafts, useDraft, useVersionsForDraft,
  useCurrentVersionForDraft (follows the pointer so restoreVersion shows
  up in the editor), useGenerationsForDraft, useAllStyles + helpers.
- UI: KindTabs (shows only kinds with drafts), StatusBadge, StatusFilter,
  DraftCard (<button> for a11y), BriefingForm (topic/kind/audience/tone/
  length/language/extra), VersionEditor (500ms debounce + onBlur flush),
  VersionHistory (restore button per version).
- Routes: /writing list + /writing/draft/[id] with {#key id} remounting.

User flow: create draft from briefing → land in detail view → type →
autosave → "Als Checkpoint speichern" for a new version → restore any
older version from the history panel. No AI yet; M3 wires mana-llm for
short-form generation and M7 switches to mana-ai missions for long-form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 14:59:56 +02:00
parent 259f6fb316
commit 3c3b2ebbc7
27 changed files with 3484 additions and 0 deletions

View file

@ -91,6 +91,12 @@ import type {
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
import type { LocalMeImage } from '../../modules/profile/types';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
import type {
LocalDraft,
LocalDraftVersion,
LocalGeneration,
LocalWritingStyle,
} from '../../modules/writing/types';
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -718,6 +724,36 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
},
// ─── Writing ─────────────────────────────────────────────
// Ghostwriter module — drafts, version snapshots, generation records,
// and user-defined styles. The full prose is the most sensitive
// surface: a draft's content, briefing (topic / audience / extra
// instructions) and any user-supplied reference notes can disclose
// anything from unannounced launches to personal letters.
//
// Plaintext (intentional):
// - kind, status, publish targets, isFavorite, visibility fields
// drive tabs / chips / sort — query-critical.
// - versionNumber, wordCount, generationId, isAiGenerated — used
// for ordering and the history panel.
// - generation.status, provider, model, params, tokenUsage,
// durationMs, missionId — purely operational, no user content.
// - style.source, presetId, isSpaceDefault, isFavorite — query.
//
// `references` on a draft is an array of { kind, targetId/url, note }.
// targetId + url + kind are plaintext (FKs and public URLs); the
// per-reference `note` travels encrypted with the whole array via
// array-path encryption (same pattern as food.foods / quiz.options).
writingDrafts: entry<LocalDraft>(['title', 'briefing', 'styleOverrides', 'references']),
writingDraftVersions: entry<LocalDraftVersion>(['content', 'summary']),
writingGenerations: entry<LocalGeneration>(['prompt', 'output']),
writingStyles: entry<LocalWritingStyle>([
'name',
'description',
'samples',
'extractedPrinciples',
]),
// ─── Invoices ────────────────────────────────────────────
// Outbound finance. Sensitive surface is non-trivial: client name and
// address, the free-text subject/notes/terms, and the line items

View file

@ -1011,6 +1011,27 @@ db.version(42).stores({
'id, isFavorite, isPublic, isArchived, prompt, updatedAt, wardrobeOutfitId, wardrobeGarmentId',
});
// v43 — Writing module (docs/plans/writing-module.md M1).
// Four space-scoped tables:
// - writingDrafts: briefing + currentVersionId pointer. Indices: kind
// (tab filter), status (chip filter), updatedAt (default sort),
// isFavorite (favourites toggle).
// - writingDraftVersions: immutable snapshots of the draft body. Indexed
// on draftId for version-history fetch, versionNumber for ordering.
// - writingGenerations: provider-level call records (prompt, status,
// tokens, model). Indexed on draftId for per-draft runs and status
// so the UI can find in-flight work on reload.
// - writingStyles: reusable style definitions (preset + custom). Indexed
// on source (preset vs custom), isSpaceDefault, isFavorite.
// All four get standard spaceId/authorId/visibility stamping via the Dexie
// hook (NOT in USER_LEVEL_TABLES).
db.version(43).stores({
writingDrafts: 'id, kind, status, updatedAt, isFavorite',
writingDraftVersions: 'id, draftId, versionNumber, createdAt',
writingGenerations: 'id, draftId, status, createdAt',
writingStyles: 'id, source, isSpaceDefault, isFavorite, updatedAt',
});
// ─── 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

View file

@ -105,6 +105,7 @@ import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
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 { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -166,6 +167,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
wetterModuleConfig,
websiteModuleConfig,
wardrobeModuleConfig,
writingModuleConfig,
aiModuleConfig,
];

View file

@ -0,0 +1,11 @@
<!--
Writing — Module-root ListView.
Mirrors the library/ convention: a thin wrapper that delegates to the
real view in views/ so the route file doesn't need to know about the
internal structure.
-->
<script lang="ts">
import ListView from './views/ListView.svelte';
</script>
<ListView />

View file

@ -0,0 +1,65 @@
/**
* Writing module Dexie accessors and guest seed.
*
* Four tables: `writingDrafts` (the briefing + pointer to current version),
* `writingDraftVersions` (immutable snapshots of the text body), `writingGenerations`
* (provider-level call records prompt, status, duration, tokens), and
* `writingStyles` (reusable named style definitions, preset or custom).
*/
import { db } from '$lib/data/database';
import type { LocalDraft, LocalDraftVersion, LocalGeneration, LocalWritingStyle } from './types';
export const draftTable = db.table<LocalDraft>('writingDrafts');
export const draftVersionTable = db.table<LocalDraftVersion>('writingDraftVersions');
export const generationTable = db.table<LocalGeneration>('writingGenerations');
export const writingStyleTable = db.table<LocalWritingStyle>('writingStyles');
// ─── Guest Seed ────────────────────────────────────────────
//
// One example draft + its initial version + a custom style, so first-run
// users can open the module and immediately see the briefing / version /
// style shape. Intentionally small — the interesting seeds are the
// preset styles (see `presets/styles.ts`), not sample drafts.
export const WRITING_GUEST_SEED = {
writingDrafts: [
{
id: 'demo-draft-welcome',
kind: 'blog' as const,
status: 'draft' as const,
title: 'Willkommen bei Writing',
briefing: {
topic: 'Was dieses Modul für dich tut',
audience: 'Neue Mana-Nutzer',
tone: 'freundlich, konkret',
language: 'de',
targetLength: { type: 'words' as const, value: 220 },
extraInstructions: null,
useResearch: false,
},
styleId: null,
styleOverrides: null,
references: [],
currentVersionId: 'demo-draft-welcome-v1',
isFavorite: false,
publishedTo: [],
},
],
writingDraftVersions: [
{
id: 'demo-draft-welcome-v1',
draftId: 'demo-draft-welcome',
versionNumber: 1,
content:
'Writing ist dein Ghostwriter in Mana. Beschreibe kurz, was du brauchst — Thema, Zielgruppe, Länge, Ton — und wähle einen Stil. Das Modul erzeugt einen ersten Entwurf, den du Absatz für Absatz verfeinern kannst. Bereit für deinen ersten Text?',
wordCount: 41,
generationId: null,
isAiGenerated: false,
parentVersionId: null,
summary: null,
},
],
writingGenerations: [],
writingStyles: [],
};

View file

@ -0,0 +1,304 @@
<!--
BriefingForm — create a new draft or edit an existing draft's briefing.
In M2 this is the only way a draft comes into existence. M3+ will add a
one-field "what do you want to write?" shortcut that proposes a briefing,
but the full form remains the canonical source-of-truth view.
-->
<script lang="ts">
import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
import { draftsStore } from '../stores/drafts.svelte';
import type { Draft, DraftKind, DraftBriefing } from '../types';
let {
mode,
draft,
initialKind,
onclose,
oncreated,
}: {
mode: 'create' | 'edit';
draft?: Draft;
initialKind?: DraftKind;
onclose: () => void;
oncreated?: (draft: Draft) => void;
} = $props();
const KIND_ORDER: DraftKind[] = [
'blog',
'essay',
'email',
'social',
'story',
'letter',
'speech',
'cover-letter',
'product-description',
'press-release',
'bio',
'other',
];
// Form state is seeded once from `draft` / `initialKind`; it never
// re-syncs because the form gets a fresh mount every time it opens.
// The svelte-ignore comments silence the (correct-in-general) warning.
/* svelte-ignore state_referenced_locally */
let title = $state(draft?.title ?? '');
/* svelte-ignore state_referenced_locally */
let kind = $state<DraftKind>(draft?.kind ?? initialKind ?? 'blog');
/* svelte-ignore state_referenced_locally */
let topic = $state(draft?.briefing.topic ?? '');
/* svelte-ignore state_referenced_locally */
let audience = $state(draft?.briefing.audience ?? '');
/* svelte-ignore state_referenced_locally */
let tone = $state(draft?.briefing.tone ?? '');
/* svelte-ignore state_referenced_locally */
let language = $state(draft?.briefing.language ?? DEFAULT_LANGUAGE);
/* svelte-ignore state_referenced_locally */
let targetLengthValue = $state<number>(
draft?.briefing.targetLength?.value ?? LENGTH_PRESETS[draft?.kind ?? 'blog'].value
);
/* svelte-ignore state_referenced_locally */
let extraInstructions = $state(draft?.briefing.extraInstructions ?? '');
let saving = $state(false);
let error = $state<string | null>(null);
// When the user switches kind while creating a new draft, nudge the
// target length to that kind's preset so they don't have to touch it.
// On edit we leave existing custom values alone.
/* svelte-ignore state_referenced_locally */
let lastKindSeen = $state(kind);
$effect(() => {
if (mode !== 'create') return;
if (kind === lastKindSeen) return;
targetLengthValue = LENGTH_PRESETS[kind].value;
lastKindSeen = kind;
});
const isValid = $derived(title.trim().length > 0 && topic.trim().length > 0);
async function submit(ev: Event) {
ev.preventDefault();
if (!isValid || saving) return;
saving = true;
error = null;
try {
const briefing: Partial<DraftBriefing> & { topic: string } = {
topic: topic.trim(),
audience: audience.trim() || null,
tone: tone.trim() || null,
language,
targetLength: { type: 'words', value: targetLengthValue },
extraInstructions: extraInstructions.trim() || null,
};
if (mode === 'create') {
const { draft: created } = await draftsStore.createDraft({
kind,
title: title.trim(),
briefing,
});
oncreated?.(created);
} else if (draft) {
await draftsStore.updateDraft(draft.id, {
title: title.trim(),
kind,
});
await draftsStore.updateBriefing(draft.id, briefing);
}
onclose();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
</script>
<form class="briefing" onsubmit={submit}>
<div class="row">
<label>
<span>Titel</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={title} placeholder="Mein Blogpost über …" required autofocus />
</label>
<label class="kind-select">
<span>Textart</span>
<select bind:value={kind}>
{#each KIND_ORDER as k (k)}
<option value={k}>
{KIND_LABELS[k].emoji}
{KIND_LABELS[k].de}
</option>
{/each}
</select>
</label>
</div>
<label>
<span>Worum geht's? <small>(wird als Kern-Briefing an die KI übergeben)</small></span>
<textarea
bind:value={topic}
rows="3"
placeholder="z.B. 'Was Mana von klassischen Produktivitätstools unterscheidet, aus Nutzersicht'"
required
></textarea>
</label>
<div class="row">
<label>
<span>Zielgruppe</span>
<input type="text" bind:value={audience} placeholder="z.B. Gründer, Eltern, …" />
</label>
<label>
<span>Ton</span>
<select bind:value={tone}>
<option value="">— kein fester Ton —</option>
{#each TONE_PRESETS as preset (preset.id)}
<option value={preset.id}>{preset.de}</option>
{/each}
</select>
</label>
</div>
<div class="row">
<label>
<span>Länge (Wörter)</span>
<input type="number" min="20" max="20000" step="20" bind:value={targetLengthValue} />
</label>
<label>
<span>Sprache</span>
<select bind:value={language}>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
</select>
</label>
</div>
<label>
<span>Zusatzhinweise <small>(optional)</small></span>
<textarea
bind:value={extraInstructions}
rows="2"
placeholder="z.B. 'keine Buzzwords', 'mit einem Zitat beginnen', …"
></textarea>
</label>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="actions">
<button type="button" class="secondary" onclick={onclose} disabled={saving}> Abbrechen </button>
<button type="submit" class="primary" disabled={!isValid || saving}>
{#if saving}
Speichert…
{:else if mode === 'create'}
Draft anlegen
{:else}
Speichern
{/if}
</button>
</div>
</form>
<style>
.briefing {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1rem 1.25rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
label > span {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
small {
font-weight: normal;
opacity: 0.7;
}
input,
select,
textarea {
padding: 0.5rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
textarea {
resize: vertical;
min-height: 3rem;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.kind-select {
max-width: 220px;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.25rem;
}
button {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
font: inherit;
font-weight: 500;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary {
background: #0ea5e9;
color: white;
border: 1px solid #0ea5e9;
}
.primary:hover:not(:disabled) {
background: #0284c7;
border-color: #0284c7;
}
.secondary {
background: transparent;
color: var(--color-text, inherit);
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
.secondary:hover:not(:disabled) {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.error {
color: #ef4444;
font-size: 0.85rem;
margin: 0;
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
.kind-select {
max-width: none;
}
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import StatusBadge from './StatusBadge.svelte';
import { KIND_LABELS } from '../constants';
import type { Draft, DraftVersion } from '../types';
let {
draft,
currentVersion,
onopen,
}: {
draft: Draft;
currentVersion: DraftVersion | null;
onopen: (draft: Draft) => void;
} = $props();
const kind = $derived(KIND_LABELS[draft.kind]);
const preview = $derived.by(() => {
const text = (currentVersion?.content ?? '').trim();
if (!text) return draft.briefing.topic;
return text.length > 160 ? text.slice(0, 160) + '…' : text;
});
const updatedLabel = $derived(formatRelative(draft.updatedAt));
function formatRelative(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
const mins = Math.round(diff / 60000);
if (mins < 1) return 'gerade eben';
if (mins < 60) return `vor ${mins} min`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `vor ${hrs} h`;
const days = Math.round(hrs / 24);
if (days < 30) return `vor ${days} d`;
return d.toLocaleDateString('de-DE');
}
function open() {
onopen(draft);
}
</script>
<button type="button" class="card" onclick={open}>
<header>
<span class="kind" title={kind.de}>
<span aria-hidden="true">{kind.emoji}</span>
{kind.de}
</span>
{#if draft.isFavorite}
<span class="fav" aria-label="Favorit"></span>
{/if}
</header>
<h3 class="title">{draft.title || draft.briefing.topic || 'Unbenannt'}</h3>
<p class="preview">{preview}</p>
<footer>
<StatusBadge status={draft.status} />
<span class="words">
{currentVersion?.wordCount ?? 0} Wörter
</span>
<span class="updated">{updatedLabel}</span>
</footer>
</button>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.04));
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card:hover,
.card:focus-visible {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
border-color: color-mix(in srgb, #0ea5e9 40%, transparent);
}
.card:focus-visible {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.kind {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fav {
color: #f59e0b;
}
.title {
font-size: 1rem;
margin: 0;
line-height: 1.3;
}
.preview {
margin: 0;
font-size: 0.85rem;
line-height: 1.4;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
footer {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
flex-wrap: wrap;
}
.words {
white-space: nowrap;
}
.updated {
margin-left: auto;
}
</style>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import { KIND_LABELS } from '../constants';
import type { DraftKind } from '../types';
let {
active,
counts,
onselect,
}: {
active: DraftKind | 'all';
counts: Record<DraftKind, number>;
onselect: (kind: DraftKind | 'all') => void;
} = $props();
const ORDER: DraftKind[] = [
'blog',
'essay',
'email',
'social',
'story',
'letter',
'speech',
'cover-letter',
'product-description',
'press-release',
'bio',
'other',
];
const total = $derived(ORDER.reduce((s, k) => s + counts[k], 0));
</script>
<div class="tabs" role="tablist">
<button
type="button"
class="tab"
class:active={active === 'all'}
onclick={() => onselect('all')}
role="tab"
aria-selected={active === 'all'}
>
Alle <span class="count">{total}</span>
</button>
{#each ORDER as kind (kind)}
{#if counts[kind] > 0 || active === kind}
<button
type="button"
class="tab"
class:active={active === kind}
onclick={() => onselect(kind)}
role="tab"
aria-selected={active === kind}
>
<span class="emoji" aria-hidden="true">{KIND_LABELS[kind].emoji}</span>
{KIND_LABELS[kind].de}
<span class="count">{counts[kind]}</span>
</button>
{/if}
{/each}
</div>
<style>
.tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text, inherit);
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.tab:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.tab.active {
background: color-mix(in srgb, #0ea5e9 12%, transparent);
border-color: #0ea5e9;
color: #0ea5e9;
font-weight: 500;
}
.emoji {
font-size: 1rem;
}
.count {
font-size: 0.75rem;
opacity: 0.7;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
padding: 0.05rem 0.4rem;
border-radius: 999px;
}
.tab.active .count {
background: color-mix(in srgb, #0ea5e9 20%, transparent);
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { STATUS_LABELS, STATUS_COLORS } from '../constants';
import type { DraftStatus } from '../types';
let { status }: { status: DraftStatus } = $props();
const color = $derived(STATUS_COLORS[status]);
const label = $derived(STATUS_LABELS[status].de);
</script>
<span class="badge" style:--badge={color}>
<span class="dot" aria-hidden="true"></span>
{label}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
color: var(--badge);
background: color-mix(in srgb, var(--badge) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--badge) 25%, transparent);
white-space: nowrap;
}
.dot {
width: 0.4rem;
height: 0.4rem;
border-radius: 999px;
background: var(--badge);
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { STATUS_LABELS } from '../constants';
import type { DraftStatus } from '../types';
let {
active,
onselect,
}: {
active: DraftStatus | null;
onselect: (status: DraftStatus | null) => void;
} = $props();
const ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published'];
</script>
<div class="chips" role="toolbar" aria-label="Status-Filter">
<button type="button" class="chip" class:active={active === null} onclick={() => onselect(null)}>
Alle
</button>
{#each ORDER as status (status)}
<button
type="button"
class="chip"
class:active={active === status}
onclick={() => onselect(active === status ? null : status)}
>
{STATUS_LABELS[status].de}
</button>
{/each}
</div>
<style>
.chips {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.chip {
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.chip:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.chip.active {
background: color-mix(in srgb, #0ea5e9 10%, transparent);
border-color: #0ea5e9;
color: #0ea5e9;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,130 @@
<!--
Editable textarea bound to the draft's current version.
Saves with a short debounce so every keystroke doesn't hit Dexie; on
blur it force-flushes any pending edit. The word-count is computed
locally for live feedback and re-derived on save.
-->
<script lang="ts">
import { draftsStore } from '../stores/drafts.svelte';
import type { DraftVersion } from '../types';
let {
version,
targetWords = null,
onchange,
}: {
version: DraftVersion;
targetWords?: number | null;
onchange?: (content: string) => void;
} = $props();
// Snapshot id so we reset local state when the active version flips
// (e.g. after "Restore this version" on the history panel).
/* svelte-ignore state_referenced_locally */
let lastVersionId = $state<string>(version.id);
/* svelte-ignore state_referenced_locally */
let text = $state<string>(version.content);
$effect(() => {
if (version.id !== lastVersionId) {
lastVersionId = version.id;
text = version.content;
}
});
const wordCount = $derived.by(() => {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
});
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pending = $state(false);
function queueSave(next: string) {
if (saveTimer) clearTimeout(saveTimer);
pending = true;
saveTimer = setTimeout(async () => {
saveTimer = null;
await draftsStore.updateVersionContent(version.id, next);
pending = false;
}, 500);
}
function handleInput() {
onchange?.(text);
queueSave(text);
}
async function flush() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
await draftsStore.updateVersionContent(version.id, text);
pending = false;
}
}
</script>
<div class="editor">
<textarea
bind:value={text}
oninput={handleInput}
onblur={flush}
placeholder="Hier schreibst du (oder die KI). Leer lassen für Generate."
spellcheck="true"
></textarea>
<footer>
<span>
{wordCount} Wörter{#if targetWords}
<span class="target"> / Ziel ~{targetWords}</span>
{/if}
</span>
<span class="status" aria-live="polite">
{#if pending}
Speichert…
{:else}
Gespeichert
{/if}
</span>
</footer>
</div>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
textarea {
width: 100%;
min-height: 50vh;
padding: 1rem 1.25rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.04));
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
font-size: 1.05rem;
line-height: 1.6;
color: inherit;
resize: vertical;
}
textarea:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.target {
opacity: 0.7;
}
.status {
font-style: italic;
}
</style>

View file

@ -0,0 +1,136 @@
<!--
List of all LocalDraftVersions for a draft. Highlights the current one;
"Wiederherstellen" flips the draft's `currentVersionId` back via the
store. Versions are immutable snapshots so Restore is a pointer change,
not a destructive revert.
-->
<script lang="ts">
import { draftsStore } from '../stores/drafts.svelte';
import type { DraftVersion } from '../types';
let {
versions,
currentVersionId,
draftId,
}: {
versions: DraftVersion[];
currentVersionId: string | null;
draftId: string;
} = $props();
// Newest on top.
const sorted = $derived([...versions].sort((a, b) => b.versionNumber - a.versionNumber));
async function restore(versionId: string) {
await draftsStore.restoreVersion(draftId, versionId);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<ul class="history">
{#each sorted as version (version.id)}
{@const isCurrent = version.id === currentVersionId}
<li class="version" class:current={isCurrent}>
<div class="meta">
<strong>v{version.versionNumber}</strong>
{#if version.isAiGenerated}
<span class="tag ai" title="KI-generiert">KI</span>
{/if}
{#if isCurrent}
<span class="tag current">Aktiv</span>
{/if}
</div>
<div class="stats">
<span>{version.wordCount} Wörter</span>
<span class="date">{formatDate(version.createdAt)}</span>
</div>
{#if version.summary}
<p class="summary">{version.summary}</p>
{/if}
{#if !isCurrent}
<button type="button" class="restore" onclick={() => restore(version.id)}>
Wiederherstellen
</button>
{/if}
</li>
{/each}
</ul>
<style>
.history {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.version {
padding: 0.6rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.04));
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.version.current {
border-color: #0ea5e9;
background: color-mix(in srgb, #0ea5e9 6%, transparent);
}
.meta {
display: flex;
align-items: center;
gap: 0.4rem;
}
.tag {
font-size: 0.65rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tag.ai {
background: color-mix(in srgb, #a855f7 15%, transparent);
color: #a855f7;
}
.tag.current {
background: color-mix(in srgb, #0ea5e9 15%, transparent);
color: #0ea5e9;
}
.stats {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.summary {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.restore {
align-self: flex-start;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
cursor: pointer;
color: inherit;
font: inherit;
}
.restore:hover {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.05));
}
</style>

View file

@ -0,0 +1,85 @@
import type { DraftKind, DraftStatus, GenerationStatus, StyleSource } from './types';
export const KIND_LABELS: Record<DraftKind, { de: string; en: string; emoji: string }> = {
blog: { de: 'Blog', en: 'Blog', emoji: '📝' },
essay: { de: 'Essay', en: 'Essay', emoji: '📄' },
email: { de: 'E-Mail', en: 'Email', emoji: '✉️' },
social: { de: 'Social', en: 'Social', emoji: '💬' },
story: { de: 'Story', en: 'Story', emoji: '📖' },
letter: { de: 'Brief', en: 'Letter', emoji: '💌' },
speech: { de: 'Rede', en: 'Speech', emoji: '🎤' },
'cover-letter': { de: 'Bewerbung', en: 'Cover letter', emoji: '💼' },
'product-description': { de: 'Produkttext', en: 'Product', emoji: '🛍️' },
'press-release': { de: 'Pressetext', en: 'Press', emoji: '📰' },
bio: { de: 'Bio', en: 'Bio', emoji: '👤' },
other: { de: 'Sonstiges', en: 'Other', emoji: '✏️' },
};
export const STATUS_LABELS: Record<DraftStatus, { de: string; en: string }> = {
draft: { de: 'Entwurf', en: 'Draft' },
refining: { de: 'In Überarbeitung', en: 'Refining' },
complete: { de: 'Fertig', en: 'Complete' },
published: { de: 'Veröffentlicht', en: 'Published' },
};
export const STATUS_COLORS: Record<DraftStatus, string> = {
draft: '#64748b',
refining: '#3b82f6',
complete: '#22c55e',
published: '#a855f7',
};
export const GENERATION_STATUS_LABELS: Record<GenerationStatus, { de: string; en: string }> = {
queued: { de: 'In Warteschlange', en: 'Queued' },
running: { de: 'Läuft', en: 'Running' },
succeeded: { de: 'Fertig', en: 'Succeeded' },
failed: { de: 'Fehlgeschlagen', en: 'Failed' },
cancelled: { de: 'Abgebrochen', en: 'Cancelled' },
};
export const STYLE_SOURCE_LABELS: Record<StyleSource, { de: string; en: string }> = {
preset: { de: 'Vorlage', en: 'Preset' },
'custom-description': { de: 'Eigene Beschreibung', en: 'Custom description' },
'sample-trained': { de: 'Aus Textproben trainiert', en: 'Trained from samples' },
'self-trained': { de: 'Schreibe wie ich', en: 'Write like me' },
};
/** Default word-count targets per kind — used in briefing defaults. */
export const LENGTH_PRESETS: Record<DraftKind, { type: 'words'; value: number }> = {
blog: { type: 'words', value: 800 },
essay: { type: 'words', value: 1500 },
email: { type: 'words', value: 180 },
social: { type: 'words', value: 80 },
story: { type: 'words', value: 1200 },
letter: { type: 'words', value: 350 },
speech: { type: 'words', value: 600 },
'cover-letter': { type: 'words', value: 400 },
'product-description': { type: 'words', value: 220 },
'press-release': { type: 'words', value: 450 },
bio: { type: 'words', value: 120 },
other: { type: 'words', value: 500 },
};
export const TONE_PRESETS: ReadonlyArray<{ id: string; de: string; en: string }> = [
{ id: 'neutral', de: 'Neutral', en: 'Neutral' },
{ id: 'warm', de: 'Warm', en: 'Warm' },
{ id: 'formal', de: 'Formell', en: 'Formal' },
{ id: 'casual', de: 'Locker', en: 'Casual' },
{ id: 'professional', de: 'Professionell', en: 'Professional' },
{ id: 'playful', de: 'Verspielt', en: 'Playful' },
{ id: 'urgent', de: 'Dringlich', en: 'Urgent' },
{ id: 'empathetic', de: 'Einfühlsam', en: 'Empathetic' },
{ id: 'assertive', de: 'Selbstbewusst', en: 'Assertive' },
{ id: 'humorous', de: 'Humorvoll', en: 'Humorous' },
];
export const DEFAULT_LANGUAGE = 'de';
/** Kinds for which the runner should produce an outline before the full draft. */
export const AUTO_OUTLINE_KINDS: ReadonlyArray<DraftKind> = [
'blog',
'essay',
'speech',
'cover-letter',
'story',
];

View file

@ -0,0 +1,86 @@
/**
* Writing module barrel exports.
*/
export { draftsStore } from './stores/drafts.svelte';
export type { CreateDraftInput, UpdateDraftPatch } from './stores/drafts.svelte';
export { stylesStore } from './stores/styles.svelte';
export type { CreateStyleInput, UpdateStylePatch } from './stores/styles.svelte';
export {
useAllDrafts,
useDraft,
useVersionsForDraft,
useVersion,
useCurrentVersionForDraft,
useGenerationsForDraft,
useAllStyles,
toDraft,
toDraftVersion,
toGeneration,
toWritingStyle,
filterByKind,
filterByStatus,
searchDrafts,
sortByUpdated,
groupByKind,
computeStats,
} from './queries';
export type { WritingStats } from './queries';
export {
draftTable,
draftVersionTable,
generationTable,
writingStyleTable,
WRITING_GUEST_SEED,
} from './collections';
export {
KIND_LABELS,
STATUS_LABELS,
STATUS_COLORS,
GENERATION_STATUS_LABELS,
STYLE_SOURCE_LABELS,
LENGTH_PRESETS,
TONE_PRESETS,
DEFAULT_LANGUAGE,
AUTO_OUTLINE_KINDS,
} from './constants';
export { STYLE_PRESETS, getStylePreset } from './presets/styles';
export type { StylePreset } from './presets/styles';
export type {
// Enums
DraftKind,
DraftStatus,
DraftLengthUnit,
GenerationStatus,
GenerationKind,
GenerationProvider,
StyleSource,
DraftReferenceKind,
DraftPublishModule,
// Sub-objects
DraftBriefing,
DraftStyleOverrides,
DraftReference,
DraftPublishTarget,
DraftGenerationParams,
DraftSelection,
DraftTokenUsage,
StyleSample,
StyleExtractedPrinciples,
// Dexie records
LocalDraft,
LocalDraftVersion,
LocalGeneration,
LocalWritingStyle,
// Domain types
Draft,
DraftVersion,
Generation,
WritingStyle,
} from './types';

View file

@ -0,0 +1,11 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const writingModuleConfig: ModuleConfig = {
appId: 'writing',
tables: [
{ name: 'writingDrafts' },
{ name: 'writingDraftVersions' },
{ name: 'writingGenerations' },
{ name: 'writingStyles' },
],
};

View file

@ -0,0 +1,175 @@
/**
* Preset style definitions the "ready-made" styles a user can pick from
* in the briefing without having to train anything. Each preset ships with
* a German + English description plus extracted principles so the prompt
* builder can treat presets and custom-trained styles identically.
*
* Keeping presets in code (not in Dexie) means they're versioned with the
* app, never need syncing, and can be edited in a PR. A user's "custom"
* style is a row in `writingStyles`; a preset is referenced by its `id`
* via `LocalDraft.styleId`. When a user "favourites" a preset we still
* write a row (source='preset', presetId=<id>) so the picker can show it.
*/
import type { StyleExtractedPrinciples } from '../types';
export interface StylePreset {
id: string;
name: { de: string; en: string };
description: { de: string; en: string };
principles: StyleExtractedPrinciples;
}
const now = '2026-04-24T00:00:00.000Z';
export const STYLE_PRESETS: ReadonlyArray<StylePreset> = [
{
id: 'academic',
name: { de: 'Akademisch', en: 'Academic' },
description: {
de: 'Dicht, passive Voice erlaubt, Zitate, Konjunktiv — für wissenschaftliche Texte.',
en: 'Dense, passive voice allowed, citations, subjunctive — for scholarly prose.',
},
principles: {
toneTraits: ['formal', 'precise', 'hedged'],
sentenceLengthAvg: 28,
vocabulary: ['furthermore', 'notwithstanding', 'consequently'],
examples: [],
rawAnalysis:
'Passive constructions allowed. Qualifier-heavy ("it may be argued that…"). References by author + year. No contractions. No rhetorical questions.',
extractedAt: now,
},
},
{
id: 'casual-blog',
name: { de: 'Casual Blog', en: 'Casual blog' },
description: {
de: 'Du-Ansprache, kurze Absätze, rhetorische Fragen — persönlicher Blog-Ton.',
en: 'Second-person, short paragraphs, rhetorical questions — personal blog voice.',
},
principles: {
toneTraits: ['conversational', 'direct', 'warm'],
sentenceLengthAvg: 16,
examples: [],
rawAnalysis:
'Address the reader as "du" / "you". Contractions fine. 23-sentence paragraphs. Occasional rhetorical question to punctuate sections.',
extractedAt: now,
},
},
{
id: 'linkedin',
name: { de: 'LinkedIn-Post', en: 'LinkedIn post' },
description: {
de: 'Hook in Zeile 1, 1-Satz-Absätze, sparsamer Emoji-Einsatz, Call-to-Action am Ende.',
en: 'Hook on line 1, one-sentence paragraphs, sparing emoji, CTA at the end.',
},
principles: {
toneTraits: ['hook-first', 'confident', 'accessible'],
sentenceLengthAvg: 12,
examples: [],
rawAnalysis:
'Line 1 must hook. Short paragraphs (often one sentence each). Bullet-style mid-post lists are OK. End with a question or explicit CTA. Emoji only to structure, not decorate.',
extractedAt: now,
},
},
{
id: 'twitter-thread',
name: { de: 'Twitter/X-Thread', en: 'Twitter/X thread' },
description: {
de: 'Nummerierte Tweets ≤280 Zeichen, Cliffhanger zwischen den Posts.',
en: 'Numbered tweets ≤280 chars, cliffhanger between posts.',
},
principles: {
toneTraits: ['punchy', 'cliffhanger-driven'],
sentenceLengthAvg: 10,
examples: [],
rawAnalysis:
'Split into numbered tweets (1/, 2/, …). Every tweet ≤280 chars. Each tweet should reward a stop, and incentivise a continue. Open with a strong claim.',
extractedAt: now,
},
},
{
id: 'hemingway',
name: { de: 'Hemingway', en: 'Hemingway' },
description: {
de: 'Deklarativ, kurze Sätze, minimale Adjektive — nüchtern und klar.',
en: 'Declarative, short sentences, minimal adjectives — lean and clear.',
},
principles: {
toneTraits: ['declarative', 'lean', 'concrete'],
sentenceLengthAvg: 9,
examples: [],
rawAnalysis:
'Mostly simple declarative sentences. Adverbs used sparingly. Concrete nouns over abstract. No meta-commentary. Show, do not tell.',
extractedAt: now,
},
},
{
id: 'news',
name: { de: 'Nachrichtlich', en: 'Newswire' },
description: {
de: 'Inverted Pyramid, nüchtern, keine Meinung — wie eine Nachrichtenagentur.',
en: 'Inverted pyramid, neutral, opinion-free — newswire style.',
},
principles: {
toneTraits: ['neutral', 'factual', 'inverted-pyramid'],
sentenceLengthAvg: 18,
examples: [],
rawAnalysis:
'Lead with the 5 Ws. Most important fact first, background last. Attribute every claim. No first-person. No opinion.',
extractedAt: now,
},
},
{
id: 'listicle',
name: { de: 'Listicle', en: 'Listicle' },
description: {
de: 'Nummerierte Liste mit überspitzten Einleitungen — Buzzfeed-Format.',
en: 'Numbered list with punchy intros — Buzzfeed-style.',
},
principles: {
toneTraits: ['punchy', 'listicle-structured', 'irreverent'],
sentenceLengthAvg: 14,
examples: [],
rawAnalysis:
'Numbered headings (e.g. "1. The thing that changed everything"). 24 sentences per item. Short opener, strong closing sentence per item. OK to use surprise and hyperbole.',
extractedAt: now,
},
},
{
id: 'pitch',
name: { de: 'Pitch / Sales', en: 'Pitch / sales' },
description: {
de: 'Problem → Agitation → Solution — Verkaufstext mit klarer Struktur.',
en: 'Problem → Agitation → Solution — sales writing with a clear arc.',
},
principles: {
toneTraits: ['persuasive', 'outcome-focused'],
sentenceLengthAvg: 15,
examples: [],
rawAnalysis:
"Open with the reader's problem. Agitate (cost of inaction). Introduce solution. Close with specific next step. Short paragraphs, concrete benefits, social proof when available.",
extractedAt: now,
},
},
{
id: 'memoir',
name: { de: 'Memoir', en: 'Memoir' },
description: {
de: '1. Person, sensorisch, Szenen statt Zusammenfassungen — persönlicher Erinnerungsstil.',
en: 'First-person, sensory, scenes over summary — personal memoir voice.',
},
principles: {
toneTraits: ['introspective', 'sensory', 'scene-driven'],
sentenceLengthAvg: 17,
examples: [],
rawAnalysis:
'First-person throughout. Anchor in time + place + body. Prefer scenes ("It was the Tuesday after…") to summaries. Interior thought marked by rhythm change rather than italics.',
extractedAt: now,
},
},
];
export function getStylePreset(id: string): StylePreset | undefined {
return STYLE_PRESETS.find((p) => p.id === id);
}

View file

@ -0,0 +1,267 @@
/**
* Reactive queries + pure helpers for the Writing module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import type {
LocalDraft,
LocalDraftVersion,
LocalGeneration,
LocalWritingStyle,
Draft,
DraftVersion,
Generation,
WritingStyle,
DraftKind,
DraftStatus,
} from './types';
// ─── Type Converters ─────────────────────────────────────
export function toDraft(local: LocalDraft): Draft {
const now = new Date().toISOString();
return {
id: local.id,
kind: local.kind,
status: local.status,
title: local.title,
briefing: local.briefing,
styleId: local.styleId ?? null,
styleOverrides: local.styleOverrides ?? null,
references: local.references ?? [],
currentVersionId: local.currentVersionId ?? null,
publishedTo: local.publishedTo ?? [],
isFavorite: local.isFavorite ?? false,
visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toDraftVersion(local: LocalDraftVersion): DraftVersion {
const now = new Date().toISOString();
return {
id: local.id,
draftId: local.draftId,
versionNumber: local.versionNumber,
content: local.content,
wordCount: local.wordCount ?? 0,
generationId: local.generationId ?? null,
isAiGenerated: local.isAiGenerated ?? false,
parentVersionId: local.parentVersionId ?? null,
summary: local.summary ?? null,
createdAt: local.createdAt ?? now,
};
}
export function toGeneration(local: LocalGeneration): Generation {
const now = new Date().toISOString();
return {
id: local.id,
draftId: local.draftId,
kind: local.kind,
status: local.status,
prompt: local.prompt,
provider: local.provider,
model: local.model ?? null,
params: local.params ?? null,
inputSelection: local.inputSelection ?? null,
output: local.output ?? null,
outputVersionId: local.outputVersionId ?? null,
startedAt: local.startedAt ?? null,
completedAt: local.completedAt ?? null,
durationMs: local.durationMs ?? null,
tokenUsage: local.tokenUsage ?? null,
error: local.error ?? null,
missionId: local.missionId ?? null,
createdAt: local.createdAt ?? now,
};
}
export function toWritingStyle(local: LocalWritingStyle): WritingStyle {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description,
source: local.source,
presetId: local.presetId ?? null,
samples: local.samples ?? [],
extractedPrinciples: local.extractedPrinciples ?? null,
isSpaceDefault: local.isSpaceDefault ?? false,
isFavorite: local.isFavorite ?? false,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllDrafts() {
return useLiveQueryWithDefault(async () => {
const locals = await scopedForModule<LocalDraft, string>('writing', 'writingDrafts').toArray();
const visible = locals.filter((d) => !d.deletedAt);
const decrypted = await decryptRecords('writingDrafts', visible);
return decrypted.map(toDraft);
}, [] as Draft[]);
}
export function useDraft(id: string) {
return useLiveQueryWithDefault(
async () => {
if (!id) return null;
const row = await db.table<LocalDraft>('writingDrafts').get(id);
if (!row || row.deletedAt) return null;
const [decrypted] = await decryptRecords('writingDrafts', [row]);
return decrypted ? toDraft(decrypted) : null;
},
null as Draft | null
);
}
export function useVersionsForDraft(draftId: string) {
return useLiveQueryWithDefault(async () => {
if (!draftId) return [] as DraftVersion[];
const rows = await db
.table<LocalDraftVersion>('writingDraftVersions')
.where('draftId')
.equals(draftId)
.toArray();
const visible = rows.filter((v) => !v.deletedAt);
const decrypted = await decryptRecords('writingDraftVersions', visible);
return decrypted.map(toDraftVersion).sort((a, b) => a.versionNumber - b.versionNumber);
}, [] as DraftVersion[]);
}
export function useVersion(versionId: string) {
return useLiveQueryWithDefault(
async () => {
if (!versionId) return null;
const row = await db.table<LocalDraftVersion>('writingDraftVersions').get(versionId);
if (!row || row.deletedAt) return null;
const [decrypted] = await decryptRecords('writingDraftVersions', [row]);
return decrypted ? toDraftVersion(decrypted) : null;
},
null as DraftVersion | null
);
}
/**
* Live-track a draft's *current* version by following the pointer on the
* draft row. Re-runs whenever either the draft or the version table
* changes so flipping `currentVersionId` via `restoreVersion` shows up
* automatically in the editor.
*/
export function useCurrentVersionForDraft(draftId: string) {
return useLiveQueryWithDefault(
async () => {
if (!draftId) return null;
const draftRow = await db.table<LocalDraft>('writingDrafts').get(draftId);
if (!draftRow || draftRow.deletedAt || !draftRow.currentVersionId) return null;
const versionRow = await db
.table<LocalDraftVersion>('writingDraftVersions')
.get(draftRow.currentVersionId);
if (!versionRow || versionRow.deletedAt) return null;
const [decrypted] = await decryptRecords('writingDraftVersions', [versionRow]);
return decrypted ? toDraftVersion(decrypted) : null;
},
null as DraftVersion | null
);
}
export function useGenerationsForDraft(draftId: string) {
return useLiveQueryWithDefault(async () => {
if (!draftId) return [] as Generation[];
const rows = await db
.table<LocalGeneration>('writingGenerations')
.where('draftId')
.equals(draftId)
.toArray();
const visible = rows.filter((g) => !g.deletedAt);
const decrypted = await decryptRecords('writingGenerations', visible);
return decrypted.map(toGeneration).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [] as Generation[]);
}
export function useAllStyles() {
return useLiveQueryWithDefault(async () => {
const rows = await scopedForModule<LocalWritingStyle, string>(
'writing',
'writingStyles'
).toArray();
const visible = rows.filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('writingStyles', visible);
return decrypted.map(toWritingStyle);
}, [] as WritingStyle[]);
}
// ─── Pure Helpers ─────────────────────────────────────────
export function filterByKind(drafts: Draft[], kind: DraftKind): Draft[] {
return drafts.filter((d) => d.kind === kind);
}
export function filterByStatus(drafts: Draft[], status: DraftStatus): Draft[] {
return drafts.filter((d) => d.status === status);
}
export function searchDrafts(drafts: Draft[], query: string): Draft[] {
const lower = query.toLowerCase();
return drafts.filter(
(d) => d.title.toLowerCase().includes(lower) || d.briefing.topic.toLowerCase().includes(lower)
);
}
export function sortByUpdated(drafts: Draft[]): Draft[] {
return [...drafts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function groupByKind(drafts: Draft[]): Record<DraftKind, Draft[]> {
const out: Record<DraftKind, Draft[]> = {
blog: [],
essay: [],
email: [],
social: [],
story: [],
letter: [],
speech: [],
'cover-letter': [],
'product-description': [],
'press-release': [],
bio: [],
other: [],
};
for (const d of drafts) out[d.kind].push(d);
return out;
}
export interface WritingStats {
totalDrafts: number;
byStatus: Record<DraftStatus, number>;
totalWords: number;
currentlyActive: number;
}
export function computeStats(drafts: Draft[], versions: DraftVersion[]): WritingStats {
const byStatus: Record<DraftStatus, number> = {
draft: 0,
refining: 0,
complete: 0,
published: 0,
};
let currentlyActive = 0;
for (const d of drafts) {
byStatus[d.status]++;
if (d.status === 'draft' || d.status === 'refining') currentlyActive++;
}
const totalWords = versions.reduce((acc, v) => acc + v.wordCount, 0);
return {
totalDrafts: drafts.length,
byStatus,
totalWords,
currentlyActive,
};
}

View file

@ -0,0 +1,312 @@
/**
* Writing drafts store mutation-only service for drafts + draft versions.
*
* Creating a draft always creates an initial empty version (v1) in the same
* transaction so the UI can always render a "current version" without
* having to handle a null/missing body. Live typing mutates the current
* version in-place; `createCheckpointVersion` snapshots the current content
* as a new numbered version. `restoreVersion` sets `currentVersionId` to an
* older version without destroying history.
*
* Full-regeneration flows (M3+) will write new versions via the generations
* store and then call `pointToVersion` never append to an existing version.
*/
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 { draftTable, draftVersionTable } from '../collections';
import { toDraft, toDraftVersion } from '../queries';
import { LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
import type {
LocalDraft,
LocalDraftVersion,
DraftKind,
DraftStatus,
DraftBriefing,
DraftReference,
DraftStyleOverrides,
} from '../types';
function wordCountOf(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}
function defaultBriefing(kind: DraftKind, topic: string): DraftBriefing {
return {
topic,
audience: null,
tone: null,
language: DEFAULT_LANGUAGE,
targetLength: LENGTH_PRESETS[kind],
extraInstructions: null,
useResearch: false,
};
}
export interface CreateDraftInput {
kind: DraftKind;
title: string;
briefing?: Partial<DraftBriefing> & { topic: string };
styleId?: string | null;
references?: DraftReference[];
initialContent?: string;
status?: DraftStatus;
isFavorite?: boolean;
}
export type UpdateDraftPatch = Partial<
Pick<
LocalDraft,
| 'title'
| 'kind'
| 'status'
| 'briefing'
| 'styleId'
| 'styleOverrides'
| 'references'
| 'isFavorite'
>
>;
export const draftsStore = {
/**
* Create a draft + its first (empty or pre-filled) version atomically.
* Returns the plaintext Draft snapshot + initial version id.
*/
async createDraft(input: CreateDraftInput) {
const draftId = crypto.randomUUID();
const versionId = crypto.randomUUID();
const briefingInput = input.briefing ?? { topic: input.title };
const briefing: DraftBriefing = {
...defaultBriefing(input.kind, briefingInput.topic),
...briefingInput,
};
const initialContent = input.initialContent ?? '';
const newDraft: LocalDraft = {
id: draftId,
kind: input.kind,
status: input.status ?? 'draft',
title: input.title,
briefing,
styleId: input.styleId ?? null,
styleOverrides: null,
references: input.references ?? [],
currentVersionId: versionId,
publishedTo: [],
isFavorite: input.isFavorite ?? false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const newVersion: LocalDraftVersion = {
id: versionId,
draftId,
versionNumber: 1,
content: initialContent,
wordCount: wordCountOf(initialContent),
generationId: null,
isAiGenerated: false,
parentVersionId: null,
summary: null,
};
const draftSnapshot = toDraft({ ...newDraft });
const versionSnapshot = toDraftVersion({ ...newVersion });
await encryptRecord('writingDrafts', newDraft);
await encryptRecord('writingDraftVersions', newVersion);
await draftTable.add(newDraft);
await draftVersionTable.add(newVersion);
emitDomainEvent('WritingDraftCreated', 'writing', 'writingDrafts', draftId, {
draftId,
kind: input.kind,
title: input.title,
});
return { draft: draftSnapshot, version: versionSnapshot };
},
async updateDraft(id: string, patch: UpdateDraftPatch) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('writingDrafts', wrapped);
await draftTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async updateBriefing(id: string, briefingPatch: Partial<DraftBriefing>) {
const existing = await draftTable.get(id);
if (!existing) return;
const merged: DraftBriefing = { ...existing.briefing, ...briefingPatch };
await draftsStore.updateDraft(id, { briefing: merged });
},
async updateStyleOverrides(id: string, overrides: DraftStyleOverrides | null) {
await draftsStore.updateDraft(id, { styleOverrides: overrides });
},
async setStatus(id: string, status: DraftStatus) {
const existing = await draftTable.get(id);
if (!existing || existing.status === status) return;
await draftTable.update(id, {
status,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WritingDraftStatusChanged', 'writing', 'writingDrafts', id, {
draftId: id,
before: existing.status,
after: status,
});
},
async toggleFavorite(id: string) {
const existing = await draftTable.get(id);
if (!existing) return;
await draftTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async deleteDraft(id: string) {
const now = new Date().toISOString();
await draftTable.update(id, { deletedAt: now, updatedAt: now });
// Soft-delete every version belonging to the draft so they stop
// showing up in version-history queries. Generations we leave —
// they're audit records and shouldn't disappear silently.
const versions = await draftVersionTable.where('draftId').equals(id).toArray();
await Promise.all(
versions.map((v) => draftVersionTable.update(v.id, { deletedAt: now, updatedAt: now }))
);
emitDomainEvent('WritingDraftDeleted', 'writing', 'writingDrafts', id, { draftId: id });
},
/**
* In-place edit of the draft's current version. This is the path for
* live typing in the editor and for selection-refinement application;
* it does NOT create a new version record. Use `createCheckpointVersion`
* when the user wants to freeze the current state.
*/
async updateVersionContent(versionId: string, content: string) {
const existing = await draftVersionTable.get(versionId);
if (!existing) return;
const wrapped: Record<string, unknown> = {
content,
wordCount: wordCountOf(content),
};
await encryptRecord('writingDraftVersions', wrapped);
const now = new Date().toISOString();
await draftVersionTable.update(versionId, { ...wrapped, updatedAt: now });
// Bump the owning draft's updatedAt so ListView sorting reflects
// the edit even though nothing on the draft itself changed.
await draftTable.update(existing.draftId, { updatedAt: now });
},
/**
* Take the current content of `sourceVersionId` and copy it into a
* new version with the next versionNumber; point the draft at it.
* Used by the "Als Checkpoint speichern" button.
*/
async createCheckpointVersion(
draftId: string,
sourceVersionId: string,
opts: { isAiGenerated?: boolean; generationId?: string | null; summary?: string | null } = {}
) {
const source = await draftVersionTable.get(sourceVersionId);
if (!source) throw new Error(`Version ${sourceVersionId} not found`);
const existing = await draftVersionTable.where('draftId').equals(draftId).toArray();
const nextNumber = Math.max(0, ...existing.map((v) => v.versionNumber)) + 1;
const newVersion: LocalDraftVersion = {
id: crypto.randomUUID(),
draftId,
versionNumber: nextNumber,
content: source.content,
wordCount: source.wordCount,
generationId: opts.generationId ?? null,
isAiGenerated: opts.isAiGenerated ?? false,
parentVersionId: sourceVersionId,
summary: opts.summary ?? null,
};
const snapshot = toDraftVersion({ ...newVersion });
await encryptRecord('writingDraftVersions', newVersion);
await draftVersionTable.add(newVersion);
await draftTable.update(draftId, {
currentVersionId: newVersion.id,
updatedAt: new Date().toISOString(),
});
emitDomainEvent(
'WritingDraftVersionCreated',
'writing',
'writingDraftVersions',
newVersion.id,
{
draftId,
versionId: newVersion.id,
versionNumber: nextNumber,
isAiGenerated: newVersion.isAiGenerated,
}
);
return snapshot;
},
/**
* Restore an older version as the draft's current version. Does NOT
* destroy newer versions the version history still shows them so
* the user can re-restore. Implemented as a pointer flip, not a copy,
* because the user wants the old text back verbatim.
*/
async restoreVersion(draftId: string, versionId: string) {
const version = await draftVersionTable.get(versionId);
if (!version || version.draftId !== draftId) {
throw new Error(`Version ${versionId} does not belong to draft ${draftId}`);
}
await draftTable.update(draftId, {
currentVersionId: versionId,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WritingDraftVersionReverted', 'writing', 'writingDrafts', draftId, {
draftId,
restoredVersionId: versionId,
versionNumber: version.versionNumber,
});
},
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await draftTable.get(id);
if (!existing) throw new Error(`Draft ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const now = new Date().toISOString();
const patch: Partial<LocalDraft> = {
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 draftTable.update(id, patch);
emitDomainEvent('VisibilityChanged', 'writing', 'writingDrafts', id, {
recordId: id,
collection: 'writingDrafts',
before,
after: next,
});
},
};

View file

@ -0,0 +1,117 @@
/**
* Writing styles store mutation service for user-defined style records.
*
* Preset styles are not stored in Dexie (they live in `presets/styles.ts`)
* unless a user explicitly "favourites" a preset that writes a row with
* `source='preset'` + `presetId`. Custom styles (typed description, sample-
* trained, self-trained) are always rows.
*
* Sample-extraction (training) lives in M4.1 and calls into this store via
* `upsertExtractedPrinciples`.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { writingStyleTable } from '../collections';
import { toWritingStyle } from '../queries';
import type {
LocalWritingStyle,
StyleSource,
StyleSample,
StyleExtractedPrinciples,
} from '../types';
export interface CreateStyleInput {
name: string;
description: string;
source: StyleSource;
presetId?: string | null;
samples?: StyleSample[];
extractedPrinciples?: StyleExtractedPrinciples | null;
isSpaceDefault?: boolean;
isFavorite?: boolean;
}
export type UpdateStylePatch = Partial<
Pick<
LocalWritingStyle,
'name' | 'description' | 'samples' | 'extractedPrinciples' | 'isSpaceDefault' | 'isFavorite'
>
>;
export const stylesStore = {
async createStyle(input: CreateStyleInput) {
const newLocal: LocalWritingStyle = {
id: crypto.randomUUID(),
name: input.name,
description: input.description,
source: input.source,
presetId: input.presetId ?? null,
samples: input.samples ?? [],
extractedPrinciples: input.extractedPrinciples ?? null,
isSpaceDefault: input.isSpaceDefault ?? false,
isFavorite: input.isFavorite ?? false,
};
const snapshot = toWritingStyle({ ...newLocal });
await encryptRecord('writingStyles', newLocal);
await writingStyleTable.add(newLocal);
emitDomainEvent('WritingStyleCreated', 'writing', 'writingStyles', newLocal.id, {
styleId: newLocal.id,
source: input.source,
name: input.name,
});
return snapshot;
},
async updateStyle(id: string, patch: UpdateStylePatch) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('writingStyles', wrapped);
await writingStyleTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async upsertExtractedPrinciples(id: string, principles: StyleExtractedPrinciples) {
await stylesStore.updateStyle(id, { extractedPrinciples: principles });
emitDomainEvent('WritingStyleTrainedFromSamples', 'writing', 'writingStyles', id, {
styleId: id,
toneTraitsCount: principles.toneTraits.length,
});
},
async toggleFavorite(id: string) {
const existing = await writingStyleTable.get(id);
if (!existing) return;
await writingStyleTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async setSpaceDefault(id: string, isDefault: boolean) {
// Only one style per space can be the default; flip the others off first.
if (isDefault) {
const existing = await writingStyleTable
.filter((s) => s.isSpaceDefault && s.id !== id)
.toArray();
await Promise.all(
existing.map((s) =>
writingStyleTable.update(s.id, {
isSpaceDefault: false,
updatedAt: new Date().toISOString(),
})
)
);
}
await writingStyleTable.update(id, {
isSpaceDefault: isDefault,
updatedAt: new Date().toISOString(),
});
},
async deleteStyle(id: string) {
const now = new Date().toISOString();
await writingStyleTable.update(id, { deletedAt: now, updatedAt: now });
},
};

View file

@ -0,0 +1,265 @@
/**
* Writing module types an AI-first Ghostwriter surface for intentionally
* produced prose. A `Draft` carries briefing + references + a pointer to its
* current version; every full (re)generation writes a new immutable
* `DraftVersion`; `Generation` records capture the provider-level call so
* we can show live progress and audit which model/prompt produced what.
* `WritingStyle` lives as its own table so a style can be reused across
* drafts, trained from samples, and (later) linked from agent personas.
*
* Plan: `docs/plans/writing-module.md`.
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
// ─── Discriminators & Enums ──────────────────────────────
export type DraftKind =
| 'blog'
| 'essay'
| 'email'
| 'social'
| 'story'
| 'letter'
| 'speech'
| 'cover-letter'
| 'product-description'
| 'press-release'
| 'bio'
| 'other';
export type DraftStatus = 'draft' | 'refining' | 'complete' | 'published';
export type DraftLengthUnit = 'words' | 'chars' | 'minutes';
export type GenerationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type GenerationKind =
| 'outline'
| 'draft-from-brief'
| 'draft-from-outline'
| 'selection-rewrite'
| 'selection-shorten'
| 'selection-expand'
| 'selection-tone'
| 'selection-translate'
| 'full-regenerate';
export type GenerationProvider = 'mana-ai' | 'mana-llm' | 'local-llm';
export type StyleSource = 'preset' | 'custom-description' | 'sample-trained' | 'self-trained';
export type DraftReferenceKind =
| 'article'
| 'note'
| 'library'
| 'kontext'
| 'goal'
| 'url'
| 'me-image';
export type DraftPublishModule = 'website' | 'articles' | 'social-relay' | 'mail' | 'presi';
// ─── Sub-objects ─────────────────────────────────────────
export interface DraftBriefing {
topic: string;
audience?: string | null;
tone?: string | null;
/** ISO language code; default 'de'. */
language: string;
targetLength?: {
type: DraftLengthUnit;
value: number;
} | null;
extraInstructions?: string | null;
/** When true, the runner injects mana-research results as standing context. */
useResearch?: boolean;
}
export interface DraftStyleOverrides {
tone?: string | null;
styleNotes?: string | null;
}
export interface DraftReference {
kind: DraftReferenceKind;
/** Module-local id; present for every kind except 'url'. */
targetId?: string | null;
/** External URL; present for kind='url', optional otherwise. */
url?: string | null;
/** Free-form note about why this reference matters for the draft. */
note?: string | null;
}
export interface DraftPublishTarget {
module: DraftPublishModule;
targetId: string;
publishedAt: string;
}
export interface DraftGenerationParams {
temperature?: number | null;
maxTokens?: number | null;
}
export interface DraftSelection {
start: number;
end: number;
}
export interface DraftTokenUsage {
input: number;
output: number;
}
export interface StyleSample {
label: string;
text: string;
/** Optional pointer back to the source (e.g. 'journal:abc', 'articles:xyz'). */
sourceRef?: string | null;
}
export interface StyleExtractedPrinciples {
toneTraits: string[];
sentenceLengthAvg?: number | null;
vocabulary?: string[];
examples?: string[];
rawAnalysis?: string | null;
extractedAt: string;
}
// ─── Local Records (Dexie) ───────────────────────────────
export interface LocalDraft extends BaseRecord {
kind: DraftKind;
status: DraftStatus;
title: string;
briefing: DraftBriefing;
/** FK to writingStyles; null = ad-hoc (no saved style). */
styleId?: string | null;
styleOverrides?: DraftStyleOverrides | null;
references: DraftReference[];
/** Points at the current LocalDraftVersion.id; null until first generation. */
currentVersionId?: string | null;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
publishedTo?: DraftPublishTarget[];
isFavorite: boolean;
}
export interface LocalDraftVersion extends BaseRecord {
draftId: string;
versionNumber: number;
/** Markdown body of this version. */
content: string;
wordCount: number;
generationId?: string | null;
isAiGenerated: boolean;
parentVersionId?: string | null;
/** Short auto-summary for the version-history panel. */
summary?: string | null;
}
export interface LocalGeneration extends BaseRecord {
draftId: string;
kind: GenerationKind;
status: GenerationStatus;
prompt: string;
provider: GenerationProvider;
model?: string | null;
params?: DraftGenerationParams | null;
/** Only set for selection-* kinds. */
inputSelection?: DraftSelection | null;
output?: string | null;
outputVersionId?: string | null;
startedAt?: string | null;
completedAt?: string | null;
durationMs?: number | null;
tokenUsage?: DraftTokenUsage | null;
error?: string | null;
/** FK into a mana-ai mission when the generation ran server-side. */
missionId?: string | null;
}
export interface LocalWritingStyle extends BaseRecord {
name: string;
description: string;
source: StyleSource;
presetId?: string | null;
samples?: StyleSample[];
extractedPrinciples?: StyleExtractedPrinciples | null;
/** True when this style is the Space-wide default for team spaces. */
isSpaceDefault: boolean;
isFavorite: boolean;
}
// ─── Domain Types (plaintext, for UI) ────────────────────
export interface Draft {
id: string;
kind: DraftKind;
status: DraftStatus;
title: string;
briefing: DraftBriefing;
styleId: string | null;
styleOverrides: DraftStyleOverrides | null;
references: DraftReference[];
currentVersionId: string | null;
visibility: VisibilityLevel;
publishedTo: DraftPublishTarget[];
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
export interface DraftVersion {
id: string;
draftId: string;
versionNumber: number;
content: string;
wordCount: number;
generationId: string | null;
isAiGenerated: boolean;
parentVersionId: string | null;
summary: string | null;
createdAt: string;
}
export interface Generation {
id: string;
draftId: string;
kind: GenerationKind;
status: GenerationStatus;
prompt: string;
provider: GenerationProvider;
model: string | null;
params: DraftGenerationParams | null;
inputSelection: DraftSelection | null;
output: string | null;
outputVersionId: string | null;
startedAt: string | null;
completedAt: string | null;
durationMs: number | null;
tokenUsage: DraftTokenUsage | null;
error: string | null;
missionId: string | null;
createdAt: string;
}
export interface WritingStyle {
id: string;
name: string;
description: string;
source: StyleSource;
presetId: string | null;
samples: StyleSample[];
extractedPrinciples: StyleExtractedPrinciples | null;
isSpaceDefault: boolean;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}

View file

@ -0,0 +1,355 @@
<!--
Writing — Detail view. Three panels on desktop (collapsing to tabs on
mobile later): briefing summary, text editor, version history. In M2
the editor is a plain textarea; M6 adds selection-based refinement
tools. "Als Checkpoint speichern" freezes the current draft content
as a numbered version (otherwise typing just edits version v1 in
place).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import BriefingForm from '../components/BriefingForm.svelte';
import StatusBadge from '../components/StatusBadge.svelte';
import VersionEditor from '../components/VersionEditor.svelte';
import VersionHistory from '../components/VersionHistory.svelte';
import { draftsStore } from '../stores/drafts.svelte';
import { useDraft, useVersionsForDraft, useCurrentVersionForDraft } from '../queries';
import { KIND_LABELS, STATUS_LABELS } from '../constants';
import type { DraftStatus } from '../types';
let { id }: { id: string } = $props();
// The parent route wraps this component in `{#key id}` so each draft
// gets a fresh mount — the live queries are safe to seed from the
// initial `id` without reacting to prop changes.
/* svelte-ignore state_referenced_locally */
const draft$ = useDraft(id);
/* svelte-ignore state_referenced_locally */
const versions$ = useVersionsForDraft(id);
/* svelte-ignore state_referenced_locally */
const currentVersion$ = useCurrentVersionForDraft(id);
const draft = $derived(draft$.value);
const versions = $derived(versions$.value);
const currentVersion = $derived(currentVersion$.value);
let briefingOpen = $state(false);
let saving = $state(false);
async function setStatus(next: DraftStatus) {
if (!draft) return;
await draftsStore.setStatus(draft.id, next);
}
async function toggleFavorite() {
if (!draft) return;
await draftsStore.toggleFavorite(draft.id);
}
async function saveCheckpoint() {
if (!draft || !currentVersion || saving) return;
saving = true;
try {
await draftsStore.createCheckpointVersion(draft.id, currentVersion.id);
} finally {
saving = false;
}
}
async function remove() {
if (!draft) return;
if (!confirm(`"${draft.title}" wirklich löschen?`)) return;
await draftsStore.deleteDraft(draft.id);
goto('/writing');
}
const kind = $derived(draft ? KIND_LABELS[draft.kind] : null);
const targetWords = $derived(draft?.briefing.targetLength?.value ?? null);
const STATUS_ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published'];
</script>
{#if draft$.loading}
<p class="muted center">Lädt…</p>
{:else if !draft}
<div class="empty">
<p>Dieser Draft existiert nicht (mehr).</p>
<a href="/writing">Zurück zur Übersicht</a>
</div>
{:else}
<div class="shell">
<header class="head">
<div class="title-row">
<a href="/writing" class="back">← Alle Drafts</a>
<div class="title-block">
<div class="kind" title={kind?.de}>
<span aria-hidden="true">{kind?.emoji}</span>
{kind?.de}
</div>
<h1>{draft.title || draft.briefing.topic || 'Unbenannt'}</h1>
</div>
<div class="actions">
<button
type="button"
class="ghost"
onclick={toggleFavorite}
aria-pressed={draft.isFavorite}
title="Favorit"
>
{draft.isFavorite ? '★' : '☆'}
</button>
<button type="button" class="ghost danger" onclick={remove}>Löschen</button>
</div>
</div>
<div class="meta-row">
<StatusBadge status={draft.status} />
<div class="status-picker">
{#each STATUS_ORDER as s (s)}
{#if s !== draft.status}
<button type="button" class="tiny" onclick={() => setStatus(s)}>
{STATUS_LABELS[s].de}
</button>
{/if}
{/each}
</div>
</div>
</header>
<section class="briefing-section">
<button type="button" class="briefing-toggle" onclick={() => (briefingOpen = !briefingOpen)}>
{briefingOpen ? '▾' : '▸'} Briefing
{#if !briefingOpen}
<span class="preview">{draft.briefing.topic}</span>
{/if}
</button>
{#if briefingOpen}
<BriefingForm mode="edit" {draft} onclose={() => (briefingOpen = false)} />
{/if}
</section>
<div class="columns">
<section class="editor-column">
{#if currentVersion}
<div class="editor-head">
<div>
<strong>Version {currentVersion.versionNumber}</strong>
{#if currentVersion.isAiGenerated}
<span class="ai-tag">KI</span>
{/if}
</div>
<button
type="button"
class="checkpoint"
onclick={saveCheckpoint}
disabled={saving}
title="Aktuellen Text als neue Version einfrieren"
>
{saving ? 'Speichert…' : ' Als Checkpoint speichern'}
</button>
</div>
<VersionEditor version={currentVersion} {targetWords} />
{:else}
<p class="muted">Diese Version existiert nicht mehr.</p>
{/if}
</section>
<aside class="history-column">
<h2>Versionen</h2>
<VersionHistory
versions={versions ?? []}
currentVersionId={draft.currentVersionId}
draftId={draft.id}
/>
</aside>
</div>
</div>
{/if}
<style>
.shell {
max-width: 1100px;
margin: 0 auto;
padding: 1.25rem 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.empty {
max-width: 600px;
margin: 4rem auto;
text-align: center;
}
.empty a {
color: #0ea5e9;
}
.head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.title-block {
flex: 1;
min-width: 0;
}
.back {
font-size: 0.85rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
text-decoration: none;
padding-top: 0.5rem;
}
.back:hover {
color: #0ea5e9;
}
.kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
h1 {
margin: 0;
font-size: 1.5rem;
line-height: 1.2;
}
.actions {
display: inline-flex;
gap: 0.4rem;
}
.ghost {
padding: 0.4rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
cursor: pointer;
color: inherit;
font: inherit;
}
.ghost:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.ghost.danger:hover {
border-color: #ef4444;
color: #ef4444;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.status-picker {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tiny {
padding: 0.15rem 0.5rem;
border-radius: 0.35rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
font-size: 0.75rem;
cursor: pointer;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.tiny:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
.briefing-section {
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.02));
}
.briefing-toggle {
width: 100%;
text-align: left;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
font: inherit;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.briefing-toggle .preview {
font-weight: normal;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.columns {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1.25rem;
}
.editor-column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.editor-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.ai-tag {
font-size: 0.65rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, #a855f7 15%, transparent);
color: #a855f7;
margin-left: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.checkpoint {
padding: 0.4rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid #0ea5e9;
background: transparent;
color: #0ea5e9;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.checkpoint:hover:not(:disabled) {
background: color-mix(in srgb, #0ea5e9 10%, transparent);
}
.checkpoint:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.history-column h2 {
font-size: 0.8rem;
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-weight: 500;
}
@media (max-width: 900px) {
.columns {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,259 @@
<!--
Writing — ListView.
Grid of drafts with KindTabs + status chips + search + "+ Neu" inline-create.
Clicking a card routes to /writing/draft/[id]. The draft preview shows the
first ~160 chars of the current version so the card isn't empty for an
unstarted draft.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import KindTabs from '../components/KindTabs.svelte';
import StatusFilter from '../components/StatusFilter.svelte';
import DraftCard from '../components/DraftCard.svelte';
import BriefingForm from '../components/BriefingForm.svelte';
import {
useAllDrafts,
filterByKind,
filterByStatus,
searchDrafts,
sortByUpdated,
} from '../queries';
import { draftVersionTable } from '../collections';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { toDraftVersion } from '../queries';
import type { Draft, DraftVersion, DraftKind, DraftStatus, LocalDraftVersion } from '../types';
const drafts$ = useAllDrafts();
const drafts = $derived(drafts$.value);
// Pull every version for the drafts we show so we can look up each
// card's current version by id without a per-card live query. On the
// list page we only need the wordCount + first 160 chars, so the whole
// content is fine to read (decryption is the same either way).
const currentVersions$ = useLiveQueryWithDefault(async () => {
const ids = drafts.map((d) => d.currentVersionId).filter((id): id is string => !!id);
if (ids.length === 0) return new Map<string, DraftVersion>();
const rows = (await draftVersionTable.bulkGet(ids)).filter(
(r): r is LocalDraftVersion => !!r && !r.deletedAt
);
const decrypted = await decryptRecords('writingDraftVersions', rows);
const map = new Map<string, DraftVersion>();
for (const v of decrypted.map(toDraftVersion)) map.set(v.id, v);
return map;
}, new Map<string, DraftVersion>());
const currentVersionsById = $derived(currentVersions$.value);
let activeKind = $state<DraftKind | 'all'>('all');
let activeStatus = $state<DraftStatus | null>(null);
let searchQuery = $state('');
let showFavoritesOnly = $state(false);
let showCreate = $state(false);
const counts = $derived<Record<DraftKind, number>>({
blog: drafts.filter((d) => d.kind === 'blog').length,
essay: drafts.filter((d) => d.kind === 'essay').length,
email: drafts.filter((d) => d.kind === 'email').length,
social: drafts.filter((d) => d.kind === 'social').length,
story: drafts.filter((d) => d.kind === 'story').length,
letter: drafts.filter((d) => d.kind === 'letter').length,
speech: drafts.filter((d) => d.kind === 'speech').length,
'cover-letter': drafts.filter((d) => d.kind === 'cover-letter').length,
'product-description': drafts.filter((d) => d.kind === 'product-description').length,
'press-release': drafts.filter((d) => d.kind === 'press-release').length,
bio: drafts.filter((d) => d.kind === 'bio').length,
other: drafts.filter((d) => d.kind === 'other').length,
});
const filtered = $derived.by(() => {
let result = drafts;
if (activeKind !== 'all') result = filterByKind(result, activeKind);
if (activeStatus) result = filterByStatus(result, activeStatus);
if (showFavoritesOnly) result = result.filter((d) => d.isFavorite);
if (searchQuery.trim()) result = searchDrafts(result, searchQuery.trim());
return sortByUpdated(result);
});
function openDraft(d: Draft) {
goto(`/writing/draft/${d.id}`);
}
function presetKind(): DraftKind | undefined {
return activeKind === 'all' ? undefined : activeKind;
}
function onCreated(d: Draft) {
showCreate = false;
openDraft(d);
}
</script>
<div class="writing-shell">
<div class="controls">
<div class="search-row">
<input
type="search"
class="search"
bind:value={searchQuery}
placeholder="Nach Titel oder Thema suchen…"
/>
<button
type="button"
class="create-btn"
class:active={showCreate}
onclick={() => (showCreate = !showCreate)}
aria-expanded={showCreate}
>
{showCreate ? '× Schließen' : '+ Neuer Draft'}
</button>
</div>
<KindTabs active={activeKind} {counts} onselect={(k) => (activeKind = k)} />
<div class="filter-row">
<StatusFilter active={activeStatus} onselect={(s) => (activeStatus = s)} />
<label class="fav-toggle">
<input type="checkbox" bind:checked={showFavoritesOnly} />
<span>Nur Favoriten</span>
</label>
</div>
</div>
{#if showCreate}
<div class="inline-create">
<BriefingForm
mode="create"
initialKind={presetKind()}
onclose={() => (showCreate = false)}
oncreated={onCreated}
/>
</div>
{/if}
{#if drafts$.loading}
<p class="muted center">Lädt…</p>
{:else if filtered.length === 0}
<div class="empty">
{#if drafts.length === 0}
<h2>Noch keine Drafts</h2>
<p>
Klick auf <strong>+ Neuer Draft</strong>, brief dem Ghostwriter Thema, Stil und Länge — M3
ergänzt die Generate-Funktion. Bis dahin kannst du Drafts manuell erstellen und editieren.
</p>
{:else}
<p class="muted">Keine Drafts passen zum aktuellen Filter.</p>
{/if}
</div>
{:else}
<div class="grid">
{#each filtered as draft (draft.id)}
<DraftCard
{draft}
currentVersion={currentVersionsById.get(draft.currentVersionId ?? '') ?? null}
onopen={openDraft}
/>
{/each}
</div>
{/if}
</div>
<style>
.writing-shell {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-size: 0.9rem;
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.create-btn {
padding: 0.45rem 0.9rem;
border-radius: 0.55rem;
border: 1px solid #0ea5e9;
background: #0ea5e9;
color: white;
cursor: pointer;
font: inherit;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.create-btn:hover {
background: #0284c7;
border-color: #0284c7;
}
.create-btn.active {
background: transparent;
color: #0ea5e9;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.fav-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
cursor: pointer;
}
.search {
flex: 1;
min-width: 0;
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
.search:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.inline-create {
margin-bottom: 1.25rem;
border: 1px solid color-mix(in srgb, #0ea5e9 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #0ea5e9 4%, transparent);
}
.empty {
max-width: 540px;
margin: 3rem auto;
text-align: center;
}
.empty h2 {
margin: 0 0 0.5rem;
font-size: 1.2rem;
}
.empty p {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.9rem;
}
</style>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/writing/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Writing - Mana</title>
</svelte:head>
<RoutePage appId="writing">
<ListView />
</RoutePage>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import DetailView from '$lib/modules/writing/views/DetailView.svelte';
import { RoutePage } from '$lib/components/shell';
import { page } from '$app/state';
const id = $derived(page.params.id ?? '');
</script>
<svelte:head>
<title>Draft - Writing - Mana</title>
</svelte:head>
<RoutePage appId="writing">
{#key id}
<DetailView {id} />
{/key}
</RoutePage>

View file

@ -0,0 +1,458 @@
# Writing — Module Plan
## Status (2026-04-24)
**Planung.** Noch nichts geshipped. Nächster Schritt: M1 (Skelett).
## Ziel
Ein Modul, mit dem der Nutzer dem AI-Agenten Brief + Stil + Referenzen gibt und **fertige Texte** produziert: Blogposts, Essays, Mails, Bewerbungen, Social Posts, Reden, Storys, Produkttexte. Kernfrage: *"Ich brauche einen Text zu X im Stil Y — schreib ihn."*
**Start-Modus: Ghostwriter.** Input → fertiger Entwurf. Nutzer bewertet ganze Versionen, verfeinert Stellen gezielt mit Selection-Tools. Ein späterer **Canvas-Modus** (freies Tippen, Inline-Autocomplete, `/`-Kommandos) ist als M9 eingeplant, aber nicht Teil des Kern-Scopes.
Nicht im Scope Phase 1:
- Freies Notizen/Journalen (→ `notes` / `journal`)
- Speichern externer Artikel (→ `articles`)
- Kollaboratives Echtzeit-Editing
- Automatische Veröffentlichung (Hand-Off zu `website` / `articles` schon, aber User löst aus)
## Abgrenzung
| Modul | Unterschied |
|---|---|
| `notes` | unstrukturierte Snippets, persönlich, ohne Zweck |
| `journal` | datierte Reflexionen, persönlich |
| `articles` | **konsumierte** Artikel (Readability-Extrakt), Highlights — hier wird gelesen, nicht produziert |
| `chat` | Gespräch, nicht produzierter Text als Artefakt |
| `presi` / `website` | Konsumenten von Text — können aus Writing-Drafts gespeist werden |
| `news-research` / `mana-research` | Recherche-Provider; Writing **konsumiert** diese Quellen als Referenz |
Writing = **intentional produzierter Prosa-Text mit Zweck und Adressat**. Existiert heute nicht.
## Getroffene Entscheidungen (vorab, 2026-04-24)
1. **Ghostwriter-Modus zuerst**, Canvas später.
2. **Styles ≠ Personas**, aber verknüpfbar. Personas (`mana-persona-runner`) bleiben für Agent-Loops; Writing hat eigene `WritingStyle`-Entität. Eine Persona kann einen `defaultWritingStyleId` referenzieren — so nutzt z.B. ein "Marketing-Agent" automatisch den "Corporate Tone"-Style.
3. **Versionierung**: Jede *volle* Generierung/Regeneration → neue `LocalDraftVersion`. Selection-basierte Refinements (Shorten/Expand/Tone) modifizieren die aktuelle Version in-place mit lokalem Undo-Stack, ohne Versions-Explosion. Erst wenn der User "Diese Änderungen übernehmen als neue Version" klickt, wird eine Version geschrieben.
4. **Kind-Liste breit von Anfang an**: `blog`, `essay`, `email`, `social`, `story`, `letter`, `speech`, `cover-letter`, `product-description`, `press-release`, `bio`, `other`. Start mit vollem Set — Templates pro Kind kommen später. Das Discriminator-Feld ist billig; nachträglich einen Kind umzubenennen ist teurer.
5. **Space-Kontext als Default-Stil**: In einem Firmen-/Team-Space wird ein `spaceDefaultStyleId` unterstützt. Der Space kann "Corporate Tone" + standardmäßig verknüpfte kontextDocs als Default-Referenzen setzen. Personal-Space → kein Default, User wählt Style pro Draft.
## Modul-Struktur
```
apps/mana/apps/web/src/lib/modules/writing/
├── types.ts # LocalDraft, Draft, Kind, Status, LocalGeneration, LocalDraftVersion, LocalWritingStyle
├── collections.ts # drafts + draftVersions + generations + writingStyles Tables + Guest-Seed
├── queries.ts # useAllDrafts, useDraftsByKind, useDraft(id), useVersions(draftId), useStyles, useStats
├── stores/
│ ├── drafts.svelte.ts # createDraft, updateBriefing, deleteDraft, setVisibility, publishVersion, restoreVersion
│ ├── generations.svelte.ts # startGeneration, cancelGeneration, applyGenerationAsVersion
│ └── styles.svelte.ts # createStyle, updateStyle, trainStyleFromSamples, deleteStyle
├── components/
│ ├── BriefingForm.svelte # topic, kind, length, tone, audience, language, style-picker, reference-picker
│ ├── DraftCard.svelte # kompakter Listeneintrag (Titel + kind-Badge + Preview + Last-Updated + Visibility-Icon)
│ ├── KindTabs.svelte # Alle | Blog | Essay | E-Mail | Social | Story | Brief | Rede | ...
│ ├── StatusBadge.svelte # entwurf | in-überarbeitung | fertig | veröffentlicht
│ ├── StylePicker.svelte # Preset-Liste + Custom-Styles + "Schreibe wie ich"-Option
│ ├── ReferencePicker.svelte # cross-modul Picker (articles, notes, library, kontext, goals, URLs)
│ ├── VersionHistory.svelte # vertikale Timeline aller Versions, Diff auf Click, Revert-Button
│ ├── DiffView.svelte # seitlicher oder Inline-Diff zwischen zwei Versionen
│ ├── SelectionToolbar.svelte # erscheint bei Text-Markierung: Kürzen / Erweitern / Ton / Umschreiben / Übersetzen
│ ├── GenerationStatus.svelte # Fortschritts-UI während Generation läuft (Streaming-Preview)
│ └── ProposalInbox.svelte # Refine-Vorschläge, die auf User-Approval warten
├── views/
│ ├── ListView.svelte # Modul-Root: KindTabs + Grid of DraftCards + "+ Neuer Draft"-FAB
│ └── DetailView.svelte # Drei-Spalten-Layout (Briefing | Text | Tools)
├── tools.ts # AI-Tools (siehe AI-Integration)
├── constants.ts # KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, STYLE_PRESETS
├── presets/
│ └── styles.ts # Preset-Styles: Akademisch, LinkedIn, Hemingway, Casual-Blog, Buzzfeed-Listicle, Nachrichten, ...
├── module.config.ts # { appId: 'writing', tables: [{ name: 'drafts' }, { name: 'draftVersions' }, { name: 'generations' }, { name: 'writingStyles' }] }
└── index.ts # Re-Exports
```
## Daten-Schema
### `LocalDraft` (Dexie)
```typescript
export type DraftKind =
| 'blog' | 'essay' | 'email' | 'social' | 'story'
| 'letter' | 'speech' | 'cover-letter'
| 'product-description' | 'press-release' | 'bio' | 'other';
export type DraftStatus = 'draft' | 'refining' | 'complete' | 'published';
export interface LocalDraft extends BaseRecord {
kind: DraftKind; // plaintext — Diskriminator
status: DraftStatus; // plaintext — filterbar
title: string; // encrypted
briefing: { // encrypted — Kern-Eingabe
topic: string;
audience?: string;
tone?: string; // z.B. "sachlich", "humorvoll", "motivierend"
language: string; // ISO-Code, default 'de'
targetLength?: { // optional — default abgeleitet von kind
type: 'words' | 'chars' | 'minutes';
value: number;
};
extraInstructions?: string;
};
styleId?: string | null; // plaintext — FK auf LocalWritingStyle, null = Ad-hoc
styleOverrides?: { // encrypted — Style-Felder, die diesen Draft übersteuern
tone?: string;
styleNotes?: string;
} | null;
references: DraftReference[]; // plaintext IDs + URLs; encrypted Notes
currentVersionId?: string | null; // plaintext — zeigt auf aktive Version
visibility: VisibilityLevel; // plaintext
visibilityChangedAt?: string | null; // plaintext
visibilityChangedBy?: string | null; // plaintext (userId)
unlistedToken?: string | null; // plaintext — minted beim Flip auf 'unlisted'
publishedTo?: DraftPublishTarget[]; // plaintext — ['website:block/abc', 'articles:xyz']
isFavorite: boolean; // plaintext
}
export interface DraftReference {
kind: 'article' | 'note' | 'library' | 'kontext' | 'goal' | 'url' | 'me-image';
targetId?: string; // plaintext, module-lokal
url?: string; // plaintext
note?: string; // encrypted — was der User an dieser Quelle relevant findet
}
export type DraftPublishTarget = {
module: 'website' | 'articles' | 'social-relay' | 'mail' | 'presi';
targetId: string;
publishedAt: string; // ISO
};
```
### `LocalDraftVersion`
```typescript
export interface LocalDraftVersion extends BaseRecord {
draftId: string; // plaintext — FK
versionNumber: number; // plaintext — 1, 2, 3...
content: string; // encrypted — der Text selbst (Markdown)
wordCount: number; // plaintext
generationId?: string | null; // plaintext — falls AI-generiert
isAiGenerated: boolean; // plaintext
parentVersionId?: string | null; // plaintext — für Branching später
summary?: string | null; // encrypted — optional Auto-Summary fürs History-Panel
}
```
Selection-basierte Refinements erzeugen **keine** neue Version; sie mutieren den `content` der aktuellen Version. Ein Undo-Stack bleibt im lokalen State (nicht synced). "Als neue Version speichern" ist ein expliziter Button.
### `LocalGeneration`
```typescript
export type GenerationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type GenerationKind =
| 'outline' // Outline aus Briefing
| 'draft-from-brief' // Volltext aus Briefing (direkt)
| 'draft-from-outline' // Volltext aus Outline
| 'selection-rewrite' // Mark. Passage umschreiben
| 'selection-shorten' | 'selection-expand'
| 'selection-tone' | 'selection-translate'
| 'full-regenerate';
export interface LocalGeneration extends BaseRecord {
draftId: string; // plaintext
kind: GenerationKind; // plaintext
status: GenerationStatus; // plaintext
prompt: string; // encrypted — finaler zusammengebauter Prompt
provider: 'mana-ai' | 'mana-llm' | 'local-llm'; // plaintext
model?: string | null; // plaintext — z.B. "claude-opus-4-7"
params?: { // plaintext
temperature?: number;
maxTokens?: number;
} | null;
inputSelection?: { start: number; end: number } | null; // plaintext — nur bei selection-*
output?: string | null; // encrypted — was generiert wurde
outputVersionId?: string | null; // plaintext — FK falls als Version gespeichert
startedAt?: string | null; // plaintext
completedAt?: string | null; // plaintext
durationMs?: number | null; // plaintext
tokenUsage?: { input: number; output: number } | null; // plaintext
error?: string | null; // plaintext — User-lesbarer Fehler
missionId?: string | null; // plaintext — FK zu mana-ai mission, falls async
}
```
### `LocalWritingStyle`
```typescript
export type StyleSource = 'preset' | 'custom-description' | 'sample-trained' | 'self-trained';
export interface LocalWritingStyle extends BaseRecord {
name: string; // encrypted
description: string; // encrypted — Style-Beschreibung
source: StyleSource; // plaintext
presetId?: string | null; // plaintext — falls source='preset'
samples?: Array<{ // encrypted
label: string;
text: string;
sourceRef?: string; // z.B. 'journal:id', 'articles:id'
}>;
extractedPrinciples?: { // encrypted — cached Style-Extraktion
toneTraits: string[];
sentenceLengthAvg?: number;
vocabulary?: string[];
examples?: string[];
rawAnalysis?: string; // Freitext-Analyse
extractedAt: string;
} | null;
isSpaceDefault: boolean; // plaintext — für Space-Kontext-Default
isFavorite: boolean; // plaintext
}
```
**Self-Training** (source='self-trained'): Tool sammelt 1020 Snippets aus `journal` + `notes` + `articles` (Highlights) des Users, extrahiert Prinzipien einmalig, cached als `extractedPrinciples`. Explizite User-Aktion — keine Hintergrund-Analyse.
### Encryption-Registry
```typescript
// apps/mana/apps/web/src/lib/data/crypto/registry.ts
drafts: {
fields: ['title', 'briefing', 'styleOverrides', 'references'], // references: wegen .note
version: 1,
},
draftVersions: {
fields: ['content', 'summary'],
version: 1,
},
generations: {
fields: ['prompt', 'output'],
version: 1,
},
writingStyles: {
fields: ['name', 'description', 'samples', 'extractedPrinciples'],
version: 1,
},
```
Alles Nutzer-getippte: encrypted. IDs, Status, Counts, Timestamps, FK-Pointer: plaintext.
## Routing
```
apps/mana/apps/web/src/routes/(app)/writing/
├── +page.svelte # ListView: KindTabs + Grid
├── [kind]/+page.svelte # Deep-Link: /writing/blog, /writing/email ...
├── draft/[id]/+page.svelte # DetailView (drei-spaltig)
├── new/+page.svelte # Kurz-Briefing-Flow (1-Feld → Kind-Vorschlag → Briefing)
└── styles/+page.svelte # Styles-Verwaltung (Preset durchstöbern, eigene anlegen/trainieren)
```
## UI-Konzept
### ListView (`/writing`)
- **Top**: `KindTabs` (Alle | Blog | Essay | E-Mail | Social | Story | ...)
- **Sekundärleiste**: Status-Chips (Entwurf | In-Überarbeitung | Fertig | Veröffentlicht), Sort (Zuletzt bearbeitet | Titel | Wortzahl), Favoriten-Toggle
- **Grid**: `DraftCard` mit Titel + kind-Badge + 2-Zeilen-Preview (erste Zeilen der aktuellen Version) + Last-Updated + Visibility-Icon + Status-Badge
- **FAB "+"**: öffnet `/writing/new`
### `/writing/new` — Kurz-Briefing-Flow
Drei-Schritt-Wizard in einer Card:
1. "Was möchtest du schreiben?" — ein Textfeld. User tippt z.B. "LinkedIn Post zu meinem neuen Modul".
2. AI schlägt basierend auf Freitext vor: `kind='social'`, Länge=200 Wörter, Ton=professional-excited. User kann adjusten.
3. "Generate" → erstellt Draft, leitet zu DetailView weiter, startet erste Generation.
Alternativ "Ohne Vorschlag anlegen" → leeres Briefing-Form.
### DetailView (`/writing/draft/[id]`)
**Drei Spalten** (responsiv: auf Mobil als Tabs):
**Links — Briefing-Panel** (collapsible):
- `BriefingForm` mit Topic, Kind, Audience, Tone, Language, TargetLength, ExtraInstructions
- `StylePicker` — Preset, Custom, oder "Schreibe wie ich"
- `ReferencePicker` — Cross-Modul-Picker: articles, notes, library, kontext, goals, URLs
- "Generate" / "Regenerate" Button — triggert volle Generation → neue Version
- Visibility-Picker (`<VisibilityPicker>` aus shared-privacy)
**Mitte — Text**:
- Editierbarer Textbereich (Markdown, WYSIWYG-Toggle)
- Bei Selektion: `SelectionToolbar` erscheint → Kürzen / Erweitern / Ton / Umschreiben / Übersetzen
- Top-Bar: aktuelle Version, Wortzahl, Sprache, "Als neue Version speichern"-Button
- Live-Streaming während aktiver Generation (Overlay mit Streaming-Preview)
**Rechts — Tools & Context**:
- `VersionHistory` — Timeline aller Versions, Click → Diff, Revert
- Referenzen-Liste (aus Briefing) mit "Öffnen"-Link
- `ProposalInbox` — wartende Refine-Vorschläge (falls `propose`-Policy)
- "Veröffentlichen als..." → Dropdown: Website, Artikel, E-Mail, PDF-Export, Zwischenablage
### Styles-Verwaltung (`/writing/styles`)
- Grid: Preset-Styles + Custom-Styles
- Button "Eigenen Style trainieren" — öffnet Dialog:
- Option A: Style-Beschreibung eintippen ("akademisch, prägnant, aktiv formuliert")
- Option B: Textproben hochladen/aus bestehenden Drafts/Notes importieren → One-Shot-Extraction
- Option C: "Schreibe wie ich" — zieht Samples aus journal/notes/articles, extrahiert Prinzipien
- Pro Style: Preview-Box "So klingt's: [Beispiel-Absatz über Dummy-Topic]" — lazy generiert auf Klick
## Style-System — Details
### Preset-Library (`presets/styles.ts`)
Erste Tranche:
- **Akademisch** — dicht, passive Voice erlaubt, Zitate, Konjunktiv
- **Casual Blog** — du-Ansprache, kurze Absätze, rhetorische Fragen
- **LinkedIn-Post** — Hook in Zeile 1, 1-Satz-Absätze, Emoji sparsam, Call-to-action am Ende
- **Twitter/X-Thread** — nummerierte Tweets, je ≤280 Chars, Cliffhanger
- **Hemingway** — deklarativ, kurze Sätze, minimal Adjektive
- **Nachrichtlich** — inverted pyramid, nüchtern, keine Meinung
- **Buzzfeed-Listicle** — Listenformat, überspitzte Einleitungen
- **Pitch / Sales** — Problem → Agitation → Solution-Struktur
- **Memoir** — 1. Person, sensorisch, Szenen statt Zusammenfassungen
### Space-Default-Style
- Personal-Spaces: kein Default; User wählt pro Draft (oder "Schreibe wie ich" ist Default nach erstem Self-Training).
- Team/Firmen-Spaces: `spaceDefaultStyleId` im `Space`-Record (Erweiterung in `spaces-foundation`). Ein Space-Admin kann einen Style als `isSpaceDefault=true` markieren.
- Vererbung: Briefing.styleId → Space-Default-Style → Kein Style (AI wählt generisch).
### Persona-Linkage
`mana-persona-runner` Personas bekommen ein optionales `defaultWritingStyleId`. Wenn eine Persona einen Writing-Draft erzeugt (via MCP `create_draft`-Tool), wird ihr Default-Style vorausgewählt. Personas und Styles bleiben getrennte Entitäten — die Linkage ist lose.
## AI-Integration
### Tools (`tools.ts` + `@mana/shared-ai`)
| Tool | Policy | Beschreibung |
|---|---|---|
| `list_drafts` | auto | Liefert Drafts gefiltert nach `kind`/`status`, read-only |
| `get_draft` | auto | Voller Draft inkl. aktueller Version |
| `create_draft` | propose | Legt neuen Draft mit Briefing an (ohne Generation) |
| `generate_draft_content` | propose | Startet Generation auf existierendem Draft → schreibt neue Version |
| `generate_outline` | propose | Generiert Outline aus Briefing, als "Outline"-Section vor Volltext |
| `refine_selection` | propose | Mark. Passage umschreiben mit Instruction |
| `shorten_draft` | propose | Verkürzen auf Ziel-Wortzahl |
| `expand_draft` | propose | Ausweiten auf Ziel-Wortzahl |
| `change_tone` | propose | Ton wechseln |
| `translate_draft` | propose | In andere Sprache übersetzen — erstellt neuen Draft mit `language` und Link auf Original |
| `publish_draft` | propose | Nach website/articles/... veröffentlichen |
| `list_writing_styles` | auto | Alle verfügbaren Styles (Preset + Custom) |
| `train_style_from_samples` | propose | Neuen Custom-Style aus Sample-Set extrahieren |
Alle `propose`-Tools landen in `ProposalInbox` mit Preview (Diff gegen aktuellen Content bei Refine-Tools).
### Provider-Wahl (Runtime)
| Fall | Provider |
|---|---|
| Kurztext (≤300 Wörter), synchron gewünscht | `mana-llm` direkt (oder `local-llm` als Fallback) |
| Langtext (>300 Wörter) | Mission über `mana-ai` — streamt zurück, versions-fähig |
| Offline / Privacy-max | `local-llm` (Gemma 4 E2B via WebGPU) — Qualität eingeschränkt |
| Mit Recherche-Flag | Mission über `mana-ai` mit pre-planning web-research-Injection (analog zu `news-research`-Keywords) |
Die Entscheidung passiert im `generations.svelte.ts`-Store, nicht im Tool. Tools sind Provider-agnostisch.
### Mission-Flow für Langtext
1. `generate_draft_content` erstellt `LocalGeneration` mit `status='queued'`, provider=`mana-ai`
2. Store startet Mission über `mana-ai` mit Context: Briefing + Style (inkl. `extractedPrinciples`) + Referenzen (aufgelöst zu voll­text wo möglich) + Space-Kontext-Docs falls vorhanden
3. Mission-Runner kettet intern bis zu 5 Planner-Calls:
- Research (optional, falls Referenzen URLs enthalten ohne Inhalt)
- Outline (falls `generate_outline` separat gecalled oder automatisch bei langen Texten)
- Volltext-Generation
- Selbst-Review (optional — Qualitätscheck)
- Final Polish
4. Streaming-Output landet via Sync-Channel im Client-Store → UI zeigt live
5. Bei `status='succeeded'`: `applyGenerationAsVersion(generationId)` schreibt neue `LocalDraftVersion`, setzt `currentVersionId`
### Recherche-Integration
- Flag `briefing.useResearch: boolean` (im UI "Mit Web-Recherche schreiben")
- Wenn gesetzt, injectet mana-ai bei Mission-Start `mana-research` pre-planning (existing Code aus `news-research`)
- Gefundene Quellen werden automatisch als `DraftReference[]` mit `kind='url'` an den Draft gehängt
- Inline-Zitate optional als M7-Feature (Markdown-Footnotes)
## Cross-Modul Integration
### Als Konsument
| Modul | Integration |
|---|---|
| `articles` | Als Referenz pickbar; Content fließt in Prompt |
| `notes` | Als Referenz pickbar |
| `library` | Entries als Referenz ("schreibe über Film X") |
| `kontext` | Kontext-Docs als Standing-Context, Space-Default-Referenzen |
| `goals` | Als Motivation-Anker ("Ziel-Update-Post") |
| `me-images` | Für Ghost-Writer mit Foto: picture-Generation eines Headers vor-/nach-geschaltet |
| `mana-research` | Bei `useResearch=true` automatisch |
### Als Produzent
| Ziel-Modul | Publish-Hook |
|---|---|
| `website` | Draft → neuer Text-Block in ausgewählter Page |
| `articles` | Als "Eigen-Artikel" speichern (mit Autor=Self) |
| `social-relay` | Zu Social-Plattformen senden (falls Modul aktiv) |
| `mail` | Als E-Mail-Entwurf übergeben |
| `presi` | Als Präsi-Outline-Import |
| Export | Markdown-Download, PDF, Zwischenablage |
Publish-Targets werden in `draft.publishedTo[]` gespeichert → User sieht "Wurde veröffentlicht als: ..." im DetailView.
## Events (Domain-Events)
Für Workbench-Timeline + Audit:
- `WritingDraftCreated`
- `WritingDraftBriefingUpdated`
- `WritingDraftGenerationStarted` (für live-Tracking)
- `WritingDraftVersionCreated`
- `WritingDraftVersionReverted`
- `WritingDraftPublished`
- `WritingDraftVisibilityChanged`
- `WritingStyleCreated`
- `WritingStyleTrainedFromSamples`
## Registrierung (Checklist)
1. `module.config.ts` anlegen mit `tables: [drafts, draftVersions, generations, writingStyles]`
2. Config in `apps/mana/apps/web/src/lib/data/module-registry.ts` aufnehmen
3. Dexie-Schema-Migration: neue Version mit vier Tables
4. Encryption-Registry: vier Einträge
5. Routes unter `(app)/writing/` anlegen
6. App-Eintrag in `packages/shared-branding/src/mana-apps.ts`:
```typescript
{ id: 'writing', name: 'Writing', description: {...}, icon: APP_ICONS.writing, color: '#0ea5e9', status: 'development', requiredTier: 'beta' }
```
7. Icon in `packages/shared-branding/src/app-icons.ts`
8. `docs/MODULE_REGISTRY.md` ergänzen
9. Guest-Seed in `collections.ts` (1 Draft + 1 leerer Custom-Style)
10. Vitest für Mutationen + Encryption-Roundtrip + Version-Logik
## Offene Fragen
- **Outline-Mandatory?** Für Blog/Essay ist eine Outline fast immer sinnvoll; für Social/Bio/E-Mail-Kurz nicht. **Vorschlag:** `AUTO_OUTLINE_KINDS = ['blog', 'essay', 'speech', 'cover-letter', 'story']` — bei denen startet die Mission mit Outline-Schritt automatisch. User kann im Briefing überschreiben.
- **Image-Integration mit `picture`:** Soll ein Draft optional einen Header/Cover-Image haben, generiert via picture? **Vorschlag:** erst M9+. Zunächst nur `coverImageId` als optionales Feld reservieren (plaintext FK) — UI kommt später.
- **Kollaboratives Editing:** Mehrere User im gleichen Space editieren denselben Draft. Sync-Layer ist LWW → letzte Änderung gewinnt. Das reicht für den Anfang. Realtime-CRDT ist kein Phase-1-Thema.
- **Auto-Title:** Soll der Title aus dem Topic automatisch gesetzt werden oder beim ersten Generate aus dem generierten Text extrahiert? **Vorschlag:** Topic = initialer Titel; beim ersten Draft-Version-Create bietet die UI "Titel vom AI vorschlagen lassen" an.
- **Re-Generate-Semantik:** Ersetzt eine volle Re-Generation die vorherige Version oder fügt neue hinzu? Wir haben entschieden "neue Version immer" — das kann aber bei 10 Iterationen unübersichtlich werden. **Vorschlag:** History-Panel zeigt nur `isAiGenerated=true`-Versions mit Label "Generation N"; "Zwischenstände" (Selection-Apply) bleiben im lokalen Undo-Stack ohne Version-Record.
- **Token-Limits bei großen Referenzen:** Lange Artikel als Referenz → Prompt-Explosion. **Vorschlag:** Im Mission-Runner automatischen Reference-Summarizer davorschalten (schon für `articles` da? prüfen). Falls nicht, als Sub-Task in M7.
- **Veröffentlichte Drafts readonly?** Nach `publish_draft` sollte der Draft vor versehentlichem Editieren geschützt sein. **Vorschlag:** Status `published` → UI rendert text readonly mit "Editieren erlauben"-Toggle; Publish-Targets zeigen Sync-Status.
## Reihenfolge (Milestones)
1. **M1 — Skelett**: types, collections, module.config, Registrierung, Dexie-Migration (v N+1), leere Routes, leeres ListView, kein UI. *Ziel: App zeigt "Writing"-Modul-Kachel an, Route lädt leer, nichts crasht.*
2. **M2 — Draft-CRUD manuell**: `createDraft`, `BriefingForm`, `DraftCard`, `KindTabs`, `DetailView` mit manuell editierbarem Text (ohne AI). Alle 12 Kinds als Chips. *Ziel: User kann Drafts anlegen und tippen — wie ein eingebauter Texteditor.*
3. **M3 — Generation v1 (Sync-LLM)**: `generate_draft_content` über `mana-llm` direkt, ohne Mission-Runner. Schreibt neue `LocalDraftVersion`. Versions-History-Panel. *Ziel: "Generate"-Button produziert ersten Draft-Text aus Briefing für Kurztexte.*
4. **M4 — Stil-System (Presets + Custom)**: `LocalWritingStyle`-Table, 9 Presets, `/writing/styles` View, `StylePicker` in Briefing, Style fließt in Prompt ein. *Ziel: User wählt "LinkedIn-Post"-Preset und Output ändert sich sichtbar.*
5. **M4.1 — "Schreibe wie ich" (Self-Training)**: `train_style_from_samples` mit Auto-Pull aus `journal` + `notes` + `articles`. Extrahierte Prinzipien gecached. *Ziel: Ein "Self"-Style, der User's Schreibstil imitiert.*
6. **M5 — Cross-Modul-Referenzen**: `ReferencePicker`, Auflösung in Prompt-Context mit Summarizer bei Langtext. *Ziel: "Schreibe Blog über Buch X (aus library) und Artikel Y (aus articles)".*
7. **M6 — Selection-Refinement-Tools**: `SelectionToolbar`, `refine_selection` / `shorten` / `expand` / `change_tone` als Selection-Operations mit Diff-Preview. Undo-Stack lokal. *Ziel: User markiert Absatz, klickt "Kürzer" → 3 Optionen als Proposal, User picked.*
8. **M7 — Mission-Runner für Langtext + Recherche**: Flip auf `mana-ai`-Missions für lange Drafts, `useResearch`-Flag, Outline-Stage, Streaming-Preview. *Ziel: Essay >1500 Wörter mit Outline→Draft→Review in einer Mission.*
9. **M8 — AI-Tool-Katalog + MCP-Exposure**: Alle Tools in `@mana/shared-ai/src/tools/schemas.ts`, in `mana-mcp` exposed, `AiProposalInbox` im DetailView. Persona-Linkage (`defaultWritingStyleId`). *Ziel: Personas können Drafts erzeugen, Claude Desktop hat Writing-Tools.*
10. **M9 — Canvas-Modus** (optional, Phase 2): Inline-Autocomplete am Cursor, `/`-Command-Palette wie Notion AI. Gleiche Draft-Datenstruktur, alternative UX. *Ziel: User tippt im leeren Canvas, AI ergänzt kontinuierlich.*
11. **M10 — Publish-Hooks**: Integration mit `website`, `articles`, `presi`, `social-relay`. Markdown/PDF-Export. *Ziel: Ein Draft kann als Block auf Website gepublisht werden mit einem Klick.*
12. **M11 — Visibility-System adoptieren**: `<VisibilityPicker>` in DetailView, Unlisted-Share-Link, Embed-Support auf Website. *Ziel: Writing konform mit Visibility-M1+-Standard.*
M1M3 sind "Grundfunktion steht". Ab M4 wird's differenzierend. M7 macht es gegenüber ChatGPT einzigartig (Space-Kontext + Cross-Modul-Refs + Mission-Chaining). M9 ist "nice-to-have, wenn Ghostwriter-Flow sich als zu starr erweist".

View file

@ -244,6 +244,13 @@ export const APP_ICONS = {
// and news-research (cyan) in the Wissen & Recherche row.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#f59e0b"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><path d="M28 22h30l18 18v38a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M58 22v14a4 4 0 0 0 4 4h14" fill="none" stroke="#f97316" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="48" width="26" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><rect x="32" y="56" width="22" height="3" rx="1.5" fill="#f97316" fill-opacity="0.45"/><rect x="32" y="64" width="24" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><path d="M62 54v22l8-6 8 6V54a4 4 0 0 0-4-4h-8a4 4 0 0 0-4 4z" fill="#f97316"/></svg>`
),
writing: svgToDataUrl(
// Fountain-pen nib writing on a lined sheet — the "Ghostwriter"
// theme. Sky→cyan gradient sits next to chat (sky) and storage
// (blue) without clashing, while standing apart from articles
// (orange) and library (purple) in the text/media family.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0ea5e9"/><stop offset="100%" style="stop-color:#06b6d4"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wr)"/><rect x="20" y="22" width="44" height="56" rx="4" fill="white" fill-opacity="0.95"/><line x1="26" y1="34" x2="56" y2="34" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.5" stroke-linecap="round"/><line x1="26" y1="42" x2="52" y2="42" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.35" stroke-linecap="round"/><line x1="26" y1="50" x2="56" y2="50" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.35" stroke-linecap="round"/><line x1="26" y1="58" x2="48" y2="58" stroke="#0ea5e9" stroke-width="2" stroke-opacity="0.35" stroke-linecap="round"/><path d="M62 72l14-14 8 8-14 14-10 2 2-10z" fill="white"/><path d="M74 60l8 8" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/><path d="M60 74l4 4" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/></svg>`
),
invoices: svgToDataUrl(
// Document with a QR-code corner (CH QR-Bill) + a diagonal amount line.
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.

View file

@ -1054,6 +1054,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'writing',
name: 'Writing',
description: {
de: 'KI-Ghostwriter für Texte',
en: 'AI ghostwriter for prose',
},
longDescription: {
de: 'Brief dem KI-Agenten Thema, Stil und Referenzen — er schreibt den Text. Blog, Essay, E-Mail, Bewerbung, Social Post, Rede, Story und mehr. Mit versionierten Entwürfen und Selection-Refinements.',
en: 'Brief the AI agent with topic, style and references — it writes the text. Blog posts, essays, emails, cover letters, social posts, speeches, stories and more. Versioned drafts with selection-based refinements.',
},
icon: APP_ICONS.writing,
color: '#0ea5e9',
comingSoon: false,
status: 'development',
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
},
{
id: 'broadcast',
name: 'Broadcasts',