From 1c82a374fec10b02b706c0eb7b2890c36a14d333 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 15:20:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(writing):=20M4=20=E2=80=94=20style=20syste?= =?UTF-8?q?m=20with=20presets=20+=20custom=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Styles are selectable in the briefing and flow into the prompt builder so "LinkedIn-Post" and "Akademisch" produce visibly different drafts from the same brief. - StylePicker.svelte: dropdown in BriefingForm grouped into Vorlagen (9 presets from presets/styles.ts) and Meine Stile (user custom rows). Emits an opaque id — `preset:` for presets or a uuid for customs — so selecting a preset requires no Dexie write. - generations store: loadStyle() now resolves both prefix shapes. The prompt builder already honoured both preset.principles and row.extractedPrinciples, so no prompt changes needed. - /writing/styles view: grid of presets (read-only dashed cards) plus a user section with create / edit / delete for custom styles. - StyleForm.svelte: M4 supports source='custom-description' (name + freeform prose the LLM reads verbatim). Sample-trained and self-trained sources come in M4.1. - DetailView surfaces the active style as a 🎨-chip next to the briefing preview; ListView gets a "🎨 Stile" link to the management route. Styles are optional — existing drafts with styleId=null keep their previous behaviour, and the LinkedIn/Hemingway/etc. presets are a zero- friction on-ramp before users bother writing a custom one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../writing/components/BriefingForm.svelte | 12 + .../writing/components/StyleForm.svelte | 176 +++++++++++ .../writing/components/StylePicker.svelte | 68 +++++ .../writing/stores/generations.svelte.ts | 33 ++- .../modules/writing/views/DetailView.svelte | 30 ++ .../lib/modules/writing/views/ListView.svelte | 16 + .../modules/writing/views/StylesView.svelte | 279 ++++++++++++++++++ .../routes/(app)/writing/styles/+page.svelte | 12 + 8 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/writing/components/StyleForm.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/writing/components/StylePicker.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/writing/views/StylesView.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/writing/styles/+page.svelte diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/BriefingForm.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/BriefingForm.svelte index 5da6fce58..5ebb07f7b 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/components/BriefingForm.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/components/BriefingForm.svelte @@ -7,6 +7,7 @@ + +
+ + + + + {#if error} +

{error}

+ {/if} + +
+ + +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/StylePicker.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/StylePicker.svelte new file mode 100644 index 000000000..1e48d59aa --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/StylePicker.svelte @@ -0,0 +1,68 @@ + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts b/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts index ff0e26e7f..a92784abc 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts @@ -20,7 +20,7 @@ import { emitDomainEvent } from '$lib/data/events'; import { generationTable, draftTable, draftVersionTable, writingStyleTable } from '../collections'; import { callWritingGeneration } from '../api'; import { buildDraftPrompt, estimateMaxTokens } from '../utils/prompt-builder'; -import { getStylePreset } from '../presets/styles'; +import { getStylePreset, type StylePreset } from '../presets/styles'; import type { LocalDraftVersion, LocalGeneration, @@ -37,10 +37,25 @@ function wordCountOf(text: string): number { return trimmed.split(/\s+/).length; } -async function loadStyle(styleId: string | null | undefined): Promise { +/** + * Resolve the `draft.styleId` reference. A draft can point at either a + * preset (serialised as `preset:`, no Dexie row needed) or a custom + * WritingStyle row (uuid). Presets are static code, so no DB write is + * required for first-time selection — the picker just sets the id. + */ +async function loadStyle( + styleId: string | null | undefined +): Promise< + { source: 'preset'; preset: StylePreset } | { source: 'custom'; row: LocalWritingStyle } | null +> { if (!styleId) return null; + if (styleId.startsWith('preset:')) { + const preset = getStylePreset(styleId.slice('preset:'.length)); + return preset ? { source: 'preset', preset } : null; + } const row = await writingStyleTable.get(styleId); - return row && !row.deletedAt ? row : null; + if (!row || row.deletedAt) return null; + return { source: 'custom', row }; } async function nextVersionNumber(draftId: string): Promise { @@ -76,16 +91,22 @@ export const generationsStore = { (await draftVersionTable.get(draft.currentVersionId))?.content?.trim() ? 'full-regenerate' : 'draft-from-brief'; - const style = await loadStyle(draft.styleId); + const resolved = await loadStyle(draft.styleId); const stylePreset = - style?.source === 'preset' && style.presetId ? getStylePreset(style.presetId) : undefined; + resolved?.source === 'preset' + ? resolved.preset + : resolved?.source === 'custom' && resolved.row.presetId + ? getStylePreset(resolved.row.presetId) + : undefined; + const styleExtracted = + resolved?.source === 'custom' ? (resolved.row.extractedPrinciples ?? undefined) : undefined; const { system, user } = buildDraftPrompt({ kind: draft.kind, title: draft.title, briefing: draft.briefing, stylePreset, - styleExtracted: style?.extractedPrinciples ?? undefined, + styleExtracted, }); const maxTokens = opts.maxTokens ?? estimateMaxTokens(draft.briefing); diff --git a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte index 595b7fd91..160c654e7 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte @@ -20,8 +20,10 @@ useVersionsForDraft, useCurrentVersionForDraft, useGenerationsForDraft, + useAllStyles, } from '../queries'; import { KIND_LABELS, STATUS_LABELS } from '../constants'; + import { getStylePreset } from '../presets/styles'; import type { DraftStatus } from '../types'; let { id }: { id: string } = $props(); @@ -109,6 +111,22 @@ 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']; + + // Resolve the active style's display name: preset ids are static + // code; custom ids are looked up in the reactive styles list. Falls + // back to null when the draft has no style set (ad-hoc). + const allStyles$ = useAllStyles(); + const allStyles = $derived(allStyles$.value); + const activeStyleName = $derived.by(() => { + const sid = draft?.styleId; + if (!sid) return null; + if (sid.startsWith('preset:')) { + const preset = getStylePreset(sid.slice('preset:'.length)); + return preset ? preset.name.de : null; + } + const custom = allStyles.find((s) => s.id === sid); + return custom ? custom.name : null; + }); {#if draft$.loading} @@ -163,6 +181,9 @@ {briefingOpen ? '▾' : '▸'} Briefing {#if !briefingOpen} {draft.briefing.topic} + {#if activeStyleName} + 🎨 {activeStyleName} + {/if} {/if} {#if briefingOpen} @@ -367,6 +388,15 @@ min-width: 0; flex: 1; } + .style-chip { + font-size: 0.75rem; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, #0ea5e9 10%, transparent); + color: #0ea5e9; + font-weight: normal; + flex-shrink: 0; + } .columns { display: grid; grid-template-columns: 1fr 280px; diff --git a/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte index e0777a69b..20adfee7c 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/views/ListView.svelte @@ -97,6 +97,7 @@ bind:value={searchQuery} placeholder="Nach Titel oder Thema suchen…" /> + 🎨 Stile + + + {#if createOpen} +
+ (createOpen = false)} /> +
+ {/if} + +
+

Vorlagen

+

+ Eingebaute Stile — direkt im Briefing auswählbar. Nicht bearbeitbar; für Anpassungen lege + einen eigenen Stil an. +

+
+ {#each STYLE_PRESETS as preset (preset.id)} +
+
+ {preset.name.de} + Vorlage +
+

{preset.description.de}

+ {#if preset.principles.toneTraits.length} +
    + {#each preset.principles.toneTraits as trait (trait)} +
  • {trait}
  • + {/each} +
+ {/if} +
+ {/each} +
+
+ +
+

Meine Stile

+ {#if styles$.loading} +

Lädt…

+ {:else if customStyles.length === 0} +

+ Keine eigenen Stile. Klick oben auf + Eigener Stil, um einen anzulegen — + z.B. "Mein Corporate-Ton" oder "Persönliche Blog-Stimme". +

+ {:else} +
+ {#each customStyles as style (style.id)} +
+
+ {style.name} + {STYLE_SOURCE_LABELS[style.source].de} +
+ {#if editingId === style.id} + (editingId = null)} /> + {:else} +

{style.description}

+ {#if style.extractedPrinciples?.toneTraits.length} +
    + {#each style.extractedPrinciples.toneTraits as trait (trait)} +
  • {trait}
  • + {/each} +
+ {/if} +
+ + +
+ {/if} +
+ {/each} +
+ {/if} +
+ + + diff --git a/apps/mana/apps/web/src/routes/(app)/writing/styles/+page.svelte b/apps/mana/apps/web/src/routes/(app)/writing/styles/+page.svelte new file mode 100644 index 000000000..ff9272c33 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/writing/styles/+page.svelte @@ -0,0 +1,12 @@ + + + + Stile - Writing - Mana + + + + +