mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
d725a8df8b
commit
1c82a374fe
8 changed files with 620 additions and 6 deletions
|
|
@ -7,6 +7,7 @@
|
|||
<script lang="ts">
|
||||
import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
|
||||
import { draftsStore } from '../stores/drafts.svelte';
|
||||
import StylePicker from './StylePicker.svelte';
|
||||
import type { Draft, DraftKind, DraftBriefing } from '../types';
|
||||
|
||||
let {
|
||||
|
|
@ -59,6 +60,8 @@
|
|||
);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let extraInstructions = $state(draft?.briefing.extraInstructions ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let styleId = $state<string | null>(draft?.styleId ?? null);
|
||||
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -96,12 +99,14 @@
|
|||
kind,
|
||||
title: title.trim(),
|
||||
briefing,
|
||||
styleId,
|
||||
});
|
||||
oncreated?.(created);
|
||||
} else if (draft) {
|
||||
await draftsStore.updateDraft(draft.id, {
|
||||
title: title.trim(),
|
||||
kind,
|
||||
styleId,
|
||||
});
|
||||
await draftsStore.updateBriefing(draft.id, briefing);
|
||||
}
|
||||
|
|
@ -177,6 +182,13 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>
|
||||
Stil <small>(optional — prägt Ton & Struktur der Generation)</small>
|
||||
</span>
|
||||
<StylePicker value={styleId} onchange={(next) => (styleId = next)} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Zusatzhinweise <small>(optional)</small></span>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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<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.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<number> {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
|
||||
{#if draft$.loading}
|
||||
|
|
@ -163,6 +181,9 @@
|
|||
{briefingOpen ? '▾' : '▸'} Briefing
|
||||
{#if !briefingOpen}
|
||||
<span class="preview">{draft.briefing.topic}</span>
|
||||
{#if activeStyleName}
|
||||
<span class="style-chip" title="Aktiver Stil">🎨 {activeStyleName}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#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;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
bind:value={searchQuery}
|
||||
placeholder="Nach Titel oder Thema suchen…"
|
||||
/>
|
||||
<a href="/writing/styles" class="styles-link" title="Stile verwalten">🎨 Stile</a>
|
||||
<button
|
||||
type="button"
|
||||
class="create-btn"
|
||||
|
|
@ -182,6 +183,21 @@
|
|||
align-items: center;
|
||||
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 {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 0.55rem;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue