feat(brain): add Ritual system with guided routines and step execution

Phase 6 of the Companion Brain. Introduces guided routines ("rituals")
that walk users through multi-step sequences, executing tools and
displaying projection data at each step.

Data layer (companion/rituals/):
- LocalRitual + LocalRitualStep + LocalRitualLog types
- 6 step types: tool_call, number_input, text_input, mood_picker,
  info_display, checklist
- 3 templates: Morning routine (water + events + tasks + streaks),
  Evening routine (progress + reflection), Hydration check
- Store with createFromTemplate, CRUD, step management, completion logs
- Reactive queries for active/all rituals

UI:
- RitualRunner.svelte: step-by-step card UI with progress bar,
  tool execution, number/text input, projection data display,
  skip/next navigation
- /companion/rituals route: ritual list, template picker, play/pause

Adds rituals + ritualSteps + ritualLogs tables (v10 schema).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 22:01:37 +02:00
parent 46db527f8c
commit 41357b2541
7 changed files with 1074 additions and 0 deletions

View file

@ -0,0 +1,11 @@
export { ritualStore } from './store';
export { useActiveRituals, useAllRituals } from './queries';
export { RITUAL_TEMPLATES } from './types';
export type {
LocalRitual,
LocalRitualStep,
LocalRitualLog,
RitualTemplate,
RitualStepType,
RitualStepConfig,
} from './types';

View file

@ -0,0 +1,19 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalRitual } from './types';
export function useActiveRituals() {
return useLiveQueryWithDefault<LocalRitual[]>(async () => {
const all = await db.table<LocalRitual>('rituals').toArray();
return all
.filter((r) => r.status === 'active' && !r.deletedAt)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}, []);
}
export function useAllRituals() {
return useLiveQueryWithDefault<LocalRitual[]>(async () => {
const all = await db.table<LocalRitual>('rituals').toArray();
return all.filter((r) => !r.deletedAt);
}, []);
}

View file

@ -0,0 +1,122 @@
/**
* Ritual Store CRUD for rituals, steps, and execution logs.
*/
import { db } from '$lib/data/database';
import type { LocalRitual, LocalRitualStep, LocalRitualLog, RitualTemplate } from './types';
const RITUALS = 'rituals';
const STEPS = 'ritualSteps';
const LOGS = 'ritualLogs';
export const ritualStore = {
async createFromTemplate(template: RitualTemplate): Promise<LocalRitual> {
const now = new Date().toISOString();
const ritual: LocalRitual = {
id: crypto.randomUUID(),
title: template.title,
description: template.description,
trigger: template.trigger,
status: 'active',
createdAt: now,
updatedAt: now,
};
await db.table(RITUALS).add(ritual);
// Create steps
for (const stepDef of template.steps) {
const step: LocalRitualStep = {
id: crypto.randomUUID(),
ritualId: ritual.id,
order: stepDef.order,
type: stepDef.type,
label: stepDef.label,
config: stepDef.config,
createdAt: now,
};
await db.table(STEPS).add(step);
}
return ritual;
},
async create(input: {
title: string;
description?: string;
trigger: LocalRitual['trigger'];
}): Promise<LocalRitual> {
const now = new Date().toISOString();
const ritual: LocalRitual = {
id: crypto.randomUUID(),
title: input.title,
description: input.description,
trigger: input.trigger,
status: 'active',
createdAt: now,
updatedAt: now,
};
await db.table(RITUALS).add(ritual);
return ritual;
},
async addStep(
ritualId: string,
step: Omit<LocalRitualStep, 'id' | 'ritualId' | 'createdAt'>
): Promise<LocalRitualStep> {
const newStep: LocalRitualStep = {
id: crypto.randomUUID(),
ritualId,
...step,
createdAt: new Date().toISOString(),
};
await db.table(STEPS).add(newStep);
return newStep;
},
async getSteps(ritualId: string): Promise<LocalRitualStep[]> {
return db.table<LocalRitualStep>(STEPS).where('ritualId').equals(ritualId).sortBy('order');
},
async pause(id: string): Promise<void> {
await db.table(RITUALS).update(id, { status: 'paused', updatedAt: new Date().toISOString() });
},
async resume(id: string): Promise<void> {
await db.table(RITUALS).update(id, { status: 'active', updatedAt: new Date().toISOString() });
},
async archive(id: string): Promise<void> {
await db.table(RITUALS).update(id, { status: 'archived', updatedAt: new Date().toISOString() });
},
async delete(id: string): Promise<void> {
await db
.table(RITUALS)
.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
},
// ── Logs ──────────────────────────────────────────
async logCompletion(ritualId: string, completedSteps: number, totalSteps: number): Promise<void> {
const now = new Date().toISOString();
const log: LocalRitualLog = {
ritualId,
date: now.split('T')[0],
completedSteps,
totalSteps,
completedAt: completedSteps >= totalSteps ? now : undefined,
createdAt: now,
};
await db.table(LOGS).add(log);
},
async getTodayLog(ritualId: string): Promise<LocalRitualLog | undefined> {
const today = new Date().toISOString().split('T')[0];
const logs = await db
.table<LocalRitualLog>(LOGS)
.where('[ritualId+date]')
.equals([ritualId, today])
.toArray();
return logs[0];
},
};

View file

@ -0,0 +1,212 @@
/**
* Ritual types Guided routines that write data into modules.
*
* A ritual is a sequence of steps. Each step either executes a tool
* (e.g. log_drink), collects user input (free text, mood picker,
* number), or displays information (DaySnapshot data).
*/
export interface LocalRitual {
id: string;
title: string;
description?: string;
/** When this ritual should trigger ('morning', 'evening', 'manual') */
trigger: 'morning' | 'evening' | 'manual';
status: 'active' | 'paused' | 'archived';
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export interface LocalRitualStep {
id: string;
ritualId: string;
order: number;
/** Step type determines the UI and behavior */
type: RitualStepType;
/** Human-readable label shown to the user */
label: string;
/** Configuration depends on type */
config: RitualStepConfig;
createdAt: string;
}
export type RitualStepType =
| 'tool_call' // Execute a tool with preset params
| 'number_input' // User enters a number → tool call
| 'text_input' // User enters text → tool call
| 'mood_picker' // User picks mood (1-5) → tool call
| 'info_display' // Show data from projections (read-only)
| 'checklist'; // Multiple items to check off
export type RitualStepConfig =
| ToolCallStepConfig
| NumberInputStepConfig
| TextInputStepConfig
| MoodPickerStepConfig
| InfoDisplayStepConfig
| ChecklistStepConfig;
export interface ToolCallStepConfig {
type: 'tool_call';
toolName: string;
params: Record<string, unknown>;
}
export interface NumberInputStepConfig {
type: 'number_input';
toolName: string;
paramName: string;
/** Other params to pass along */
baseParams: Record<string, unknown>;
unit?: string;
min?: number;
max?: number;
defaultValue?: number;
}
export interface TextInputStepConfig {
type: 'text_input';
toolName: string;
paramName: string;
baseParams: Record<string, unknown>;
placeholder?: string;
}
export interface MoodPickerStepConfig {
type: 'mood_picker';
toolName: string;
paramName: string;
baseParams: Record<string, unknown>;
}
export interface InfoDisplayStepConfig {
type: 'info_display';
/** Which projection data to show */
source: 'tasks_today' | 'events_today' | 'drink_progress' | 'nutrition_progress' | 'streaks';
}
export interface ChecklistStepConfig {
type: 'checklist';
items: { label: string; toolName?: string; toolParams?: Record<string, unknown> }[];
}
export interface LocalRitualLog {
id?: number;
ritualId: string;
date: string; // YYYY-MM-DD
completedSteps: number;
totalSteps: number;
completedAt?: string;
createdAt: string;
}
// ── Templates ───────────────────────────────────────
export interface RitualTemplate {
id: string;
title: string;
description: string;
trigger: LocalRitual['trigger'];
steps: Omit<LocalRitualStep, 'id' | 'ritualId' | 'createdAt'>[];
}
export const RITUAL_TEMPLATES: RitualTemplate[] = [
{
id: 'tpl-morning',
title: 'Morgenroutine',
description: 'Starte den Tag mit Wasser, Tagesueberblick und Prioritaeten',
trigger: 'morning',
steps: [
{
order: 0,
type: 'tool_call',
label: 'Glas Wasser trinken',
config: {
type: 'tool_call',
toolName: 'log_drink',
params: { drinkType: 'water', quantityMl: 250, name: 'Wasser' },
},
},
{
order: 1,
type: 'info_display',
label: 'Dein Tag auf einen Blick',
config: { type: 'info_display', source: 'events_today' },
},
{
order: 2,
type: 'info_display',
label: 'Heutige Tasks',
config: { type: 'info_display', source: 'tasks_today' },
},
{
order: 3,
type: 'info_display',
label: 'Deine Streaks',
config: { type: 'info_display', source: 'streaks' },
},
],
},
{
id: 'tpl-evening',
title: 'Abendroutine',
description: 'Reflektiere den Tag und plane morgen',
trigger: 'evening',
steps: [
{
order: 0,
type: 'info_display',
label: 'Tages-Zusammenfassung',
config: { type: 'info_display', source: 'drink_progress' },
},
{
order: 1,
type: 'info_display',
label: 'Ernaehrung heute',
config: { type: 'info_display', source: 'nutrition_progress' },
},
{
order: 2,
type: 'text_input',
label: 'Was war heute gut?',
config: {
type: 'text_input',
toolName: 'create_task',
paramName: 'title',
baseParams: {},
placeholder: 'z.B. Gutes Gespraech mit Anna...',
},
},
],
},
{
id: 'tpl-hydration',
title: 'Trink-Check',
description: 'Schneller Wasser-Check und Nachloggen',
trigger: 'manual',
steps: [
{
order: 0,
type: 'info_display',
label: 'Wasser-Fortschritt',
config: { type: 'info_display', source: 'drink_progress' },
},
{
order: 1,
type: 'number_input',
label: 'Wasser nachloggen',
config: {
type: 'number_input',
toolName: 'log_drink',
paramName: 'quantityMl',
baseParams: { drinkType: 'water', name: 'Wasser' },
unit: 'ml',
min: 100,
max: 1000,
defaultValue: 250,
},
},
],
},
];

View file

@ -432,6 +432,10 @@ db.version(10).stores({
_nudgeOutcomes: '++id, nudgeId, nudgeType, outcome, timestamp, [nudgeType+outcome]',
companionConversations: 'id, createdAt',
companionMessages: 'id, conversationId, role, createdAt, [conversationId+createdAt]',
// Rituals
rituals: 'id, status, createdAt',
ritualSteps: 'id, ritualId, order, [ritualId+order]',
ritualLogs: '++id, ritualId, date, [ritualId+date]',
});
// Schema version 11 — adds the Mail module (local draft cache).

View file

@ -0,0 +1,423 @@
<!--
RitualRunner — Step-by-step guided routine execution.
Walks through ritual steps: executes tools, collects user input,
displays projection data. Tracks progress and logs completion.
-->
<script lang="ts">
import { Check, ArrowRight, Drop, Lightning, ListChecks } from '@mana/shared-icons';
import { executeTool } from '$lib/data/tools';
import { useDaySnapshot } from '$lib/data/projections/day-snapshot';
import { useStreaks } from '$lib/data/projections/streaks';
import { ritualStore } from '$lib/companion/rituals/store';
import type { LocalRitual, LocalRitualStep } from '$lib/companion/rituals/types';
import { onMount } from 'svelte';
interface Props {
ritual: LocalRitual;
onComplete: () => void;
onClose: () => void;
}
let { ritual, onComplete, onClose }: Props = $props();
const day = useDaySnapshot();
const streaks = useStreaks();
let steps = $state<LocalRitualStep[]>([]);
let currentStepIdx = $state(0);
let completedSteps = $state<Set<number>>(new Set());
let stepResult = $state<string | null>(null);
let inputValue = $state<string | number>('');
let executing = $state(false);
let currentStep = $derived(steps[currentStepIdx]);
let isLastStep = $derived(currentStepIdx >= steps.length - 1);
let progress = $derived(steps.length > 0 ? (completedSteps.size / steps.length) * 100 : 0);
onMount(async () => {
steps = await ritualStore.getSteps(ritual.id);
});
async function executeCurrentStep() {
if (!currentStep || executing) return;
executing = true;
stepResult = null;
try {
const config = currentStep.config;
if (config.type === 'tool_call') {
const result = await executeTool(config.toolName, config.params);
stepResult = result.message;
} else if (config.type === 'number_input') {
const val = typeof inputValue === 'number' ? inputValue : Number(inputValue);
if (isNaN(val)) {
executing = false;
return;
}
const params = { ...config.baseParams, [config.paramName]: val };
const result = await executeTool(config.toolName, params);
stepResult = result.message;
} else if (config.type === 'text_input') {
const val = String(inputValue).trim();
if (!val) {
executing = false;
return;
}
const params = { ...config.baseParams, [config.paramName]: val };
const result = await executeTool(config.toolName, params);
stepResult = result.message;
} else if (config.type === 'info_display') {
// Info steps are auto-completed
stepResult = null;
}
completeStep();
} catch (err) {
stepResult = `Fehler: ${err instanceof Error ? err.message : String(err)}`;
} finally {
executing = false;
}
}
function completeStep() {
completedSteps = new Set([...completedSteps, currentStepIdx]);
inputValue = '';
}
function nextStep() {
if (isLastStep) {
ritualStore.logCompletion(ritual.id, completedSteps.size, steps.length);
onComplete();
return;
}
stepResult = null;
currentStepIdx++;
// Auto-complete info_display steps
if (currentStep?.config.type === 'info_display') {
completeStep();
}
}
function skipStep() {
nextStep();
}
</script>
{#if steps.length === 0}
<div class="loading">Lade Ritual...</div>
{:else}
<div class="ritual-runner">
<!-- Header -->
<div class="ritual-header">
<h3>{ritual.title}</h3>
<div class="progress-bar">
<div class="progress-fill" style:width="{progress}%"></div>
</div>
<span class="step-counter">{completedSteps.size} / {steps.length}</span>
</div>
<!-- Current Step -->
{#if currentStep}
<div class="step-card">
<div class="step-label">{currentStep.label}</div>
{#if currentStep.config.type === 'tool_call'}
{#if completedSteps.has(currentStepIdx)}
<div class="step-done"><Check size={20} weight="bold" /> {stepResult}</div>
{:else}
<button class="step-action" onclick={executeCurrentStep} disabled={executing}>
<Lightning size={16} weight="bold" /> Ausfuehren
</button>
{/if}
{:else if currentStep.config.type === 'number_input'}
{#if completedSteps.has(currentStepIdx)}
<div class="step-done"><Check size={20} weight="bold" /> {stepResult}</div>
{:else}
<div class="input-row">
<input
type="number"
class="step-input"
bind:value={inputValue}
min={currentStep.config.min}
max={currentStep.config.max}
placeholder={String(currentStep.config.defaultValue ?? '')}
/>
{#if currentStep.config.unit}
<span class="input-unit">{currentStep.config.unit}</span>
{/if}
<button class="step-action" onclick={executeCurrentStep} disabled={executing}>
Loggen
</button>
</div>
{/if}
{:else if currentStep.config.type === 'text_input'}
{#if completedSteps.has(currentStepIdx)}
<div class="step-done"><Check size={20} weight="bold" /> {stepResult}</div>
{:else}
<div class="input-row">
<input
type="text"
class="step-input text"
bind:value={inputValue}
placeholder={currentStep.config.placeholder ?? ''}
/>
<button class="step-action" onclick={executeCurrentStep} disabled={executing}>
Speichern
</button>
</div>
{/if}
{:else if currentStep.config.type === 'info_display'}
<div class="info-card">
{#if currentStep.config.source === 'tasks_today'}
{#if day.value.tasks.dueToday.length > 0}
{#each day.value.tasks.dueToday as t}
<div class="info-item">{t.title}</div>
{/each}
{:else}
<div class="info-empty">Keine Tasks faellig heute</div>
{/if}
{:else if currentStep.config.source === 'events_today'}
{#if day.value.events.upcoming.length > 0}
{#each day.value.events.upcoming as e}
<div class="info-item">{e.title}</div>
{/each}
{:else}
<div class="info-empty">Keine Termine heute</div>
{/if}
{:else if currentStep.config.source === 'drink_progress'}
<div class="info-item">
Wasser: {day.value.drinks.water.ml}ml / {day.value.drinks.water.goal}ml ({day.value
.drinks.water.percent}%)
</div>
<div class="info-item">Gesamt: {day.value.drinks.total.count} Getraenke</div>
{:else if currentStep.config.source === 'nutrition_progress'}
<div class="info-item">
Kalorien: {day.value.nutrition.calories.actual} / {day.value.nutrition.calories
.goal} kcal
</div>
<div class="info-item">Mahlzeiten: {day.value.nutrition.meals}</div>
{:else if currentStep.config.source === 'streaks'}
{#each streaks.value as s}
<div class="info-item">
{s.label}: {s.currentStreak} Tage
{#if s.status === 'at_risk'}(gefaehrdet){/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Navigation -->
<div class="step-nav">
<button class="nav-skip" onclick={skipStep}>
{completedSteps.has(currentStepIdx) || currentStep.config.type === 'info_display'
? ''
: 'Ueberspringen'}
</button>
<button class="nav-next" onclick={nextStep}>
{isLastStep ? 'Fertig' : 'Weiter'}
<ArrowRight size={16} weight="bold" />
</button>
</div>
{/if}
<!-- Close -->
<button class="close-btn" onclick={onClose}>Abbrechen</button>
</div>
{/if}
<style>
.ritual-runner {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.5rem;
max-width: 480px;
margin: 0 auto;
}
.loading {
text-align: center;
padding: 2rem;
color: hsl(var(--color-muted-foreground));
}
.ritual-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 0.75rem;
}
.progress-bar {
height: 4px;
background: hsl(var(--color-muted) / 0.3);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: hsl(var(--color-primary));
border-radius: 2px;
transition: width 0.3s ease;
}
.step-counter {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.step-card {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.step-label {
font-size: 1rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.step-action {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.step-action:hover:not(:disabled) {
filter: brightness(1.1);
}
.step-action:disabled {
opacity: 0.6;
cursor: wait;
}
.step-done {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-success, 142 71% 45%));
font-size: 0.875rem;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.step-input {
flex: 1;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1.5px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
max-width: 120px;
}
.step-input.text {
max-width: none;
}
.step-input:focus {
border-color: hsl(var(--color-primary));
}
.input-unit {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.info-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.info-item {
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.info-empty {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.step-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-skip {
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
}
.nav-skip:hover {
color: hsl(var(--color-foreground));
}
.nav-next {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.nav-next:hover {
background: hsl(var(--color-primary) / 0.2);
}
.close-btn {
align-self: center;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
padding: 0.375rem 0.75rem;
}
.close-btn:hover {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -0,0 +1,283 @@
<script lang="ts">
import { Plus, Play, Pause, Trash } from '@mana/shared-icons';
import RitualRunner from '$lib/modules/companion/components/RitualRunner.svelte';
import {
ritualStore,
useActiveRituals,
useAllRituals,
RITUAL_TEMPLATES,
} from '$lib/companion/rituals';
import type { LocalRitual } from '$lib/companion/rituals/types';
const rituals = useAllRituals();
let activeRitual = $state<LocalRitual | null>(null);
let showTemplates = $state(false);
async function createFromTemplate(templateId: string) {
const template = RITUAL_TEMPLATES.find((t) => t.id === templateId);
if (!template) return;
await ritualStore.createFromTemplate(template);
showTemplates = false;
}
function startRitual(ritual: LocalRitual) {
activeRitual = ritual;
}
function handleComplete() {
activeRitual = null;
}
</script>
<svelte:head>
<title>Rituale - Mana Companion</title>
</svelte:head>
{#if activeRitual}
<RitualRunner
ritual={activeRitual}
onComplete={handleComplete}
onClose={() => (activeRitual = null)}
/>
{:else}
<div class="rituals-page">
<div class="page-header">
<h2>Rituale</h2>
<button class="add-btn" onclick={() => (showTemplates = !showTemplates)}>
<Plus size={16} weight="bold" /> Neu
</button>
</div>
{#if showTemplates}
<div class="templates">
<h3>Vorlage waehlen</h3>
{#each RITUAL_TEMPLATES as tpl}
<button class="template-card" onclick={() => createFromTemplate(tpl.id)}>
<span class="tpl-title">{tpl.title}</span>
<span class="tpl-desc">{tpl.description}</span>
<span class="tpl-trigger"
>{tpl.trigger === 'morning'
? 'Morgens'
: tpl.trigger === 'evening'
? 'Abends'
: 'Manuell'}</span
>
</button>
{/each}
</div>
{/if}
<div class="ritual-list">
{#each rituals.value as ritual (ritual.id)}
<div class="ritual-card">
<div class="ritual-info">
<span class="ritual-title">{ritual.title}</span>
{#if ritual.description}
<span class="ritual-desc">{ritual.description}</span>
{/if}
<span class="ritual-trigger">
{ritual.trigger === 'morning'
? 'Morgens'
: ritual.trigger === 'evening'
? 'Abends'
: 'Manuell'}
· {ritual.status === 'active' ? 'Aktiv' : 'Pausiert'}
</span>
</div>
<div class="ritual-actions">
{#if ritual.status === 'active'}
<button class="action-btn play" onclick={() => startRitual(ritual)} title="Starten">
<Play size={16} weight="fill" />
</button>
<button
class="action-btn"
onclick={() => ritualStore.pause(ritual.id)}
title="Pausieren"
>
<Pause size={14} weight="bold" />
</button>
{:else}
<button
class="action-btn"
onclick={() => ritualStore.resume(ritual.id)}
title="Fortsetzen"
>
<Play size={14} weight="bold" />
</button>
{/if}
<button
class="action-btn danger"
onclick={() => ritualStore.delete(ritual.id)}
title="Loeschen"
>
<Trash size={14} />
</button>
</div>
</div>
{:else}
<p class="empty">Noch keine Rituale. Erstelle eins aus einer Vorlage.</p>
{/each}
</div>
</div>
{/if}
<style>
.rituals-page {
max-width: 560px;
margin: 0 auto;
padding: 1rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.page-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.templates {
margin-bottom: 1.5rem;
}
.templates h3 {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin: 0 0 0.75rem;
}
.template-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
cursor: pointer;
text-align: left;
margin-bottom: 0.5rem;
transition: all 0.15s;
}
.template-card:hover {
border-color: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-primary) / 0.03);
}
.tpl-title {
font-weight: 600;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
.tpl-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.tpl-trigger {
font-size: 0.75rem;
color: hsl(var(--color-primary));
font-weight: 500;
}
.ritual-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ritual-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.ritual-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.ritual-title {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
.ritual-desc {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.ritual-trigger {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.ritual-actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: hsl(var(--color-muted) / 0.2);
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.action-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.action-btn.play {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.action-btn.play:hover {
background: hsl(var(--color-primary) / 0.25);
}
.action-btn.danger:hover {
background: hsl(var(--color-error) / 0.15);
color: hsl(var(--color-error));
}
.empty {
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
padding: 2rem 1rem;
}
</style>