/* ========================================================= CONNIE - main app (voice-wired against the connie-app web API) Replaces the original Claude-Design simulation (runScenario) with the real loop: Web Speech (de-DE) -> POST /web/turn -> POST /web/tts -> mp3. Same state machine (ready | listening | thinking | speaking | error), same Orb/Panels/Waveform. Echo-guard: the mic never listens while Connie's audio is playing (SR-VM-4). ========================================================= */ const { useState: aState, useEffect: aEffect, useRef: aRef } = React; // ---------- Backend config (from gitignored config.js) ---------- // nullish-coalesce, not ||, so an explicit empty string CONNIE_API_BASE="" (same- // origin, used by the WG-served Caddy build at connie.vpn.conventic.com) is // preserved instead of silently falling back to the local-tunnel default. const API_BASE = (window.CONNIE_API_BASE ?? "http://127.0.0.1:8081").replace(/\/+$/, ""); const WEB_TOKEN = window.CONNIE_WEB_TOKEN || ""; const TURN_TIMEOUT_MS = 60000; // ---------- Layout/settings persistence (dashboard-v3) ---------- const LAYOUT = window.ConnieLayout; // from layout.jsx (window-global, no build) // Card registry: stable id -> column + German title (for the hidden-list). The // render function lives in App (cards take different live props). The central // Orb/core is intentionally NOT a card here - it can never be hidden (#8). // // S3: a 4-column "2 | Orb | 2" widescreen layout. The natural distribution of // the 9 cards (F1): left | left2 | Orb | right | right2. The column names // "left"/"right" are unchanged from the old 2-column model so a stored layout // keeps working; "left2"/"right2" are the two new columns. Per-column order in // COLUMN_CARD_IDS is the natural fallback order (drag overrides it per card). const COLUMN_CARD_IDS = { left: ["schedule", "weather"], left2: ["stocks", "pipeline", "billomat"], right: ["approvals", "workflow", "memory", "reel"], right2: ["working_memory", "activity", "transcript"], }; const CARD_TITLES = { schedule: "Heute", weather: "Wetter", stocks: "Aktien", pipeline: "Pipeline · HubSpot", billomat: "Billomat · Offen", approvals: "Freigaben", memory: "Gedächtnis · Graph", workflow: "Workflows", reel: "Reel · Video", working_memory: "Arbeitsgedächtnis · Live", activity: "Aktivität · Live", transcript: "Transkript", }; // Short German label per column - shown in the column header and, rotated, on a // collapsed column's strip. Neutral ordinals because cards are freely draggable // between columns, so a semantic label ("Heute") would lie once a card moves. const COLUMN_LABELS = { left: "Spalte 1", left2: "Spalte 2", right: "Spalte 3", right2: "Spalte 4", }; // Natural column for every card (cardId -> column name). This is the fallback // the layout engine uses when a card has no saved column override. New cards // added here automatically appear in their natural column (robust vs old state). const NATURAL_COLS = (() => { const m = {}; for (const [col, ids] of Object.entries(COLUMN_CARD_IDS)) { ids.forEach((id) => { m[id] = col; }); } return m; })(); // ---------- Quick-action prompts (sent verbatim to Connie) ---------- const QUICK_PROMPTS = { briefing: "Gib mir bitte mein Tagesbriefing.", draft_mail: "Ich möchte eine E-Mail entwerfen. Frag mich bitte zuerst, an wen sie gehen soll und worum es geht.", find_deal: "Such mir Deals. Frag mich bitte, zu welchem Kunden oder Thema.", prep_meeting: "Bereite mein nächstes anstehendes Meeting vor.", }; // ---------- Spoken morning briefing (sent verbatim to Connie) ---------- // A relaxed, warm spoken briefing for the start of the day. It is read aloud, // so the prompt forbids Markdown / lists / numbers / links and asks for whole // spoken sentences with naturally spoken times and dates. Connie duzt Bernd // (no Sie-form). Tools named explicitly so she pulls the right data: // calendar_agenda (all calendars + birthdays via title scan) and tasks_open // against the "Connie Follow-ups" list. Nothing is invented when empty. const MORNING_BRIEFING_PROMPT = [ "Sprich mir ein kurzes, entspanntes Morgen-Briefing - so, als würdest du mir beim ersten Kaffee erzählen, was heute ansteht.", "Beginne mit einer kurzen, warmen Begrüßung und dem heutigen Datum.", "Schau dir dann meine heutigen Termine über ALLE meine Kalender an - benutze dafür das Tool calendar_agenda.", "Nenne danach meine offenen To-dos aus meiner Google-Tasks-Liste \"Connie Follow-ups\" (Tool tasks_open).", "Erwähne anstehende Geburtstage der nächsten Tage, falls welche dabei sind - die findest du im Feld birthdays von calendar_agenda.", "Schließe locker und freundlich ab.", "Ganz wichtig: Das wird laut vorgelesen. Sprich also in ganzen Sätzen als Fließtext, KEINE Aufzählungen, keine Listen, keine Nummerierung, keine Links, kein Markdown.", "Sag Uhrzeiten und Daten natürlich gesprochen, also \"um zehn Uhr\" statt \"10:00\" und \"am Mittwoch, dem vierten Juni\" statt einem Datum mit Bindestrichen.", "Halte es kurz, ungefähr dreißig bis sechzig Sekunden. Duze mich (kein Sie), nett und natürlich, ohne Floskeln.", "Wenn nichts ansteht - keine Termine, keine To-dos, keine Geburtstage - dann sag das kurz und positiv und erfinde nichts.", ].join(" "); // True between 05:00 and 11:00 local time (the morning window in which the // "Guten Morgen" button is shown). The "g" shortcut and pills work anytime. function isMorningWindow() { const h = new Date().getHours(); return h >= 5 && h < 11; } // ---------- Map a tool-call name to a friendly German activity label ---------- // Two label flavors: // - toolLabel(name) - completed-action ("HubSpot abgefragt") -> Activity log // - toolLabelActive(name) - present-progressive ("Frage HubSpot ab") -> live status // under the orb while the tool is running function toolLabel(name) { const n = (name || "").replace(/^mcp__conventic__/, ""); const rules = [ [/^gmail_(send|draft|update_draft|delete_draft|reply|forward)/, "Mail vorbereitet"], [/^gmail_(search|latest|get|read|list|extract_attachment|download)/, "Postfach durchsucht"], [/^gmail/, "Gmail bearbeitet"], [/^calendar_(create|update|delete|move)/, "Termin eingetragen"], [/^calendar/, "Kalender gelesen"], [/^tasks_(create|update|delete|complete)/, "Aufgabe gespeichert"], [/^tasks/, "Aufgaben gelesen"], [/^hubspot_.*_(create|update|delete|associate|add_note)/, "HubSpot aktualisiert"], [/^hubspot/, "HubSpot abgefragt"], [/^kg_remember/, "Im Gedächtnis abgelegt"], [/^kg_(search|query|neighbors|diff|health|recent)/, "Gedächtnis durchsucht"], [/^kg_/, "Gedächtnis verwendet"], [/^drive/, "Drive durchsucht"], [/^web_search/, "Im Web gesucht"], [/^web_fetch/, "Webseite gelesen"], [/^web_/, "Web abgerufen"], [/^llm_/, "Modell befragt"], [/^telegram_send_voice/, "Sprachnachricht gesendet"], [/^telegram_send/, "Telegram-Nachricht gesendet"], [/^telegram/, "Telegram"], [/^workflow_/, "Workflow ausgeführt"], [/^market_/, "Börsenkurs geholt"], [/^location_/, "Standort gelesen"], [/^weather/, "Wetter abgefragt"], ]; for (const [re, label] of rules) if (re.test(n)) return label; return n.replace(/_/g, " "); } function toolLabelActive(name) { const n = (name || "").replace(/^mcp__conventic__/, ""); const rules = [ [/^gmail_(send|draft|update_draft|reply|forward)/, "Schreibe die Mail"], [/^gmail_delete_draft/, "Lösche den Entwurf"], [/^gmail_(search|latest|get|read|list)/, "Frage den Mail-Server ab"], [/^gmail_(extract_attachment|download)/, "Lese den Mail-Anhang"], [/^gmail/, "Bearbeite Gmail"], [/^calendar_(create|update|move)/, "Trage den Termin ein"], [/^calendar_delete/, "Lösche den Termin"], [/^calendar/, "Schaue in den Kalender"], [/^tasks_(create|update|complete)/, "Speichere die Aufgabe"], [/^tasks_delete/, "Lösche die Aufgabe"], [/^tasks/, "Schaue in die Aufgaben"], [/^hubspot_.*_(create|update|associate|add_note)/, "Schreibe nach HubSpot"], [/^hubspot_.*_delete/, "Lösche in HubSpot"], [/^hubspot/, "Frage HubSpot ab"], [/^kg_remember/, "Lege es im Gedächtnis ab"], [/^kg_(search|query|neighbors|recent)/, "Durchsuche das Gedächtnis"], [/^kg_(health|diff)/, "Prüfe das Gedächtnis"], [/^kg_/, "Greife auf das Gedächtnis zu"], [/^drive/, "Durchsuche Drive"], [/^web_search/, "Suche im Web"], [/^web_fetch/, "Lese die Webseite"], [/^web_/, "Rufe Web-Daten ab"], [/^llm_/, "Frage das Modell"], [/^telegram_send_voice/, "Schicke Sprachnachricht"], [/^telegram_send/, "Schicke die Nachricht"], [/^telegram/, "Bearbeite Telegram"], [/^workflow_/, "Starte den Workflow"], [/^market_/, "Hole den Börsenkurs"], [/^location_/, "Lese den Standort"], [/^weather/, "Hole das Wetter"], ]; for (const [re, label] of rules) if (re.test(n)) return label; return "Arbeite an " + n.replace(/_/g, " "); } // ---------- Helpers ---------- function nowTime() { return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); } function errorMessage(status) { if (status === "no-token") return "Kein Web-Token gesetzt. Bitte config.js befüllen (Token vom Server)."; if (status === 401) return "Authentifizierung fehlgeschlagen. Token in config.js prüfen."; if (status === 503) return "Web-Kanal am Server nicht aktiv (kein Token konfiguriert)."; if (status === "network") return "Connie ist gerade still (Netz oder langer Tool-Call). Bitte nochmal probieren."; return "Es gab ein Problem bei der Anfrage. Bitte noch einmal versuchen."; } // ---------- Clean Connie's reply for the speech synthesizer ---------- // Strips Markdown / list markers / numbering that the TTS would otherwise read // out as "Stern", "Raute", "eins Punkt" etc., and removes URLs so the synth // never spells out "h-t-t-p-s-colon-slash-slash ...". Conservative: only // symbols, leading list/paragraph numbers and link tokens are touched - every // real WORD is kept. The on-screen transcript keeps the original text // (including the links); only the TTS payload is cleaned. function cleanForSpeech(text) { if (!text) return ""; let t = String(text); // Markdown links [label](url) -> label (do this before any URL stripping so // the human-readable label survives and only the bare url would be removed). t = t.replace(/\[([^\]]+)\]\((?:[^)]*)\)/g, "$1"); // URLs -> a short speakable placeholder, so the synthesizer doesn't read out // the protocol/host/path character by character. Three shapes, widest first: // 1. http(s)://... and ftp://... (scheme-qualified, run to whitespace) // 2. www.... (no scheme but an unmistakable host) // 3. bare / (host WITH a path, so a normal // end-of-sentence "...im Graphen." is NOT mistaken for a domain) // E-mail addresses are intentionally left untouched (the URL is the focus; // a name@domain reads acceptably and a greedy rule would eat normal words). t = t.replace(/\b(?:https?|ftp):\/\/[^\s]+/gi, " (Link) "); t = t.replace(/\bwww\.[^\s]+/gi, " (Link) "); t = t.replace( /\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.[a-z]{2,}\/[^\s]*/gi, " (Link) ", ); // Process line by line so leading markers/numbers anchor to ^. const lines = t.split(/\r?\n/).map((line) => { let l = line; // Leading heading hashes: "## Titel" -> "Titel". l = l.replace(/^\s{0,3}#{1,6}\s+/, ""); // Leading blockquote markers. l = l.replace(/^\s{0,3}>\s?/, ""); // Leading bullet markers: -, *, +, • , · , – followed by a space. l = l.replace(/^\s*[-*+•·–]\s+/, ""); // Leading list/paragraph numbers: "1. ", "2) ", "10 - ". l = l.replace(/^\s*\d{1,3}[.)]\s+/, ""); l = l.replace(/^\s*\d{1,3}\s+[-–]\s+/, ""); return l; }); t = lines.join("\n"); // Inline emphasis / code markers (keep the inner words). t = t.replace(/\*\*([^*]+)\*\*/g, "$1"); // **bold** t = t.replace(/\*([^*]+)\*/g, "$1"); // *italic* // Underscore emphasis only at word boundaries, so snake_case identifiers // like open_deals / kg_remember keep their inner underscores. t = t.replace(/(^|[^\w])__([^_]+)__(?=[^\w]|$)/g, "$1$2"); // __bold__ t = t.replace(/(^|[^\w])_([^_]+)_(?=[^\w]|$)/g, "$1$2"); // _italic_ t = t.replace(/`{1,3}([^`]+)`{1,3}/g, "$1"); // `code` / ```code``` // Any stray leftover Markdown symbols that survived. Underscores are left // intact on purpose so identifiers like kg_remember stay readable. t = t.replace(/[`*#]+/g, ""); // Collapse whitespace into a speakable flow. t = t.replace(/[ \t]+/g, " "); t = t.replace(/\s*\n\s*/g, " "); t = t.replace(/\s{2,}/g, " "); return t.trim(); } // ---------- Map the /web/context payload to the card props ---------- // Backend already shaped this; here we only do display polish (time fallback // for all-day events, relative "last sync" label). function ctxAppointments(today) { if (!Array.isArray(today)) return []; const tones = ["teal", "lime", "neutral"]; return today.map((a, i) => ({ time: a.all_day ? "ganztags" : (a.time || "--:--"), title: a.title || "(kein Titel)", sub: a.all_day ? "ganztaegig" : "", tone: tones[i % tones.length], })); } function relSync(iso) { if (!iso) return "offline"; const then = new Date(iso).getTime(); if (!then || Number.isNaN(then)) return "offline"; const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); if (secs < 60) return "gerade eben"; const mins = Math.round(secs / 60); if (mins < 60) return `vor ${mins} Min`; const hrs = Math.round(mins / 60); return `vor ${hrs} Std`; } // ---------- Waveform bars (under speech) ---------- function Waveform({ active }) { const ref = aRef(null); aEffect(() => { if (!ref.current) return; const bars = Array.from(ref.current.querySelectorAll(".bar")); let raf = 0, t = 0; function tick() { t += 1; bars.forEach((b, i) => { const v = active ? 4 + Math.abs(Math.sin(t * 0.08 + i * 0.4) * Math.sin(t * 0.025 + i * 0.18)) * 22 : 3; b.style.height = `${v}px`; }); raf = requestAnimationFrame(tick); } const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; if (!reduced) raf = requestAnimationFrame(tick); else bars.forEach((b) => (b.style.height = "3px")); return () => cancelAnimationFrame(raf); }, [active]); return (
{Array.from({ length: 36 }).map((_, i) => )}
); } // ---------- Clock ---------- function Clock() { const [t, setT] = aState(new Date()); aEffect(() => { const id = setInterval(() => setT(new Date()), 30000); return () => clearInterval(id); }, []); const date = t.toLocaleDateString("de-DE", { day: "2-digit", month: "short" }); const time = t.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); return ( {date}{time} ); } const STATUS_LABELS = { ready: "Bereit", listening: "Ich höre zu", thinking: "Denke nach", speaking: "Spreche", error: "Hinweis", }; // ---------- Binaural ambient ---------- // Two oscillators panned hard L/R produce a binaural beat (200 + 207 Hz -> // 7 Hz alpha-ish beat). Lives idle until the user clicks the toggle (browser // autoplay policy requires a user gesture). When Connie is speaking the gain // ducks to 0 to avoid masking; restores on release. const BINAURAL_KEY = "connie.binaural.enabled"; const BINAURAL_FREQ_L = 200; const BINAURAL_FREQ_R = 207; const BINAURAL_TARGET_GAIN = 0.035; class BinauralPlayer { constructor() { this.ctx = null; this.gain = null; this.oscL = null; this.oscR = null; this._ducked = false; } start() { if (this.ctx) return; const C = window.AudioContext || window.webkitAudioContext; if (!C) return; this.ctx = new C(); const merger = this.ctx.createChannelMerger(2); this.oscL = this.ctx.createOscillator(); this.oscL.frequency.value = BINAURAL_FREQ_L; this.oscL.type = "sine"; this.oscL.connect(merger, 0, 0); this.oscR = this.ctx.createOscillator(); this.oscR.frequency.value = BINAURAL_FREQ_R; this.oscR.type = "sine"; this.oscR.connect(merger, 0, 1); this.gain = this.ctx.createGain(); this.gain.gain.value = 0; merger.connect(this.gain).connect(this.ctx.destination); this.oscL.start(); this.oscR.start(); this.gain.gain.linearRampToValueAtTime(BINAURAL_TARGET_GAIN, this.ctx.currentTime + 1.5); } stop() { if (!this.ctx) return; const ctx = this.ctx; const g = this.gain; const oL = this.oscL; const oR = this.oscR; this.ctx = null; this.gain = null; this.oscL = null; this.oscR = null; this._ducked = false; try { g.gain.cancelScheduledValues(ctx.currentTime); } catch (_) {} try { g.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.4); } catch (_) {} setTimeout(() => { try { oL.stop(); } catch (_) {} try { oR.stop(); } catch (_) {} try { ctx.close(); } catch (_) {} }, 500); } duck(yes) { if (!this.ctx || !this.gain) return; if (yes === this._ducked) return; this._ducked = yes; const target = yes ? 0 : BINAURAL_TARGET_GAIN; try { this.gain.gain.cancelScheduledValues(this.ctx.currentTime); this.gain.gain.linearRampToValueAtTime(target, this.ctx.currentTime + 0.25); } catch (_) {} } isRunning() { return !!this.ctx; } } // ---------- App ---------- function App() { const [state, setState] = aState("ready"); // ready | listening | thinking | speaking | error // Auth bootstrap (WB-Auth Phase 1 dual-accept, 2026-06-04). The frontend // calls /web/whoami on mount to learn who is logged in and via what // method (cookie or bearer). 401 means neither identity source resolved // -> redirect to /login. The whoami response also overrides // window.CONNIE_USER_NAME so a cookie-only deploy (no config.js) shows // the right name without a static config file. const [whoami, setWhoami] = aState(null); // null = pending, false = 401, {user,...} = ok aEffect(() => { let cancelled = false; (async () => { try { // We omit the Authorization header when the static bearer is empty; // the HttpOnly cookie travels automatically. When the bearer IS // set (legacy config.js path), we still send it for the dual-accept // window so existing deploys keep working. const headers = WEB_TOKEN ? { Authorization: `Bearer ${WEB_TOKEN}` } : {}; const resp = await fetch(`${API_BASE}/web/whoami`, { headers, credentials: "include" }); if (cancelled) return; if (resp.status === 401) { // Send the operator to the login page UNLESS they are already there // (defence vs accidental redirect-loops if /login itself fails). if (!window.location.pathname.startsWith("/login")) { window.location.assign("/login"); return; } setWhoami(false); return; } if (!resp.ok) { setWhoami(false); return; } const data = await resp.json(); setWhoami(data || false); } catch (_) { if (!cancelled) setWhoami(false); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Per-user greeting: prefer the whoami response (cookie-driven, dynamic); // fall back to config.js' static CONNIE_USER_NAME for the legacy bearer // path; fall back to a name-less Anrede so a missing value never miscalls // anyone. const USER_NAME = ((whoami && whoami.user) || window.CONNIE_USER_NAME || "").trim(); const ANREDE = USER_NAME ? USER_NAME.charAt(0).toUpperCase() + USER_NAME.slice(1) : "dir"; const canLogout = !!(whoami && whoami.can_logout); async function doLogout() { if (!canLogout) return; try { await fetch(`${API_BASE}/web/auth/logout`, { method: "POST", credentials: "include" }); } catch (_) { /* server-side cleanup is the security-relevant part; ignore network */ } window.location.assign("/login"); } // Local chat-history persistence (2026-06-02). Restores the visible // transcript across reloads / tab-closes / hard-resets so Bernd does not // lose the last conversation. Stored per-user (so Bernd and Sabine on the // same machine don't leak into each other). Server-side, connie-app keeps // a parallel 5+5 turn tail that re-seeds the SDK client after a container // restart - so even a 3am restart preserves the freshest context. const HISTORY_KEY = `connie.chat.history.${(USER_NAME || "default").toLowerCase()}`; const HISTORY_MAX = 200; function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); if (!raw) return null; const doc = JSON.parse(raw); if (!doc || !Array.isArray(doc.messages) || doc.messages.length === 0) return null; return doc.messages.slice(-HISTORY_MAX); } catch (_) { return null; } } function persistHistory(msgs) { try { const tail = (msgs || []).slice(-HISTORY_MAX); localStorage.setItem(HISTORY_KEY, JSON.stringify({ v: 1, messages: tail, updated_at: new Date().toISOString(), })); } catch (_) { /* quota exceeded / disabled */ } } // Initial greeting. In the single-URL cookie mode WEB_TOKEN is empty by // design; whoami fills the name shortly after mount. We greet generically // until whoami arrives and an effect below refreshes the line with the // resolved alias. const [spoken, setSpoken] = aState( "Guten Tag. Drücken Sie auf das Mikrofon und sprechen Sie mit mir." ); // Refresh greeting once whoami resolves so the dashboard shows the actual // logged-in user. aEffect(() => { if (whoami && whoami.user) { const name = whoami.user.charAt(0).toUpperCase() + whoami.user.slice(1); setSpoken(`Guten Tag, ${name}. Drücken Sie auf das Mikrofon und sprechen Sie mit mir.`); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [whoami]); const [showCaret, setShowCaret] = aState(false); // Live tool-status under the orb ("Frage den Mail-Server ab"). Cleared when // the tool ends or the turn finishes; surfaces what Connie is doing during // the inevitable Sonnet-tool-call wait so the orb is not silent for 5-15 s. const [currentTool, setCurrentTool] = aState(null); // Server-reported count of turns the agent still remembers (set by the // /web/turn end-event). If localStorage shows more than the server knows // we surface a drift-banner so Bernd knows Connie has forgotten the start. const [serverTurnCount, setServerTurnCount] = aState(null); const [restoredFromStorage, setRestoredFromStorage] = aState(false); const [driftBannerDismissed, setDriftBannerDismissed] = aState(false); const [messages, setMessages] = aState(() => { const restored = loadHistory(); if (restored) return restored; return [{ who: "connie", text: `Guten Tag, ${ANREDE}. Womit kann ich helfen?` }]; }); aEffect(() => { persistHistory(messages); }, [messages]); aEffect(() => { // One-shot: mark "this session was loaded from storage" so the drift // banner can decide whether the server tail is shorter than expected. if (loadHistory() !== null) setRestoredFromStorage(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function clearChatHistory() { try { localStorage.removeItem(HISTORY_KEY); } catch (_) {} setMessages([{ who: "connie", text: `Guten Tag, ${ANREDE}. Womit kann ich helfen?` }]); setRestoredFromStorage(false); setDriftBannerDismissed(false); } // Drift banner: UI was restored, server now reports a small tail (=> // container restarted), and the UI knows more assistant turns than the // server's tail. Auto-hides once the user dismissed it or asked enough // turns that the server tail catches up. const uiAssistantCount = messages.filter(m => m && m.who === "connie").length; const showDriftBanner = ( restoredFromStorage && !driftBannerDismissed && serverTurnCount !== null && serverTurnCount < uiAssistantCount && uiAssistantCount > 1 ); const [activity, setActivity] = aState([ { id: "init-1", text: "Frontend bereit", sub: `Backend ${API_BASE}`, status: "done", time: nowTime() }, ]); const [input, setInput] = aState(""); // Live context cards (left column). null = not loaded yet -> cards show "lade ...". const [today, setToday] = aState(null); const [pipeline, setPipeline] = aState(null); const [memory, setMemory] = aState(null); // Interactive graph (Gedaechtnis card). graphFocus is the opaque // "