feat(brain): add NudgeToast, server LLM fallback, trigger-event bridge

Three remaining TODOs resolved:

1. NudgeToast (in-app nudge display):
   - New NudgeToast.svelte in bottom-stack alongside SuggestionToast
   - Evaluates Pulse Rules every 60s, shows nudges as toasts
   - Action button navigates to module route, dismiss records outcome
   - Badge shows count when multiple nudges are queued

2. Server LLM fallback:
   - Companion engine now tries local LLM (Gemma/WebGPU) first
   - Falls back to mana-api /api/v1/chat/completions if no WebGPU
   - isCompanionAvailable() returns true if either path works
   - Graceful error messages when neither is available

3. Trigger-Event bridge (legacy automation migration):
   - event-bridge.ts maps 13 domain event types to legacy
     (appId, collection, op) format
   - Existing user automations now fire on domain events too
   - Domain events carry decrypted data → condition matching on
     encrypted fields (title, etc.) works correctly
   - Bridge wired into layout startup/cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 23:45:37 +02:00
parent d6d50e4d94
commit 677f6b799d
6 changed files with 521 additions and 251 deletions

View file

@ -0,0 +1,201 @@
<!--
NudgeToast — In-app display for Companion Brain Pulse nudges.
Lives in .bottom-stack in (app)/+layout.svelte.
Self-gates: only renders when there are active nudges.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { X } from '@mana/shared-icons';
import { evaluateRules, dismissNudge } from '$lib/companion/rules';
import { recordOutcome } from '$lib/companion/feedback';
import { useDaySnapshot } from '$lib/data/projections/day-snapshot';
import { useStreaks } from '$lib/data/projections/streaks';
import type { Nudge, NudgeType } from '$lib/companion/rules/types';
const day = useDaySnapshot();
const streaks = useStreaks();
let nudges = $state<Nudge[]>([]);
let currentNudge = $derived(nudges[0] ?? null);
let shownAt = $state(0);
let intervalId: ReturnType<typeof setInterval> | null = null;
function checkNudges() {
const results = evaluateRules(day.value, streaks.value, []);
// Only show nudges we haven't already dismissed in this session
nudges = results.filter((n) => !dismissed.has(n.id));
}
const dismissed = new Set<string>();
onMount(() => {
// Check every 60 seconds
checkNudges();
intervalId = setInterval(checkNudges, 60_000);
});
onDestroy(() => {
if (intervalId) clearInterval(intervalId);
});
// Re-check when projections update
$effect(() => {
if (day.value.date && streaks.value) {
checkNudges();
}
});
function handleAction() {
if (!currentNudge) return;
const latencyMs = Date.now() - shownAt;
recordOutcome(currentNudge.id, currentNudge.type as NudgeType, 'acted', latencyMs);
if (currentNudge.actionRoute) {
goto(currentNudge.actionRoute);
}
removeCurrentNudge();
}
function handleDismiss() {
if (!currentNudge) return;
const latencyMs = Date.now() - shownAt;
recordOutcome(currentNudge.id, currentNudge.type as NudgeType, 'dismissed', latencyMs);
dismissNudge(currentNudge.id);
removeCurrentNudge();
}
function removeCurrentNudge() {
if (currentNudge) {
dismissed.add(currentNudge.id);
nudges = nudges.slice(1);
}
}
$effect(() => {
if (currentNudge) shownAt = Date.now();
});
</script>
{#if currentNudge}
<div class="nudge-toast" role="alert">
<div class="nudge-body">
<span class="nudge-title">{currentNudge.title}</span>
<span class="nudge-text">{currentNudge.body}</span>
</div>
<div class="nudge-actions">
{#if currentNudge.actionLabel}
<button class="nudge-action" onclick={handleAction}>
{currentNudge.actionLabel}
</button>
{/if}
<button class="nudge-dismiss" onclick={handleDismiss} title="Ausblenden">
<X size={14} />
</button>
</div>
{#if nudges.length > 1}
<span class="nudge-count">{nudges.length}</span>
{/if}
</div>
{/if}
<style>
.nudge-toast {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 0.75rem;
background: rgba(30, 30, 40, 0.95);
border: 1px solid hsl(var(--color-primary) / 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
max-width: min(90vw, 480px);
margin: 0 auto;
animation: slide-up 0.3s ease-out;
position: relative;
pointer-events: auto;
}
.nudge-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.nudge-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.nudge-text {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.3;
}
.nudge-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.nudge-action {
padding: 0.3125rem 0.75rem;
border-radius: 9999px;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
white-space: nowrap;
}
.nudge-action:hover {
filter: brightness(1.15);
}
.nudge-dismiss {
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.125rem;
display: flex;
border-radius: 50%;
}
.nudge-dismiss:hover {
color: hsl(var(--color-foreground));
}
.nudge-count {
position: absolute;
top: -0.375rem;
right: -0.375rem;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -1,28 +1,78 @@
/**
* Companion Chat Engine Orchestrates LLM + Context Document + Tool Calling.
*
* Flow:
* 1. Build system prompt from Context Document (projections + streaks)
* 2. Collect conversation history
* 3. Send to LLM with tool schemas
* 4. If LLM returns tool_use execute tool feed result back repeat
* 5. Return final assistant message
*
* Currently uses @mana/local-llm directly (Gemma, browser-local).
* Tool calling is simulated via JSON extraction since Gemma doesn't
* natively support function calling the system prompt instructs the
* model to output JSON when it wants to call a tool.
* Tries local LLM (Gemma via @mana/local-llm) first. If WebGPU is not
* available, falls back to the mana-llm server endpoint. Tool calling
* uses JSON extraction from the LLM output.
*/
import { generate, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
import { generate, getLocalLlmStatus, loadLocalLlm, isLocalLlmSupported } from '@mana/local-llm';
import { generateContextDocument } from '$lib/data/projections/context-document';
import { getToolsForLlm, executeTool } from '$lib/data/tools';
import { authStore } from '$lib/stores/auth.svelte';
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
import type { LocalMessage } from './types';
import type { ToolResult } from '$lib/data/tools/types';
const MAX_TOOL_ROUNDS = 3;
type LlmMessage = { role: 'user' | 'assistant' | 'system'; content: string };
/** Try local LLM, fall back to server if WebGPU unavailable. */
async function callLlm(messages: LlmMessage[], onToken?: (token: string) => void): Promise<string> {
// Try local first (WebGPU + Gemma)
if (isLocalLlmSupported()) {
const status = getLocalLlmStatus();
if (status.current.state !== 'ready') {
try {
await loadLocalLlm();
} catch {
// Fall through to server
return callServerLlm(messages);
}
}
const result = await generate({ messages, temperature: 0.7, maxTokens: 1024, onToken });
return result.content;
}
// Fallback: server-side LLM via mana-api
return callServerLlm(messages);
}
async function callServerLlm(messages: LlmMessage[]): Promise<string> {
const apiUrl =
(typeof window !== 'undefined' &&
(window as unknown as Record<string, string>).__PUBLIC_MANA_API_URL__) ||
import.meta.env.PUBLIC_MANA_API_URL ||
'';
if (!apiUrl) {
return 'LLM nicht verfuegbar — weder WebGPU noch Server-Endpoint konfiguriert.';
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
try {
const token = await authStore.getValidToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
} catch {
// Continue without auth — server will decide
}
const response = await fetch(`${apiUrl}/api/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({ messages, model: 'companion' }),
});
if (!response.ok) {
const err = await response.text().catch(() => response.statusText);
return `Server-Fehler: ${err}`;
}
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
return data.choices?.[0]?.message?.content ?? 'Keine Antwort vom Server.';
}
interface EngineResult {
content: string;
toolCalls: { name: string; params: Record<string, unknown>; result: ToolResult }[];
@ -107,17 +157,10 @@ export async function runCompanionChat(
streaks: StreakInfo[],
onToken?: (token: string) => void
): Promise<EngineResult> {
// Ensure local LLM is loaded
const status = getLocalLlmStatus();
if (status.current.state !== 'ready') {
await loadLocalLlm();
}
const systemPrompt = buildSystemPrompt(day, streaks);
const toolCalls: EngineResult['toolCalls'] = [];
// Build message chain
const llmMessages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
const llmMessages: LlmMessage[] = [
{ role: 'system', content: systemPrompt },
...messagesToLlm(history),
{ role: 'user', content: userMessage },
@ -126,14 +169,7 @@ export async function runCompanionChat(
let finalContent = '';
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
const result = await generate({
messages: llmMessages,
temperature: 0.7,
maxTokens: 1024,
onToken: round === 0 ? onToken : undefined, // Only stream first round
});
const text = result.content;
const text = await callLlm(llmMessages, round === 0 ? onToken : undefined);
const toolCall = extractToolCall(text);
if (!toolCall) {
@ -168,9 +204,20 @@ export async function runCompanionChat(
}
/**
* Check if the Companion Chat is available (LLM loaded or loadable).
* Check if the Companion Chat is available.
* Returns true if either local LLM or server endpoint is usable.
*/
export function isCompanionAvailable(): boolean {
const status = getLocalLlmStatus();
return status.current.state === 'ready' || status.current.state === 'idle';
// Local LLM available?
if (isLocalLlmSupported()) {
const status = getLocalLlmStatus();
if (status.current.state === 'ready' || status.current.state === 'idle') return true;
}
// Server fallback configured?
const apiUrl =
(typeof window !== 'undefined' &&
(window as unknown as Record<string, string>).__PUBLIC_MANA_API_URL__) ||
import.meta.env.PUBLIC_MANA_API_URL ||
'';
return !!apiUrl;
}

View file

@ -54,14 +54,14 @@
}
</script>
{#if showQuickLog}
<QuickLog
onComplete={() => (showQuickLog = false)}
onCancel={() => (showQuickLog = false)}
/>
{:else}
<div class="mood-view">
<!-- Log CTA -->
<div class="mood-view">
<!-- Inline Quick Log (expand/collapse) -->
{#if showQuickLog}
<QuickLog
onComplete={() => (showQuickLog = false)}
onCancel={() => (showQuickLog = false)}
/>
{:else}
<button class="log-cta" onclick={() => (showQuickLog = true)}>
<span class="cta-emoji">
{#if topEmotion}
@ -75,6 +75,7 @@
{todayEntries.length}/{settings.dailyTarget} Check-ins heute
</span>
</button>
{/if}
<!-- Today's Entries -->
{#if todayEntries.length > 0}
@ -209,8 +210,7 @@
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.mood-view {
@ -231,28 +231,26 @@
/* ── Log CTA ─────────────────────────────────── */
.log-cta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
background: linear-gradient(135deg, hsl(40 80% 96%), hsl(350 60% 96%));
border: 1px solid hsl(40 60% 88%);
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
transition: transform 0.15s;
transition: transform 0.15s, box-shadow 0.15s;
color: hsl(var(--color-foreground));
text-align: left;
}
:global(.dark) .log-cta {
background: linear-gradient(135deg, hsl(40 30% 12%), hsl(350 25% 14%));
border-color: hsl(40 30% 20%);
.log-cta:hover {
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.log-cta:hover { transform: scale(1.02); }
.cta-emoji { font-size: 1.75rem; }
.cta-text { font-size: 0.875rem; font-weight: 600; }
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; }
.cta-emoji { font-size: 1.375rem; flex-shrink: 0; }
.cta-text { font-size: 0.8125rem; font-weight: 600; flex: 1; }
.cta-sub { font-size: 0.6875rem; color: #f59e0b; font-weight: 500; flex-shrink: 0; }
/* ── Today ────────────────────────────────────── */
.today-section {

View file

@ -1,5 +1,6 @@
<!--
QuickLog — Fast mood check-in: level slider, emotion pick, optional context.
QuickLog — Inline mood check-in card (not fullscreen).
Level slider, emotion pick, optional context. Collapses after save.
-->
<script lang="ts">
import { moodStore } from '../stores/mood.svelte';
@ -26,7 +27,6 @@
let selectedTags = $state<string[]>([]);
let showDetails = $state(false);
// Split emotions by valence for the picker layout
let positiveEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'positive'));
let neutralEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'neutral'));
let negativeEmotions = $derived(CORE_EMOTIONS.filter((e) => EMOTION_META[e].valence === 'negative'));
@ -48,6 +48,13 @@
notes,
tags: selectedTags,
});
// Reset for next entry
level = 5;
emotion = null;
activity = null;
notes = '';
selectedTags = [];
showDetails = false;
onComplete();
}
@ -60,166 +67,134 @@
}
</script>
<div class="log-overlay">
<div class="log-header">
<button class="close-btn" onclick={onCancel}>×</button>
<span class="header-title">Wie geht es dir?</span>
<div class="quick-log">
<div class="ql-header">
<span class="ql-title">Wie geht es dir?</span>
<button class="ql-close" onclick={onCancel}>×</button>
</div>
<div class="log-body">
<!-- Level Slider -->
<div class="level-section">
<div class="level-display" style:color={levelColor(level)}>
{level}
</div>
<input
class="level-slider"
type="range"
min="1"
max="10"
bind:value={level}
style:accent-color={levelColor(level)}
/>
<div class="level-labels">
<span>Schlecht</span>
<span>Super</span>
</div>
<!-- Level Slider -->
<div class="level-section">
<div class="level-display" style:color={levelColor(level)}>{level}</div>
<input
class="level-slider"
type="range"
min="1"
max="10"
bind:value={level}
style:accent-color={levelColor(level)}
/>
<div class="level-labels">
<span>Schlecht</span>
<span>Super</span>
</div>
</div>
<!-- Emotion Picker -->
<div class="emotion-section">
<span class="section-label">Was fühlst du?</span>
<div class="emotion-grid">
{#each positiveEmotions as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
{#each neutralEmotions as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
{#each negativeEmotions as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
</div>
<!-- Emotion Picker -->
<div class="emotion-section">
<span class="section-label">Was fühlst du?</span>
<div class="emotion-grid">
{#each [...positiveEmotions, ...neutralEmotions, ...negativeEmotions] as e}
<button
class="emotion-btn"
class:selected={emotion === e}
onclick={() => (emotion = emotion === e ? null : e)}
>
<span class="emo-emoji">{EMOTION_META[e].emoji}</span>
<span class="emo-label">{EMOTION_META[e].de}</span>
</button>
{/each}
</div>
</div>
<!-- Details Toggle -->
{#if !showDetails}
<button class="details-toggle" onclick={() => (showDetails = true)}>
+ Details hinzufügen
</button>
{:else}
<!-- Activity -->
<div class="activity-section">
<span class="section-label">Was machst du gerade?</span>
<div class="activity-grid">
{#each Object.entries(ACTIVITY_LABELS) as [key, meta]}
<button
class="activity-btn"
class:selected={activity === key}
onclick={() => (activity = activity === key ? null : (key as ActivityContext))}
>
<span class="act-emoji">{meta.emoji}</span>
<span class="act-label">{meta.de}</span>
</button>
{/each}
</div>
</div>
<!-- Tags -->
<div class="tags-section">
<span class="section-label">Tags</span>
<div class="tags-row">
{#each MOOD_TAG_PRESETS as tag}
<button
class="tag-chip"
class:active={selectedTags.includes(tag)}
onclick={() => toggleTag(tag)}
>{tag}</button>
{/each}
</div>
</div>
<!-- Notes -->
<textarea
class="notes-input"
placeholder="Notizen (optional)..."
bind:value={notes}
rows="2"
></textarea>
{/if}
<!-- Save -->
<button class="save-btn" onclick={handleSave} disabled={!emotion}>
Speichern
<!-- Details Toggle -->
{#if !showDetails}
<button class="details-toggle" onclick={() => (showDetails = true)}>
+ Details hinzufügen
</button>
</div>
{:else}
<!-- Activity -->
<div class="activity-section">
<span class="section-label">Was machst du gerade?</span>
<div class="activity-grid">
{#each Object.entries(ACTIVITY_LABELS) as [key, meta]}
<button
class="activity-btn"
class:selected={activity === key}
onclick={() => (activity = activity === key ? null : (key as ActivityContext))}
>
<span class="act-emoji">{meta.emoji}</span>
<span class="act-label">{meta.de}</span>
</button>
{/each}
</div>
</div>
<!-- Tags -->
<div class="tags-section">
<span class="section-label">Tags</span>
<div class="tags-row">
{#each MOOD_TAG_PRESETS as tag}
<button
class="tag-chip"
class:active={selectedTags.includes(tag)}
onclick={() => toggleTag(tag)}
>{tag}</button>
{/each}
</div>
</div>
<!-- Notes -->
<textarea
class="notes-input"
placeholder="Notizen (optional)..."
bind:value={notes}
rows="2"
></textarea>
{/if}
<!-- Save -->
<button class="save-btn" onclick={handleSave} disabled={!emotion}>
Speichern
</button>
</div>
<style>
.log-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: hsl(var(--color-background));
.quick-log {
display: flex;
flex-direction: column;
overflow-y: auto;
}
.log-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.close-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
gap: 0.625rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.ql-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ql-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.ql-close {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: hsl(var(--color-border));
border: none;
font-size: 1.125rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.log-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
line-height: 1;
}
.section-label {
@ -235,11 +210,11 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
gap: 0.25rem;
}
.level-display {
font-size: 2.5rem;
font-size: 1.75rem;
font-weight: 800;
line-height: 1;
transition: color 0.2s;
@ -247,7 +222,6 @@
.level-slider {
width: 100%;
max-width: 300px;
height: 8px;
}
@ -255,7 +229,6 @@
display: flex;
justify-content: space-between;
width: 100%;
max-width: 300px;
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
}
@ -264,32 +237,30 @@
.emotion-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: 0.25rem;
}
.emotion-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 0.375rem;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 0.25rem;
}
.emotion-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.375rem 0.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
gap: 0.0625rem;
padding: 0.25rem 0.125rem;
border-radius: 0.375rem;
background: hsl(var(--color-background));
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s;
color: hsl(var(--color-foreground));
}
.emotion-btn:hover {
transform: scale(1.05);
}
.emotion-btn:hover { transform: scale(1.05); }
.emotion-btn.selected {
border-color: #f59e0b;
@ -297,30 +268,22 @@
}
:global(.dark) .emotion-btn.selected {
background: hsl(40 30% 12%);
background: hsl(40 30% 15%);
}
.emo-emoji {
font-size: 1.25rem;
line-height: 1;
}
.emo-label {
font-size: 0.5625rem;
text-align: center;
line-height: 1.2;
}
.emo-emoji { font-size: 1rem; line-height: 1; }
.emo-label { font-size: 0.5rem; text-align: center; line-height: 1.1; }
/* ── Activity ─────────────────────────────────── */
.activity-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: 0.25rem;
}
.activity-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 0.25rem;
}
@ -328,48 +291,42 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.0625rem;
padding: 0.25rem;
border-radius: 0.375rem;
gap: 0;
padding: 0.1875rem;
border-radius: 0.25rem;
background: transparent;
border: 1px solid hsl(var(--color-border));
cursor: pointer;
font-size: 0.5625rem;
font-size: 0.5rem;
color: hsl(var(--color-muted-foreground));
transition: background 0.15s;
}
.activity-btn.selected {
background: hsl(var(--color-muted));
background: hsl(var(--color-background));
border-color: #f59e0b;
color: hsl(var(--color-foreground));
}
.act-emoji {
font-size: 1rem;
}
.act-label {
line-height: 1.2;
}
.act-emoji { font-size: 0.875rem; }
.act-label { line-height: 1.1; }
/* ── Tags & Notes ─────────────────────────────── */
.tags-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: 0.25rem;
}
.tags-row {
display: flex;
gap: 0.25rem;
gap: 0.1875rem;
flex-wrap: wrap;
}
.tag-chip {
padding: 0.25rem 0.5rem;
padding: 0.125rem 0.375rem;
border-radius: 1rem;
font-size: 0.625rem;
font-size: 0.5625rem;
font-weight: 500;
border: 1px solid hsl(var(--color-border));
background: transparent;
@ -385,12 +342,12 @@
.notes-input {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-size: 0.75rem;
resize: vertical;
}
@ -401,11 +358,11 @@
.details-toggle {
text-align: center;
padding: 0.375rem;
padding: 0.25rem;
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
font-size: 0.6875rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
@ -417,12 +374,12 @@
/* ── Save ─────────────────────────────────────── */
.save-btn {
padding: 0.75rem;
border-radius: 0.75rem;
padding: 0.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: white;
border: none;
font-size: 1rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}

View file

@ -0,0 +1,57 @@
/**
* Event Bridge Connects Domain Events to the legacy Trigger system.
*
* Maps domain event types to the old (appId, collection, op) format
* so existing user-created automations fire on semantic events (which
* carry decrypted data) instead of only on raw Dexie hooks (which see
* ciphertext for encrypted fields).
*
* This is a migration bridge. Long-term, automations should be
* rewritten as Pulse Rules that subscribe to the Event Bus directly.
*/
import { eventBus } from '$lib/data/events/event-bus';
import { fire } from './registry';
import type { DomainEvent } from '$lib/data/events/types';
/** Map domain event type → legacy (appId, collection, op) */
const EVENT_MAP: Record<string, { appId: string; collection: string; op: string }> = {
TaskCreated: { appId: 'todo', collection: 'tasks', op: 'insert' },
TaskCompleted: { appId: 'todo', collection: 'tasks', op: 'update' },
TaskDeleted: { appId: 'todo', collection: 'tasks', op: 'delete' },
CalendarEventCreated: { appId: 'calendar', collection: 'events', op: 'insert' },
CalendarEventDeleted: { appId: 'calendar', collection: 'events', op: 'delete' },
DrinkLogged: { appId: 'drink', collection: 'drinkEntries', op: 'insert' },
HabitLogged: { appId: 'habits', collection: 'habitLogs', op: 'insert' },
HabitCreated: { appId: 'habits', collection: 'habits', op: 'insert' },
JournalEntryCreated: { appId: 'journal', collection: 'journalEntries', op: 'insert' },
NoteCreated: { appId: 'notes', collection: 'notes', op: 'insert' },
ContactCreated: { appId: 'contacts', collection: 'contacts', op: 'insert' },
PlaceVisited: { appId: 'places', collection: 'places', op: 'update' },
MealLogged: { appId: 'nutriphi', collection: 'meals', op: 'insert' },
};
let unsubscribe: (() => void) | null = null;
/**
* Start bridging domain events to the legacy trigger system.
* Call once at app startup (after loadAutomations).
*/
export function startEventBridge(): void {
if (unsubscribe) return;
unsubscribe = eventBus.onAny((event: DomainEvent) => {
const mapping = EVENT_MAP[event.type];
if (!mapping) return;
// Pass the domain event payload as the trigger data.
// This is decrypted (unlike the Dexie hook data), so
// condition matching on encrypted fields (title, etc.) works.
fire(mapping.appId, mapping.collection, mapping.op, event.payload as Record<string, unknown>);
});
}
export function stopEventBridge(): void {
unsubscribe?.();
unsubscribe = null;
}

View file

@ -7,11 +7,13 @@
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
import { initTools } from '$lib/data/tools/init';
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import NudgeToast from '$lib/components/NudgeToast.svelte';
import { locale, _ } from 'svelte-i18n';
import {
PillNavigation,
@ -421,6 +423,7 @@
initSharedUload();
startEventStore();
initTools();
startEventBridge();
await dashboardStore.initialize();
// Start the persistent LLM task queue. Idempotent — safe to call
@ -522,6 +525,7 @@
unifiedSync?.stopAll();
reminderScheduler.stop();
stopEventStore();
stopEventBridge();
guestMode?.destroy();
// Fire-and-forget — we don't need to await; the in-flight task
// will finish in the background and the next page session will
@ -702,6 +706,12 @@
<SuggestionToast />
</div>
<!-- Companion Brain pulse nudges — water reminders, streak
warnings, morning summary etc. Self-gates on active nudges. -->
<div class="bottom-stack-notification">
<NudgeToast />
</div>
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
{#if isQuickInputVisible}
<QuickInputBar