feat(writing): M4 — style system with presets + custom styles

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:<id>` 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 15:20:06 +02:00
parent d725a8df8b
commit 1c82a374fe
8 changed files with 620 additions and 6 deletions

View file

@ -7,6 +7,7 @@
<script lang="ts"> <script lang="ts">
import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants'; import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
import { draftsStore } from '../stores/drafts.svelte'; import { draftsStore } from '../stores/drafts.svelte';
import StylePicker from './StylePicker.svelte';
import type { Draft, DraftKind, DraftBriefing } from '../types'; import type { Draft, DraftKind, DraftBriefing } from '../types';
let { let {
@ -59,6 +60,8 @@
); );
/* svelte-ignore state_referenced_locally */ /* svelte-ignore state_referenced_locally */
let extraInstructions = $state(draft?.briefing.extraInstructions ?? ''); let extraInstructions = $state(draft?.briefing.extraInstructions ?? '');
/* svelte-ignore state_referenced_locally */
let styleId = $state<string | null>(draft?.styleId ?? null);
let saving = $state(false); let saving = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -96,12 +99,14 @@
kind, kind,
title: title.trim(), title: title.trim(),
briefing, briefing,
styleId,
}); });
oncreated?.(created); oncreated?.(created);
} else if (draft) { } else if (draft) {
await draftsStore.updateDraft(draft.id, { await draftsStore.updateDraft(draft.id, {
title: title.trim(), title: title.trim(),
kind, kind,
styleId,
}); });
await draftsStore.updateBriefing(draft.id, briefing); await draftsStore.updateBriefing(draft.id, briefing);
} }
@ -177,6 +182,13 @@
</label> </label>
</div> </div>
<label>
<span>
Stil <small>(optional — prägt Ton & Struktur der Generation)</small>
</span>
<StylePicker value={styleId} onchange={(next) => (styleId = next)} />
</label>
<label> <label>
<span>Zusatzhinweise <small>(optional)</small></span> <span>Zusatzhinweise <small>(optional)</small></span>
<textarea <textarea

View file

@ -0,0 +1,176 @@
<!--
StyleForm — create / edit a custom WritingStyle row.
M4 supports source='custom-description': name + freeform style prose.
M4.1 will add source='sample-trained' (sample collection + extraction)
and source='self-trained' (auto-pull from journal/notes/articles) via
separate flows; those don't belong in this form.
-->
<script lang="ts">
import { stylesStore } from '../stores/styles.svelte';
import type { WritingStyle } from '../types';
let {
mode,
style,
onclose,
}: {
mode: 'create' | 'edit';
style?: WritingStyle;
onclose: () => void;
} = $props();
/* svelte-ignore state_referenced_locally */
let name = $state(style?.name ?? '');
/* svelte-ignore state_referenced_locally */
let description = $state(style?.description ?? '');
let saving = $state(false);
let error = $state<string | null>(null);
const isValid = $derived(name.trim().length > 0 && description.trim().length > 0);
async function submit(ev: Event) {
ev.preventDefault();
if (!isValid || saving) return;
saving = true;
error = null;
try {
if (mode === 'create') {
await stylesStore.createStyle({
name: name.trim(),
description: description.trim(),
source: 'custom-description',
});
} else if (style) {
await stylesStore.updateStyle(style.id, {
name: name.trim(),
description: description.trim(),
});
}
onclose();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
</script>
<form class="style-form" onsubmit={submit}>
<label>
<span>Name</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={name} placeholder="Mein Corporate-Ton" required autofocus />
</label>
<label>
<span>
Beschreibung
<small>(die KI liest das wörtlich — sei konkret)</small>
</span>
<textarea
bind:value={description}
rows="5"
placeholder={'z.B. "Kurze Sätze, aktive Formulierungen, keine Buzzwords. Erste-Person-Singular, du-Ansprache. Max. 3 Sätze pro Absatz. Jeder Abschnitt endet mit einer konkreten nächsten Aktion."'}
required
></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'}
Stil anlegen
{:else}
Speichern
{/if}
</button>
</div>
</form>
<style>
.style-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1rem 1.25rem;
}
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,
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: 5rem;
}
input:focus,
textarea:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.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;
}
</style>

View file

@ -0,0 +1,68 @@
<!--
StylePicker — dropdown for selecting a writing style in the briefing.
The picker emits an opaque id in one of three shapes:
- null → ad-hoc / no style
- `preset:<id>` → reference to a built-in preset (no Dexie row)
- <uuid> → reference to a custom LocalWritingStyle row
The generations store resolves both prefixes transparently, so picking
a preset does NOT require writing to Dexie. Users favourite / customise
via the /writing/styles view.
-->
<script lang="ts">
import { STYLE_PRESETS } from '../presets/styles';
import { useAllStyles } from '../queries';
let {
value,
onchange,
}: {
value: string | null;
onchange: (next: string | null) => void;
} = $props();
const customStyles$ = useAllStyles();
const customStyles = $derived(customStyles$.value);
function handle(ev: Event) {
const target = ev.target as HTMLSelectElement;
const raw = target.value;
onchange(raw === '' ? null : raw);
}
</script>
<select class="style-picker" value={value ?? ''} onchange={handle}>
<option value="">— Kein Stil —</option>
<optgroup label="Vorlagen">
{#each STYLE_PRESETS as preset (preset.id)}
<option value={`preset:${preset.id}`}>{preset.name.de}</option>
{/each}
</optgroup>
{#if customStyles.length > 0}
<optgroup label="Meine Stile">
{#each customStyles as style (style.id)}
<option value={style.id}>{style.name}</option>
{/each}
</optgroup>
{/if}
</select>
<style>
.style-picker {
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;
width: 100%;
}
.style-picker:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
</style>

View file

@ -20,7 +20,7 @@ import { emitDomainEvent } from '$lib/data/events';
import { generationTable, draftTable, draftVersionTable, writingStyleTable } from '../collections'; import { generationTable, draftTable, draftVersionTable, writingStyleTable } from '../collections';
import { callWritingGeneration } from '../api'; import { callWritingGeneration } from '../api';
import { buildDraftPrompt, estimateMaxTokens } from '../utils/prompt-builder'; import { buildDraftPrompt, estimateMaxTokens } from '../utils/prompt-builder';
import { getStylePreset } from '../presets/styles'; import { getStylePreset, type StylePreset } from '../presets/styles';
import type { import type {
LocalDraftVersion, LocalDraftVersion,
LocalGeneration, LocalGeneration,
@ -37,10 +37,25 @@ function wordCountOf(text: string): number {
return trimmed.split(/\s+/).length; return trimmed.split(/\s+/).length;
} }
async function loadStyle(styleId: string | null | undefined): Promise<LocalWritingStyle | null> { /**
* Resolve the `draft.styleId` reference. A draft can point at either a
* preset (serialised as `preset:<id>`, 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) 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); 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<number> { async function nextVersionNumber(draftId: string): Promise<number> {
@ -76,16 +91,22 @@ export const generationsStore = {
(await draftVersionTable.get(draft.currentVersionId))?.content?.trim() (await draftVersionTable.get(draft.currentVersionId))?.content?.trim()
? 'full-regenerate' ? 'full-regenerate'
: 'draft-from-brief'; : 'draft-from-brief';
const style = await loadStyle(draft.styleId); const resolved = await loadStyle(draft.styleId);
const stylePreset = 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({ const { system, user } = buildDraftPrompt({
kind: draft.kind, kind: draft.kind,
title: draft.title, title: draft.title,
briefing: draft.briefing, briefing: draft.briefing,
stylePreset, stylePreset,
styleExtracted: style?.extractedPrinciples ?? undefined, styleExtracted,
}); });
const maxTokens = opts.maxTokens ?? estimateMaxTokens(draft.briefing); const maxTokens = opts.maxTokens ?? estimateMaxTokens(draft.briefing);

View file

@ -20,8 +20,10 @@
useVersionsForDraft, useVersionsForDraft,
useCurrentVersionForDraft, useCurrentVersionForDraft,
useGenerationsForDraft, useGenerationsForDraft,
useAllStyles,
} from '../queries'; } from '../queries';
import { KIND_LABELS, STATUS_LABELS } from '../constants'; import { KIND_LABELS, STATUS_LABELS } from '../constants';
import { getStylePreset } from '../presets/styles';
import type { DraftStatus } from '../types'; import type { DraftStatus } from '../types';
let { id }: { id: string } = $props(); let { id }: { id: string } = $props();
@ -109,6 +111,22 @@
const kind = $derived(draft ? KIND_LABELS[draft.kind] : null); const kind = $derived(draft ? KIND_LABELS[draft.kind] : null);
const targetWords = $derived(draft?.briefing.targetLength?.value ?? null); const targetWords = $derived(draft?.briefing.targetLength?.value ?? null);
const STATUS_ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published']; 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<string | null>(() => {
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;
});
</script> </script>
{#if draft$.loading} {#if draft$.loading}
@ -163,6 +181,9 @@
{briefingOpen ? '▾' : '▸'} Briefing {briefingOpen ? '▾' : '▸'} Briefing
{#if !briefingOpen} {#if !briefingOpen}
<span class="preview">{draft.briefing.topic}</span> <span class="preview">{draft.briefing.topic}</span>
{#if activeStyleName}
<span class="style-chip" title="Aktiver Stil">🎨 {activeStyleName}</span>
{/if}
{/if} {/if}
</button> </button>
{#if briefingOpen} {#if briefingOpen}
@ -367,6 +388,15 @@
min-width: 0; min-width: 0;
flex: 1; 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 { .columns {
display: grid; display: grid;
grid-template-columns: 1fr 280px; grid-template-columns: 1fr 280px;

View file

@ -97,6 +97,7 @@
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Nach Titel oder Thema suchen…" placeholder="Nach Titel oder Thema suchen…"
/> />
<a href="/writing/styles" class="styles-link" title="Stile verwalten">🎨 Stile</a>
<button <button
type="button" type="button"
class="create-btn" class="create-btn"
@ -182,6 +183,21 @@
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.styles-link {
padding: 0.45rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
color: inherit;
text-decoration: none;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
.styles-link:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
.create-btn { .create-btn {
padding: 0.45rem 0.9rem; padding: 0.45rem 0.9rem;
border-radius: 0.55rem; border-radius: 0.55rem;

View file

@ -0,0 +1,279 @@
<!--
StylesView — manage writing styles.
Two sections: built-in presets (read-only, 9 of them) and the user's
custom styles (CRUD). In M4 custom styles are "custom-description"
only — a name + a prose description the prompt builder hands straight
to the LLM. M4.1 adds sample-trained styles (principles extracted
from a batch of user samples) via a separate flow.
-->
<script lang="ts">
import { STYLE_PRESETS } from '../presets/styles';
import { useAllStyles } from '../queries';
import { stylesStore } from '../stores/styles.svelte';
import StyleForm from '../components/StyleForm.svelte';
import { STYLE_SOURCE_LABELS } from '../constants';
import type { WritingStyle } from '../types';
const styles$ = useAllStyles();
const customStyles = $derived(styles$.value);
let createOpen = $state(false);
let editingId = $state<string | null>(null);
const editingStyle = $derived<WritingStyle | null>(
editingId ? (customStyles.find((s) => s.id === editingId) ?? null) : null
);
async function remove(style: WritingStyle) {
if (!confirm(`"${style.name}" wirklich löschen?`)) return;
await stylesStore.deleteStyle(style.id);
}
</script>
<div class="styles-shell">
<header class="head">
<a href="/writing" class="back">← Zurück zu Writing</a>
<div>
<h1>Stile</h1>
<p class="muted">Vorlagen und eigene Stile, die der Ghostwriter beim Generieren anwendet.</p>
</div>
<button
type="button"
class="create-btn"
class:active={createOpen}
onclick={() => (createOpen = !createOpen)}
>
{createOpen ? '× Schließen' : '+ Eigener Stil'}
</button>
</header>
{#if createOpen}
<section class="inline-form">
<StyleForm mode="create" onclose={() => (createOpen = false)} />
</section>
{/if}
<section>
<h2>Vorlagen</h2>
<p class="muted small">
Eingebaute Stile — direkt im Briefing auswählbar. Nicht bearbeitbar; für Anpassungen lege
einen eigenen Stil an.
</p>
<div class="grid">
{#each STYLE_PRESETS as preset (preset.id)}
<article class="card preset">
<header class="card-head">
<strong>{preset.name.de}</strong>
<span class="tag">Vorlage</span>
</header>
<p class="desc">{preset.description.de}</p>
{#if preset.principles.toneTraits.length}
<ul class="traits">
{#each preset.principles.toneTraits as trait (trait)}
<li>{trait}</li>
{/each}
</ul>
{/if}
</article>
{/each}
</div>
</section>
<section>
<h2>Meine Stile</h2>
{#if styles$.loading}
<p class="muted small">Lädt…</p>
{:else if customStyles.length === 0}
<p class="muted small">
Keine eigenen Stile. Klick oben auf <strong>+ Eigener Stil</strong>, um einen anzulegen —
z.B. "Mein Corporate-Ton" oder "Persönliche Blog-Stimme".
</p>
{:else}
<div class="grid">
{#each customStyles as style (style.id)}
<article class="card" class:editing={editingId === style.id}>
<header class="card-head">
<strong>{style.name}</strong>
<span class="tag">{STYLE_SOURCE_LABELS[style.source].de}</span>
</header>
{#if editingId === style.id}
<StyleForm mode="edit" {style} onclose={() => (editingId = null)} />
{:else}
<p class="desc">{style.description}</p>
{#if style.extractedPrinciples?.toneTraits.length}
<ul class="traits">
{#each style.extractedPrinciples.toneTraits as trait (trait)}
<li>{trait}</li>
{/each}
</ul>
{/if}
<div class="actions">
<button type="button" class="tiny" onclick={() => (editingId = style.id)}>
Bearbeiten
</button>
<button type="button" class="tiny danger" onclick={() => remove(style)}>
Löschen
</button>
</div>
{/if}
</article>
{/each}
</div>
{/if}
</section>
</div>
<style>
.styles-shell {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
}
.back {
font-size: 0.85rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
text-decoration: none;
}
.back:hover {
color: #0ea5e9;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
h2 {
margin: 0 0 0.25rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-weight: 500;
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
margin: 0;
}
.muted.small {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.create-btn {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
border: 1px solid #0ea5e9;
background: #0ea5e9;
color: white;
cursor: pointer;
font: inherit;
font-weight: 500;
white-space: nowrap;
}
.create-btn:hover {
background: #0284c7;
border-color: #0284c7;
}
.create-btn.active {
background: transparent;
color: #0ea5e9;
}
.inline-form {
border: 1px solid color-mix(in srgb, #0ea5e9 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #0ea5e9 4%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.card {
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));
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.card.preset {
border-style: dashed;
}
.card.editing {
border-color: #0ea5e9;
padding: 0;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.tag {
font-size: 0.65rem;
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, #0ea5e9 12%, transparent);
color: #0ea5e9;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.desc {
margin: 0;
font-size: 0.85rem;
line-height: 1.45;
color: var(--color-text, inherit);
}
.traits {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0;
margin: 0;
list-style: none;
}
.traits li {
font-size: 0.7rem;
padding: 0.05rem 0.5rem;
border-radius: 999px;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.actions {
display: inline-flex;
gap: 0.4rem;
margin-top: 0.25rem;
}
.tiny {
padding: 0.2rem 0.55rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
font-size: 0.75rem;
cursor: pointer;
color: inherit;
font: inherit;
}
.tiny:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
.tiny.danger:hover {
border-color: #ef4444;
color: #ef4444;
}
@media (max-width: 600px) {
.head {
grid-template-columns: 1fr;
}
}
</style>

View file

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