From 98e32d8ac04e600b1afe0b8b3bf78e9022bda51f Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Sun, 17 May 2026 16:48:40 +0200 Subject: [PATCH] feat: implement R42 chat infrastructure with Anthropic API integration and custom design system --- AI_AGENT.md | 23 +- public/favicon.svg | 4 +- public/fonts/BallPill-light.otf | Bin 0 -> 47684 bytes src/App.jsx | 9 +- src/components/admin/KnowledgeGraph.jsx | 17 +- src/components/admin/SuggestionsQueue.jsx | 89 ++++ src/components/chat/ChatLauncher.jsx | 59 +++ src/components/chat/ChatMessage.jsx | 84 +++ src/components/chat/ChatWindow.jsx | 131 +++++ src/components/chat/chat.css | 233 +++++++++ src/components/chat/prompts.js | 110 ++++ src/components/chat/rag.js | 127 +++++ src/components/chat/useChat.js | 137 +++++ src/components/ui/Mark.jsx | 71 +++ src/index.css | 24 + src/lib/api.js | 55 ++ src/lib/kbStore.js | 117 +++++ src/pages/Testen.jsx | 26 + styles-r3x-v2.css | 589 ++++++++++++++++++++++ styles-r3x.css | 589 ++++++++++++++++++++++ styles.css | 143 ++++++ 21 files changed, 2631 insertions(+), 6 deletions(-) create mode 100644 public/fonts/BallPill-light.otf create mode 100644 src/components/admin/SuggestionsQueue.jsx create mode 100644 src/components/chat/ChatLauncher.jsx create mode 100644 src/components/chat/ChatMessage.jsx create mode 100644 src/components/chat/ChatWindow.jsx create mode 100644 src/components/chat/chat.css create mode 100644 src/components/chat/prompts.js create mode 100644 src/components/chat/rag.js create mode 100644 src/components/chat/useChat.js create mode 100644 src/components/ui/Mark.jsx create mode 100644 src/lib/kbStore.js create mode 100644 styles-r3x-v2.css create mode 100644 styles-r3x.css create mode 100644 styles.css diff --git a/AI_AGENT.md b/AI_AGENT.md index d1b7798..e3a4c65 100644 --- a/AI_AGENT.md +++ b/AI_AGENT.md @@ -30,6 +30,9 @@ All persistent data lives in **PocketBase**. The data access layer is in `src/li * `respellion:admin:anthropic_key` — Anthropic API key. * `respellion:admin:model` — Model override. * `respellion:admin:use_simulation` — Simulation mode toggle. +* `kb:suggestions` — Pending/approved/rejected graph deltas proposed by R42. Always mutated via `kbStore` (see §8). +* `quiz:active:{userId}` — Boolean flag set while the user is mid-quiz. R42's FAB is hidden when this is true (quiz-integrity rule). +* `chat:thread:{userId}` — Persisted R42 conversation, capped at 50 messages. **Session:** User login is PIN-based. The logged-in user's ID is stored in `sessionStorage` under `respellion_session` and resolved against `team_members` on app load (see `src/store/AppContext.jsx`). @@ -65,7 +68,25 @@ The app is fully containerized. PocketBase runs as a sidecar service. * **Nginx:** Ensures any frontend route not matching a static file falls back to `index.html` (SPA support). The `/api/anthropic` path is proxied to the Anthropic API. The `/pb` path is proxied to the PocketBase service. * **PocketBase URL:** Resolved from `VITE_PB_URL` env var, or falls back to `window.location.origin + '/pb'` (see `src/lib/pb.js`). -## 7. How to Add New Features +## 8. R42 Chatbot +The platform ships a global chatbot avatar called **R42**, rendered as the Respellion `{ r }` brand mark in three states (idle / typing / error). + +* **Mark component:** `src/components/ui/Mark.jsx`. Pure SVG; renders the brand mark with `state`, `size`, `theme`, `showFrame`, `brace`, `letter` props. Requires the `BallPill` font (loaded via `@font-face` in `src/index.css`, served from `public/fonts/BallPill-light.otf`). Falls back to JetBrains Mono (already loaded). +* **Chat module:** `src/components/chat/`. + * `ChatLauncher.jsx` — global FAB; auto-hides when `quiz:active:{userId}` is set. Listens to the `respellion:quiz-state` window event for fast updates. + * `ChatWindow.jsx` — 380×480 chat panel; Esc closes; renders messages from `useChat`. + * `useChat.js` — owns the message list, persists to `chat:thread:{userId}`, calls `anthropicApi.chat()`. `buildKbContext` is async (PocketBase), so `send()` is fully async. + * `prompts.js` — system prompt, greeting, and the `propose_graph_delta` tool spec. + * `rag.js` — fetches `kb:topics` + `kb:relations` from PocketBase; lazy-loads `kb:content:{id}` only when a topic is mentioned. Returns `{ context, topics }` so `validateDelta` can reuse the fetched topics without a second round-trip. +* **Multi-turn API:** `anthropicApi.chat(systemPrompt, messages, { tools })` in `src/lib/api.js`. Returns the raw Anthropic response (`{ content: [...], stop_reason }`) so callers can read both text blocks and `tool_use` blocks. No API key header — Caddy proxy injects it server-side, matching the existing `generateContent` pattern. +* **Quiz-integrity rule:** `src/pages/Testen.jsx` sets `quiz:active:{userId}=true` on start and clears it on every non-quiz phase + unmount, plus dispatches a `respellion:quiz-state` event. Never bypass this — letting users ask R42 mid-quiz would break scoring. +* **Graph refinement:** when R42's `tool_use` block proposes a `propose_graph_delta`, `rag.js` validates (no duplicate ids, no orphan relations, caps 3 topics / 5 relations) and surfaces a confirmation chip inline. + * **Admin user clicks Ja** — `kbStore.applyDelta` writes to PocketBase via `db.upsertTopic` / `db.addRelation` immediately. + * **Non-admin clicks Ja** — `kbStore.appendSuggestion` queues an entry in `kb:suggestions` localStorage (status `pending`). +* **Admin approval UI:** `src/components/admin/SuggestionsQueue.jsx`, mounted at the top of the Knowledge Graph admin panel. Approve calls `kbStore.approveSuggestion(id)` which runs the same `applyDelta` merge (async, PocketBase) and flips status to `approved`. Reject flips to `rejected` for audit. +* **kbStore:** `src/lib/kbStore.js` is the single source of truth for KB mutations from the chatbot path. Topics/relations go to PocketBase; suggestions queue goes to localStorage. Dispatches `respellion:kb-updated` after any write so the D3 graph and the queue panel refresh without a reload. + +## 9. How to Add New Features 1. **Define Schema:** Add a new PocketBase collection via the Admin UI or the init script (`scripts/setup-pb-collections.mjs`). 2. **Add DB Helpers:** Add async CRUD functions in `src/lib/db.js`. 3. **Build UI:** Create components using `Card`, `Button`, and `framer-motion` for smooth entry (`animate-in fade-in slide-in-from-bottom-4`). diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..7730e4f 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,3 @@ - \ No newline at end of file + + {r} + diff --git a/public/fonts/BallPill-light.otf b/public/fonts/BallPill-light.otf new file mode 100644 index 0000000000000000000000000000000000000000..058229a6dac7ac7be204d75fcbb010a5d4335707 GIT binary patch literal 47684 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Ũ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 literal 0 HcmV?d00001 diff --git a/src/App.jsx b/src/App.jsx index 06f1378..29d14ba 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,8 @@ import React from 'react' import { Routes, Route, Navigate, Link } from 'react-router-dom' import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react' import { useApp } from './store/AppContext' +import Mark from './components/ui/Mark' +import ChatLauncher from './components/chat/ChatLauncher' import Login from './pages/Login' import Dashboard from './pages/Dashboard' @@ -29,8 +31,9 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
{/* Top Navigation Bar */}
) } diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx index 101b330..99a3488 100644 --- a/src/components/admin/KnowledgeGraph.jsx +++ b/src/components/admin/KnowledgeGraph.jsx @@ -1,9 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react'; import * as db from '../../lib/db'; import { anthropicApi } from '../../lib/api'; import Button from '../ui/Button'; +import SuggestionsQueue from './SuggestionsQueue'; const KnowledgeGraph = () => { const svgRef = useRef(null); @@ -19,11 +20,18 @@ const KnowledgeGraph = () => { const [topics, setTopics] = useState([]); const [relations, setRelations] = useState([]); - useEffect(() => { + const reloadKb = useCallback(() => { db.getTopics().then(setTopics); db.getRelations().then(setRelations); }, []); + useEffect(() => { + reloadKb(); + const handler = () => reloadKb(); + window.addEventListener('respellion:kb-updated', handler); + return () => window.removeEventListener('respellion:kb-updated', handler); + }, [reloadKb]); + useEffect(() => { if (!wrapperRef.current) return; const { width, height } = wrapperRef.current.getBoundingClientRect(); @@ -290,6 +298,11 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str )} + {/* R42 chatbot suggestions queue */} +
+ +
+

Node Details

{selectedNode && !isEditing && ( diff --git a/src/components/admin/SuggestionsQueue.jsx b/src/components/admin/SuggestionsQueue.jsx new file mode 100644 index 0000000..24d620a --- /dev/null +++ b/src/components/admin/SuggestionsQueue.jsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from 'react'; +import { Check, X, Clock, Sparkles } from 'lucide-react'; +import { kbStore } from '../../lib/kbStore'; +import Button from '../ui/Button'; + +/** + * Admin sub-panel inside the Knowledge Graph view. Shows pending R42 chatbot + * suggestions with approve / reject controls. + */ +export default function SuggestionsQueue({ onApplied }) { + const [pending, setPending] = useState([]); + + const refresh = () => setPending(kbStore.listSuggestions('pending')); + + useEffect(() => { + refresh(); + const onChange = () => refresh(); + window.addEventListener('respellion:kb-updated', onChange); + return () => window.removeEventListener('respellion:kb-updated', onChange); + }, []); + + if (pending.length === 0) { + return ( +
+ Geen openstaande voorstellen van R42. +
+ ); + } + + return ( +
+
+ R42-voorstellen ({pending.length}) +
+ {pending.map(s => ( +
+
+
+ + {new Date(s.ts).toLocaleString()} + {s.proposedByName && <> · door {s.proposedByName}} +
+
+ {s.reason &&

{s.reason}

} + {(s.topics?.length > 0 || s.relations?.length > 0) && ( +
    + {s.topics?.map(t => ( +
  • + {t.label}{' '} + ({t.type}) + {t.description && <> — {t.description}} +
  • + ))} + {s.relations?.map((r, i) => ( +
  • + {r.source}{' → '} + {r.type}{' → '} + {r.target} +
  • + ))} +
+ )} +
+ + +
+
+ ))} +
+ ); +} diff --git a/src/components/chat/ChatLauncher.jsx b/src/components/chat/ChatLauncher.jsx new file mode 100644 index 0000000..2620553 --- /dev/null +++ b/src/components/chat/ChatLauncher.jsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import Mark from '../ui/Mark'; +import ChatWindow from './ChatWindow'; +import { useApp } from '../../store/AppContext'; +import { storage } from '../../lib/storage'; +import { STRINGS } from './prompts'; +import './chat.css'; + +const QUIZ_EVENT = 'respellion:quiz-state'; + +/** + * Global floating launcher. Hidden while a quiz is active (set via + * the `respellion:quiz-state` window event from Testen.jsx) to protect + * quiz integrity. + */ +export default function ChatLauncher() { + const { state } = useApp(); + const user = state.currentUser; + const isAdmin = user?.role === 'admin'; + + const [open, setOpen] = useState(false); + const [quizActive, setQuizActive] = useState(() => { + if (!user) return false; + return storage.get(`quiz:active:${user.id}`, false) === true; + }); + + useEffect(() => { + if (!user) return; + setQuizActive(storage.get(`quiz:active:${user.id}`, false) === true); + }, [user]); + + useEffect(() => { + const handler = (e) => { + setQuizActive(Boolean(e?.detail?.active)); + if (e?.detail?.active) setOpen(false); + }; + window.addEventListener(QUIZ_EVENT, handler); + return () => window.removeEventListener(QUIZ_EVENT, handler); + }, []); + + if (!user || quizActive) return null; + + return ( + <> + {open && ( + setOpen(false)} /> + )} + + + ); +} diff --git a/src/components/chat/ChatMessage.jsx b/src/components/chat/ChatMessage.jsx new file mode 100644 index 0000000..34bff24 --- /dev/null +++ b/src/components/chat/ChatMessage.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import Mark from '../ui/Mark'; +import { STRINGS } from './prompts'; + +/** + * Renders one message bubble. Assistant messages may include a `suggestion` + * (validated graph delta) — the user confirms or rejects inline. + */ +export default function ChatMessage({ msg, onAcceptSuggestion, onRejectSuggestion }) { + if (msg.role === 'user') { + return ( +
+
{msg.content}
+
+ ); + } + + if (msg.role === 'error') { + return ( +
+
+ +
+
{msg.content}
+
+ ); + } + + // assistant + const s = msg.suggestion; + return ( +
+
+ +
+
+
{msg.content}
+ {s && ( +
+
{STRINGS.suggestionTitle}
+ {s.reason &&
{s.reason}
} + {(s.topics.length > 0 || s.relations.length > 0) && ( +
    + {s.topics.map(t => ( +
  • + {t.label}{' '} + ({t.type}) +
  • + ))} + {s.relations.map((r, i) => ( +
  • + {r.source}{' '} + {r.type}{' → '} + {r.target} +
  • + ))} +
+ )} + {s.status === 'pending' ? ( +
+ + +
+ ) : ( +
+ {s.status === 'applied' && STRINGS.suggestionAppliedAdmin} + {s.status === 'queued' && STRINGS.suggestionQueuedUser} + {s.status === 'rejected' && STRINGS.suggestionDismissed} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/chat/ChatWindow.jsx b/src/components/chat/ChatWindow.jsx new file mode 100644 index 0000000..061f3a0 --- /dev/null +++ b/src/components/chat/ChatWindow.jsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Mark from '../ui/Mark'; +import ChatMessage from './ChatMessage'; +import { useChat } from './useChat'; +import { kbStore } from '../../lib/kbStore'; +import { BOT_NAME, STRINGS } from './prompts'; + +export default function ChatWindow({ user, isAdmin, onClose }) { + const { messages, isThinking, send } = useChat({ user, isAdmin }); + const [draft, setDraft] = useState(''); + const bodyRef = useRef(null); + const inputRef = useRef(null); + const [decided, setDecided] = useState({}); // { [msgId]: 'applied'|'queued'|'rejected' } + + // Scroll to bottom when new messages arrive + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.scrollTop = bodyRef.current.scrollHeight; + } + }, [messages, isThinking]); + + // Focus the input on open + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Close on Escape + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') onClose?.(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!draft.trim() || isThinking) return; + send(draft); + setDraft(''); + }; + + const handleAccept = useCallback(async (msgId) => { + const msg = messages.find(m => m.id === msgId); + if (!msg?.suggestion) return; + if (isAdmin) { + await kbStore.applyDelta(msg.suggestion); + setDecided(prev => ({ ...prev, [msgId]: 'applied' })); + } else { + kbStore.appendSuggestion({ + ...msg.suggestion, + proposedBy: user?.id, + proposedByName: user?.name, + }); + setDecided(prev => ({ ...prev, [msgId]: 'queued' })); + } + }, [messages, isAdmin, user]); + + const handleReject = useCallback((msgId) => { + setDecided(prev => ({ ...prev, [msgId]: 'rejected' })); + }, []); + + const renderedMessages = messages.map(m => { + if (!m.suggestion) return m; + const status = decided[m.id] || m.suggestion.status || 'pending'; + return { ...m, suggestion: { ...m.suggestion, status } }; + }); + + return ( +
+
+
+ +
+
+
{BOT_NAME}
+
{STRINGS.status}
+
+ +
+ +
+ {renderedMessages.map(m => ( + + ))} + {isThinking && ( +
+
+ +
+
+ +
+
+ )} +
+ +
+ setDraft(e.target.value)} + placeholder={STRINGS.placeholder} + disabled={isThinking} + aria-label={STRINGS.placeholder} + /> + +
+
+ ); +} diff --git a/src/components/chat/chat.css b/src/components/chat/chat.css new file mode 100644 index 0000000..e5a0691 --- /dev/null +++ b/src/components/chat/chat.css @@ -0,0 +1,233 @@ +/* R42 chatbot — FAB + chat window */ + +.r42-fab { + position: fixed; + right: 24px; + bottom: 80px; + width: 64px; + height: 64px; + border-radius: var(--r-pill); + background: var(--teal-900); + display: grid; + place-items: center; + box-shadow: var(--shadow-pill); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 2px solid var(--cream); + z-index: 60; + padding: 0; +} +.r42-fab:hover { transform: translateY(-2px); } +.r42-fab:focus-visible { + outline: 2px solid var(--purple); + outline-offset: 3px; +} +.r42-fab.open { + bottom: calc(80px + 480px - 56px); + width: 48px; + height: 48px; +} + +@media (min-width: 768px) { + .r42-fab { bottom: 24px; } + .r42-fab.open { bottom: calc(24px + 480px - 56px); } +} + +.r42-window { + position: fixed; + right: 24px; + bottom: 80px; + width: min(380px, calc(100vw - 32px)); + height: min(480px, calc(100vh - 120px)); + background: var(--paper); + border-radius: var(--r-md); + border: 1px solid rgba(31, 85, 96, 0.06); + box-shadow: var(--shadow-pill); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 60; +} +@media (min-width: 768px) { + .r42-window { bottom: 24px; } +} + +.r42-window-hd { + background: var(--teal-900); + color: var(--cream); + padding: var(--s-3) var(--s-4); + display: flex; + align-items: center; + gap: var(--s-3); + flex-shrink: 0; +} +.r42-window-hd .av { + width: 36px; + height: 36px; + border-radius: var(--r-pill); + background: var(--teal); + display: grid; + place-items: center; + flex-shrink: 0; +} +.r42-window-hd-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.r42-window-hd-name { font: 600 14px/1 var(--font-sans); } +.r42-window-hd-status { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(236, 233, 233, 0.6); + display: flex; + align-items: center; + gap: 6px; +} +.r42-window-hd-status i { + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--sage); +} +.r42-window-hd-x { + margin-left: auto; + color: rgba(236, 233, 233, 0.7); + background: transparent; + border: none; + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--r-sm); +} +.r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); } + +.r42-window-body { + flex: 1; + padding: var(--s-4); + background: var(--cream); + display: flex; + flex-direction: column; + gap: var(--s-3); + overflow-y: auto; +} + +.r42-msg { display: flex; gap: var(--s-2); align-items: flex-end; } +.r42-msg .av-sm { + width: 26px; + height: 26px; + border-radius: var(--r-pill); + background: var(--teal-900); + display: grid; + place-items: center; + flex-shrink: 0; +} +.r42-msg .bub { + background: var(--paper); + border-radius: 16px 16px 16px 4px; + padding: 10px 14px; + font: 400 14px/1.45 var(--font-sans); + color: var(--ink); + max-width: 250px; + border: 1px solid rgba(31, 85, 96, 0.06); + word-wrap: break-word; +} +.r42-msg.me { justify-content: flex-end; } +.r42-msg.me .bub { + background: var(--teal); + color: var(--cream); + border: none; + border-radius: 16px 16px 4px 16px; +} +.r42-msg.error .bub { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.r42-suggestion { + margin-top: var(--s-2); + padding: 10px 12px; + background: rgba(92, 29, 216, 0.08); + border: 1px solid rgba(92, 29, 216, 0.2); + border-radius: var(--r-sm); + font: 400 13px/1.4 var(--font-sans); + color: var(--ink); +} +.r42-suggestion-title { + font-weight: 600; + color: var(--purple); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 4px; +} +.r42-suggestion-items { + margin: 6px 0 10px; + padding-left: 16px; + list-style: disc; +} +.r42-suggestion-items li { margin: 2px 0; } +.r42-suggestion-actions { + display: flex; + gap: 6px; +} +.r42-suggestion-actions button { + flex: 1; + padding: 6px 10px; + border-radius: var(--r-pill); + border: 1px solid rgba(92, 29, 216, 0.3); + font: 600 12px/1 var(--font-sans); + cursor: pointer; + background: var(--paper); + color: var(--purple); + transition: background 0.15s ease, color 0.15s ease; +} +.r42-suggestion-actions button.primary { + background: var(--purple); + color: var(--cream); + border-color: var(--purple); +} +.r42-suggestion-actions button.primary:hover { background: var(--purple-700); } +.r42-suggestion-actions button:not(.primary):hover { background: rgba(92, 29, 216, 0.1); } +.r42-suggestion-status { + margin-top: 6px; + font: 500 11px/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--fg-muted); +} + +.r42-window-input { + padding: var(--s-3) var(--s-4); + background: var(--paper); + border-top: 1px solid rgba(31, 85, 96, 0.06); + display: flex; + align-items: center; + gap: var(--s-2); + flex-shrink: 0; +} +.r42-window-input input { + flex: 1; + background: var(--cream); + border: none; + outline: none; + border-radius: var(--r-pill); + padding: 10px 14px; + font: 400 14px/1 var(--font-sans); + color: var(--ink); + min-width: 0; +} +.r42-window-input input::placeholder { color: rgba(60, 58, 58, 0.4); } +.r42-window-input input:disabled { opacity: 0.5; } +.r42-window-input button { + background: var(--purple); + color: var(--cream); + border: none; + border-radius: var(--r-pill); + padding: 10px 18px; + font: 600 13px/1 var(--font-sans); + cursor: pointer; + transition: background 0.15s ease; + flex-shrink: 0; +} +.r42-window-input button:hover:not(:disabled) { background: var(--purple-700); } +.r42-window-input button:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/src/components/chat/prompts.js b/src/components/chat/prompts.js new file mode 100644 index 0000000..c771970 --- /dev/null +++ b/src/components/chat/prompts.js @@ -0,0 +1,110 @@ +/** + * R42 — system prompts, greeting, and the propose_graph_delta tool spec. + * All user-facing strings live here so they can be localised in one place. + */ + +export const BOT_NAME = 'R42'; + +export const STRINGS = { + greeting: (name) => + `Hoi ${name}, ik ben R42 — vraag iets over je weekonderwerp, of laat me uitleggen wat je in de quiz tegenkwam.`, + placeholder: `Vraag R42 iets…`, + send: 'Stuur', + status: 'online · antwoordt direct', + errorGeneric: 'Sorry, ik kon dat niet beantwoorden. Probeer het nog eens.', + errorNoKey: 'Er is geen API-sleutel ingesteld. Vraag een beheerder om hem toe te voegen in Admin → Settings.', + suggestionTitle: 'Ik hoorde iets nieuws — voeg toe aan de kennisgraaf?', + suggestionAccept: 'Ja, voeg toe', + suggestionReject: 'Nee, sla over', + suggestionAppliedAdmin: 'Toegevoegd aan de graaf.', + suggestionQueuedUser: 'Genoteerd — ik geef het door aan een beheerder.', + suggestionDismissed: 'Oké, niets gedaan.', + closeAria: 'Sluit chatvenster', + openAria: 'Open R42 chatbot', +}; + +export function buildSystemPrompt({ userName, isAdmin, kbContext }) { + return [ + `Je bent R42, de chatbot-avatar van Respellion — een leerplatform voor microlearning, quizzen en kennisontwikkeling.`, + `Antwoord altijd in het Nederlands, kort en zakelijk-vriendelijk. Spreek de gebruiker aan met hun voornaam wanneer dat natuurlijk voelt (${userName}).`, + ``, + `JE TAKEN:`, + `1. Leg onderwerpen uit die in de kennisbasis staan.`, + `2. Help de gebruiker quizvragen begrijpen ná afloop (niet tijdens een actieve quiz).`, + `3. Verwijs bij twijfel terug naar het bronmateriaal of zeg eerlijk dat je het niet weet.`, + ``, + `JE KENNIS:`, + `Je kennis is beperkt tot de onderstaande Respellion-kennisgraaf. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`, + ``, + kbContext, + ``, + `KENNISGRAAF VERFIJNEN:`, + `Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`, + ``, + `STIJL:`, + `- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`, + `- Geen markdown-headers; gewone Nederlandse tekst.`, + `- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`, + isAdmin + ? `\nDe gebruiker is beheerder; voorstellen die de tool genereert worden direct toegepast.` + : `\nDe gebruiker is geen beheerder; voorstellen worden in een goedkeuringswachtrij gezet.`, + ].join('\n'); +} + +export const PROPOSE_GRAPH_DELTA_TOOL = { + name: 'propose_graph_delta', + description: + 'Stel toevoegingen aan de Respellion-kennisgraaf voor wanneer de gebruiker een topic, proces, rol of relatie noemt die nog niet bestaat. Gebruik dit alleen wanneer je iets nieuws en concreets hoort — niet voor algemene speculatie.', + input_schema: { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'Korte uitleg (1 zin, NL) waarom dit voorstel relevant is.', + }, + topics: { + type: 'array', + description: 'Maximaal 3 nieuwe topics.', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'kebab-case identifier, bv. "onboarding-buddy".', + }, + label: { + type: 'string', + description: 'Mens-leesbare naam.', + }, + type: { + type: 'string', + enum: ['concept', 'role', 'process'], + }, + description: { + type: 'string', + description: '1–2 zinnen Nederlandstalige beschrijving.', + }, + }, + required: ['id', 'label', 'type', 'description'], + }, + }, + relations: { + type: 'array', + description: 'Maximaal 5 nieuwe relaties.', + items: { + type: 'object', + properties: { + source: { type: 'string', description: 'topic id' }, + target: { type: 'string', description: 'topic id' }, + type: { + type: 'string', + enum: ['related_to', 'depends_on', 'part_of', 'executed_by'], + }, + }, + required: ['source', 'target', 'type'], + }, + }, + }, + required: ['reason'], + }, +}; diff --git a/src/components/chat/rag.js b/src/components/chat/rag.js new file mode 100644 index 0000000..2929c4e --- /dev/null +++ b/src/components/chat/rag.js @@ -0,0 +1,127 @@ +import * as db from '../../lib/db'; + +/** + * Build a compact knowledge-base context string to inject into the system prompt. + * Reads topics + relations from PocketBase via db.js. + * Topic-level content is loaded only when a topic id/label appears in the user's message. + * + * Returns { context: string, topics: Array } so callers can reuse the fetched topics + * for validateDelta without a second round-trip. + */ +export async function buildKbContext(userMessage = '') { + const [topics, relations] = await Promise.all([ + db.getTopics(), + db.getRelations(), + ]); + + if (topics.length === 0) { + return { + context: 'KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)', + topics: [], + }; + } + + const topicLines = topics.map(t => { + const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200); + return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`; + }); + + const relLines = relations.map(r => { + const src = typeof r.source === 'object' ? r.source.id : r.source; + const tgt = typeof r.target === 'object' ? r.target.id : r.target; + return `- ${src} --${r.type}--> ${tgt}`; + }); + + // Pull deep content for any topic explicitly mentioned in the user message. + const lowered = userMessage.toLowerCase(); + const mentionedDeepContent = []; + for (const t of topics) { + const idHit = t.id && lowered.includes(t.id.toLowerCase()); + const labelHit = t.label && lowered.includes(t.label.toLowerCase()); + if (idHit || labelHit) { + const content = await db.getContent(t.id).catch(() => null); + if (content) { + let raw; + if (typeof content === 'string') raw = content; + else if (content.article) raw = content.article; + else raw = JSON.stringify(content); + const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200); + mentionedDeepContent.push(`### ${t.label}\n${snippet}`); + } + } + } + + const context = [ + `KENNISGRAAF — TOPICS:`, + topicLines.join('\n'), + ``, + `KENNISGRAAF — RELATIES:`, + relLines.length ? relLines.join('\n') : '(geen relaties)', + mentionedDeepContent.length + ? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}` + : '', + ].join('\n'); + + return { context, topics }; +} + +/** + * Validate a delta proposal against the current topic list (already fetched). + * Drops: + * - topics whose id already exists (by id or case-folded label) + * - relations whose source/target isn't in the current graph + this delta + * - self-referencing relations + * Caps to 3 topics + 5 relations. + * + * @param {object} delta - Raw input from the propose_graph_delta tool call + * @param {Array} existingTopics - Topics already fetched from PocketBase + */ +export function validateDelta(delta, existingTopics = []) { + if (!delta || typeof delta !== 'object') return null; + + const existingIds = new Set(existingTopics.map(t => t.id)); + const existingLabels = new Set(existingTopics.map(t => (t.label || '').toLowerCase())); + + const safeTopics = []; + for (const t of Array.isArray(delta.topics) ? delta.topics : []) { + if (!t || typeof t.id !== 'string' || typeof t.label !== 'string') continue; + if (existingIds.has(t.id)) continue; + if (existingLabels.has(t.label.toLowerCase())) continue; + if (!['concept', 'role', 'process'].includes(t.type)) continue; + safeTopics.push({ + id: t.id.trim(), + label: t.label.trim(), + type: t.type, + description: (t.description || '').trim(), + }); + existingIds.add(t.id); + existingLabels.add(t.label.toLowerCase()); + if (safeTopics.length >= 3) break; + } + + const knownAfter = new Set([...existingIds, ...safeTopics.map(t => t.id)]); + const safeRelations = []; + for (const r of Array.isArray(delta.relations) ? delta.relations : []) { + if (!r || typeof r.source !== 'string' || typeof r.target !== 'string') continue; + if (r.source === r.target) continue; + if (!knownAfter.has(r.source) || !knownAfter.has(r.target)) continue; + if (!['related_to', 'depends_on', 'part_of', 'executed_by'].includes(r.type)) continue; + safeRelations.push({ source: r.source, target: r.target, type: r.type }); + if (safeRelations.length >= 5) break; + } + + if (safeTopics.length === 0 && safeRelations.length === 0) return null; + + return { + reason: typeof delta.reason === 'string' ? delta.reason : '', + topics: safeTopics, + relations: safeRelations, + }; +} + +/** Stable key for a delta (used to dedupe within a thread). */ +export function deltaKey(delta) { + const t = delta.topics.map(x => x.id).sort().join(','); + const r = delta.relations.map(x => `${x.source}-${x.type}->${x.target}`).sort().join(','); + return `t:${t}|r:${r}`; +} diff --git a/src/components/chat/useChat.js b/src/components/chat/useChat.js new file mode 100644 index 0000000..90d3c50 --- /dev/null +++ b/src/components/chat/useChat.js @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { storage } from '../../lib/storage'; +import { anthropicApi } from '../../lib/api'; +import { buildKbContext, validateDelta, deltaKey } from './rag'; +import { buildSystemPrompt, PROPOSE_GRAPH_DELTA_TOOL, STRINGS } from './prompts'; + +const MAX_HISTORY = 50; + +/** + * Conversation hook for R42. + * Owns the message list, persists to chat:thread:{userId}, calls Anthropic, + * and surfaces validated graph delta suggestions inline. + * + * Note: buildKbContext is async (reads PocketBase), so send() is fully async. + */ +export function useChat({ user, isAdmin }) { + const threadKey = user ? `chat:thread:${user.id}` : null; + const [messages, setMessages] = useState([]); + const [isThinking, setIsThinking] = useState(false); + const [errored, setErrored] = useState(false); + const seenDeltaKeys = useRef(new Set()); + + // Load persisted thread + seed greeting + useEffect(() => { + if (!user) return; + const stored = storage.get(threadKey, null); + if (stored && Array.isArray(stored) && stored.length) { + setMessages(stored); + for (const m of stored) { + if (m.suggestion?.key) seenDeltaKeys.current.add(m.suggestion.key); + } + } else { + setMessages([ + { + id: `m_${Date.now()}`, + role: 'assistant', + content: STRINGS.greeting(user.name || 'daar'), + ts: Date.now(), + }, + ]); + } + }, [user, threadKey]); + + // Persist on change + useEffect(() => { + if (!threadKey) return; + const capped = messages.slice(-MAX_HISTORY); + storage.set(threadKey, capped); + }, [messages, threadKey]); + + const updateMessage = useCallback((id, patch) => { + setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m))); + }, []); + + const send = useCallback(async (text) => { + const trimmed = (text || '').trim(); + if (!trimmed || !user) return; + + const userMsg = { + id: `m_${Date.now()}_u`, + role: 'user', + content: trimmed, + ts: Date.now(), + }; + const next = [...messages, userMsg]; + setMessages(next); + setIsThinking(true); + setErrored(false); + + try { + const { context: kbContext, topics: kbTopics } = await buildKbContext(trimmed); + const systemPrompt = buildSystemPrompt({ + userName: user.name || 'daar', + isAdmin, + kbContext, + }); + + // Strip UI-only fields before sending to Anthropic + const apiMessages = next + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ role: m.role, content: m.content })); + + const response = await anthropicApi.chat(systemPrompt, apiMessages, { + tools: [PROPOSE_GRAPH_DELTA_TOOL], + }); + + let textOut = ''; + let suggestion = null; + for (const block of response.content || []) { + if (block.type === 'text') { + textOut += (textOut ? '\n' : '') + (block.text || ''); + } else if (block.type === 'tool_use' && block.name === PROPOSE_GRAPH_DELTA_TOOL.name) { + const validated = validateDelta(block.input, kbTopics); + if (validated) { + const key = deltaKey(validated); + if (!seenDeltaKeys.current.has(key)) { + seenDeltaKeys.current.add(key); + suggestion = { ...validated, key, status: 'pending' }; + } + } + } + } + + const assistantMsg = { + id: `m_${Date.now()}_a`, + role: 'assistant', + content: textOut || (suggestion ? STRINGS.suggestionTitle : STRINGS.errorGeneric), + ts: Date.now(), + ...(suggestion ? { suggestion } : {}), + }; + setMessages(prev => [...prev, assistantMsg]); + } catch (e) { + console.error('[R42] chat error', e); + setErrored(true); + const isKey = /api key/i.test(e?.message || ''); + setMessages(prev => [ + ...prev, + { + id: `m_${Date.now()}_e`, + role: 'error', + content: isKey ? STRINGS.errorNoKey : STRINGS.errorGeneric, + ts: Date.now(), + }, + ]); + } finally { + setIsThinking(false); + } + }, [messages, user, isAdmin]); + + return { + messages, + isThinking, + errored, + send, + updateMessage, + }; +} diff --git a/src/components/ui/Mark.jsx b/src/components/ui/Mark.jsx new file mode 100644 index 0000000..f506101 --- /dev/null +++ b/src/components/ui/Mark.jsx @@ -0,0 +1,71 @@ +import React from 'react'; + +/** + * Respellion brand mark — typeset directly from BallPill Light. + * Three states drive the middle glyph; braces always remain. + * idle → { r } + * typing → { … } three bouncing dots animated in CSS + * error → { ! } with a brief shake + */ + +const RESP_PURPLE = '#5C1DD8'; +const RESP_TEAL_900 = '#183E46'; +const RESP_CREAM = '#ECE9E9'; + +export default function Mark({ + state = 'idle', + size = 160, + theme = 'none', + brace = RESP_PURPLE, + letter = RESP_PURPLE, + showFrame = false, + ariaLabel = 'Respellion mark', +}) { + const bg = + theme === 'dark' ? RESP_TEAL_900 : + theme === 'light' ? RESP_CREAM : + 'transparent'; + + let middle = null; + if (state === 'idle') middle = 'r'; + else if (state === 'error') middle = '!'; + + return ( + + {showFrame && theme !== 'none' && ( + + )} + + + {'{'} + {middle && {middle}} + {state === 'typing' && {' '}} + {'}'} + + + {state === 'typing' && ( + + + + + + )} + + ); +} diff --git a/src/index.css b/src/index.css index e23a6f0..6c1b012 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,14 @@ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=JetBrains+Mono:wght@400;500;700&display=swap'); @import "tailwindcss"; +@font-face { + font-family: 'BallPill'; + font-weight: 300; + font-style: normal; + font-display: swap; + src: url('/fonts/BallPill-light.otf') format('opentype'); +} + @theme { --font-sans: "Space Grotesk", system-ui, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, monospace; @@ -117,3 +125,19 @@ code, .mono { font-family: var(--font-mono); } .label { font: 500 var(--t-label)/1 var(--font-mono); letter-spacing: var(--label-ls); text-transform: uppercase; } ::selection { background: var(--purple); color: var(--paper); } + +/* ─── R42 chatbot mark animations ────────────────────────────── */ +@keyframes dotBounce { + 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } + 40% { transform: translateY(-4px); opacity: 1; } +} +.r42-mark .dot { animation: dotBounce 1.2s ease-in-out infinite; transform-origin: center; } +.r42-mark .dot:nth-child(2) { animation-delay: 0.15s; } +.r42-mark .dot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes r42Shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-2px); } + 40%, 80% { transform: translateX(2px); } +} +.r42-mark.shake { animation: r42Shake 0.5s ease-in-out; } diff --git a/src/lib/api.js b/src/lib/api.js index 206fb1d..689bf79 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -64,6 +64,61 @@ export const anthropicApi = { } }; +/** + * Multi-turn chat with optional tool use. + * Returns the raw Anthropic response so callers can read both `text` and + * `tool_use` content blocks. + * + * @param {string} systemPrompt + * @param {Array<{role: 'user'|'assistant', content: string}>} messages + * @param {{tools?: Array}} opts + * @returns {Promise<{content: Array, stop_reason: string}>} + */ +anthropicApi.chat = async function chat(systemPrompt, messages, opts = {}) { + const useSimulation = storage.get('admin:use_simulation') === true; + if (useSimulation) { + await new Promise(r => setTimeout(r, 600)); + return { + content: [{ + type: 'text', + text: 'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.', + }], + stop_reason: 'end_turn', + }; + } + + const model = storage.get('admin:model') || DEFAULT_MODEL; + + const body = { + model, + max_tokens: 1024, + system: systemPrompt, + messages, + }; + if (opts.tools && opts.tools.length) body.tools = opts.tools; + + const response = await fetch('/api/anthropic/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Your session has expired. Please refresh the page and log in again.'); + } + + return await response.json(); +}; + async function simulateResponse() { await new Promise(r => setTimeout(r, 2000)); return JSON.stringify({ diff --git a/src/lib/kbStore.js b/src/lib/kbStore.js new file mode 100644 index 0000000..f037a95 --- /dev/null +++ b/src/lib/kbStore.js @@ -0,0 +1,117 @@ +import { storage } from './storage'; +import * as db from './db'; + +/** + * Knowledge-base mutations for the R42 chatbot path. + * + * Topics and relations live in PocketBase (via db.js). The suggestions queue + * (pending / approved / rejected) is ephemeral and stored in localStorage + * since there is no PocketBase collection for it. + * + * Every write dispatches `respellion:kb-updated` so the D3 graph and the + * suggestions panel refresh without a page reload. + */ + +function dispatchUpdated() { + try { + window.dispatchEvent(new CustomEvent('respellion:kb-updated')); + } catch { + // non-browser environment — no-op + } +} + +export const kbStore = { + /** + * Apply a validated delta directly to PocketBase topics + relations. + * Skips topics/relations that already exist. + */ + async applyDelta(delta) { + if (!delta) return { addedTopics: 0, addedRelations: 0 }; + + const [existingTopics, existingRelations] = await Promise.all([ + db.getTopics(), + db.getRelations(), + ]); + + const existingIds = new Set(existingTopics.map(t => t.id)); + const existingLabels = new Set(existingTopics.map(t => (t.label || '').toLowerCase())); + + let addedTopics = 0; + for (const t of delta.topics || []) { + if (existingIds.has(t.id) || existingLabels.has((t.label || '').toLowerCase())) continue; + await db.upsertTopic({ + id: t.id, + label: t.label, + type: t.type || 'concept', + description: t.description || '', + }); + existingIds.add(t.id); + existingLabels.add((t.label || '').toLowerCase()); + addedTopics++; + } + + let addedRelations = 0; + const allIds = new Set([...existingTopics.map(t => t.id), ...(delta.topics || []).map(t => t.id)]); + for (const r of delta.relations || []) { + if (!allIds.has(r.source) || !allIds.has(r.target) || r.source === r.target) continue; + const dup = existingRelations.some(x => { + const sx = typeof x.source === 'object' ? x.source.id : x.source; + const tx = typeof x.target === 'object' ? x.target.id : x.target; + return sx === r.source && tx === r.target && x.type === r.type; + }); + if (dup) continue; + await db.addRelation({ source: r.source, target: r.target, type: r.type }); + addedRelations++; + } + + dispatchUpdated(); + return { addedTopics, addedRelations }; + }, + + /** Queue a suggestion for admin review (stored in localStorage). */ + appendSuggestion(suggestion) { + const list = storage.get('kb:suggestions', []) || []; + const entry = { + id: `sug_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + ts: Date.now(), + status: 'pending', + reason: suggestion.reason || '', + proposedBy: suggestion.proposedBy || null, + proposedByName: suggestion.proposedByName || null, + topics: suggestion.topics || [], + relations: suggestion.relations || [], + }; + storage.set('kb:suggestions', [...list, entry]); + dispatchUpdated(); + return entry; + }, + + listSuggestions(status) { + const all = storage.get('kb:suggestions', []) || []; + if (!status) return all; + return all.filter(s => s.status === status); + }, + + async approveSuggestion(id) { + const all = storage.get('kb:suggestions', []) || []; + const target = all.find(s => s.id === id); + if (!target || target.status !== 'pending') return null; + const result = await this.applyDelta(target); + target.status = 'approved'; + target.appliedAt = Date.now(); + storage.set('kb:suggestions', all); + dispatchUpdated(); + return result; + }, + + rejectSuggestion(id) { + const all = storage.get('kb:suggestions', []) || []; + const target = all.find(s => s.id === id); + if (!target || target.status !== 'pending') return null; + target.status = 'rejected'; + target.rejectedAt = Date.now(); + storage.set('kb:suggestions', all); + dispatchUpdated(); + return target; + }, +}; diff --git a/src/pages/Testen.jsx b/src/pages/Testen.jsx index c754804..430fd93 100644 --- a/src/pages/Testen.jsx +++ b/src/pages/Testen.jsx @@ -10,9 +10,21 @@ import Button from '../components/ui/Button'; import Tag from '../components/ui/Tag'; import { useApp } from '../store/AppContext'; import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService'; +import { storage } from '../lib/storage'; const TIMER_SECONDS = 300; // 5 minutes +function setQuizActive(userId, active) { + if (!userId) return; + if (active) storage.set(`quiz:active:${userId}`, true); + else storage.remove(`quiz:active:${userId}`); + try { + window.dispatchEvent(new CustomEvent('respellion:quiz-state', { detail: { active } })); + } catch { + // ignore + } +} + // ─── Helper: format mm:ss ────────────────────────────────── function formatTime(sec) { const m = Math.floor(sec / 60); @@ -65,6 +77,19 @@ const Testen = () => { return () => clearInterval(timerRef.current); }, [phase]); + // ── Clear quiz-active flag whenever we leave the quiz phase or unmount ── + useEffect(() => { + if (phase !== 'quiz' && currentUser) { + setQuizActive(currentUser.id, false); + } + }, [phase, currentUser]); + + useEffect(() => { + return () => { + if (currentUser) setQuizActive(currentUser.id, false); + }; + }, [currentUser]); + // ── Start quiz ── const startQuiz = async () => { setPhase('loading'); @@ -76,6 +101,7 @@ const Testen = () => { setAnswers({}); setShowFeedback(false); setTimeLeft(TIMER_SECONDS); + setQuizActive(currentUser.id, true); setPhase('quiz'); } catch (e) { setError(e.message); diff --git a/styles-r3x-v2.css b/styles-r3x-v2.css new file mode 100644 index 0000000..a3ee994 --- /dev/null +++ b/styles-r3x-v2.css @@ -0,0 +1,589 @@ +@import url("design-system/respellion-tokens.css"); + +/* Brand display font — used for the { r } mark itself. */ +@font-face { + font-family: "BallPill"; + src: url("fonts/BallPill-light.otf") format("opentype"); + font-weight: 300; + font-style: normal; + font-display: block; +} + +/* ─── Page shell ─────────────────────────────────────────── */ +html, body { + margin: 0; min-height: 100%; + background: var(--teal-900); + font-family: var(--font-sans); + color: var(--cream); + -webkit-font-smoothing: antialiased; +} +* { box-sizing: border-box; } + +#root { min-height: 100vh; } + +.page { + max-width: var(--max); + margin: 0 auto; + padding: var(--s-8) var(--s-7) var(--s-9); + display: flex; flex-direction: column; gap: var(--s-8); +} + +/* tints used throughout the dark surface */ +:root { + --ink-cream: var(--cream); + --ink-muted: rgba(236,233,233,0.62); + --ink-subtle: rgba(236,233,233,0.32); + --border-soft: rgba(236,233,233,0.10); + --border-softer: rgba(236,233,233,0.06); + --surface-deep: #122F36; /* between teal and teal-900 */ + --surface-card: #163841; + --tint-purple: rgba(92,29,216,0.10); +} + +/* ─── Header ─────────────────────────────────────────────── */ +.hdr { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--s-7); flex-wrap: wrap; } +.hdr-eyebrow { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); + margin-bottom: var(--s-4); +} +.hdr h1 { + font: 700 var(--t-h2)/1.02 var(--font-sans); + letter-spacing: -0.015em; + max-width: 820px; + color: var(--ink-cream); +} +.hdr h1 em { + font-style: normal; + font-family: var(--font-mono); + font-weight: 500; + color: var(--peri); +} +.hdr-lede { + font-size: var(--t-lead); + color: var(--ink-muted); + margin-top: var(--s-5); + max-width: 620px; + line-height: 1.55; +} +.hdr-lede b { color: var(--ink-cream); font-weight: 500; } +.hdr-lede code { + font-family: var(--font-mono); + background: rgba(236,233,233,0.08); + color: var(--ink-cream); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.85em; +} + +.hdr-meta { + font: 500 var(--t-label)/1.7 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--ink-subtle); +} +.hdr-meta span { color: var(--ink-cream); text-transform: none; letter-spacing: 0.02em; } +.hdr-meta i { + display: inline-block; width: 6px; height: 6px; border-radius: 999px; + background: var(--sage); margin-right: 6px; vertical-align: middle; + animation: pulseDot 1.8s ease-in-out infinite; + box-shadow: 0 0 10px rgba(205,216,187,0.6); +} +@keyframes pulseDot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ─── Section header ─────────────────────────────────────── */ +.sec-h { + display: flex; align-items: baseline; gap: var(--s-5); + border-bottom: 1px solid var(--border-soft); + padding-bottom: var(--s-4); +} +.sec-num { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); +} +.sec-h h2 { + font: 700 var(--t-h3)/1.1 var(--font-sans); + letter-spacing: -0.005em; + color: var(--ink-cream); +} +.sec-h .sub { + font-size: var(--t-small); + color: var(--ink-muted); + margin-left: auto; +} + +/* ─── Hero (big mark) ────────────────────────────────────── */ +.hero { + display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-6); + align-items: stretch; +} +.hero-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-lg); + padding: var(--s-7); + display: flex; flex-direction: column; justify-content: space-between; + min-height: 460px; + position: relative; + overflow: hidden; +} +.hero-card.light { + background: var(--cream); + color: var(--teal-900); + border-color: rgba(0,0,0,0.05); +} +.hero-card .grid-bg { + position: absolute; inset: 0; + background-image: + linear-gradient(rgba(236,233,233,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(236,233,233,0.04) 1px, transparent 1px); + background-size: 32px 32px; + pointer-events: none; +} +.hero-card.light .grid-bg { + background-image: + linear-gradient(rgba(31,85,96,0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(31,85,96,0.06) 1px, transparent 1px); +} + +/* Organic blob on the hero for personality (responsible rebellion: structure + curve) */ +.hero-card .blob { + position: absolute; + width: 180px; height: 180px; + background: var(--purple); + opacity: 0.22; + border-radius: var(--r-org); + filter: blur(2px); + top: -40px; right: -50px; + pointer-events: none; +} +.hero-card.light .blob { background: var(--sage); opacity: 0.6; } +.hero-card .blob.b2 { + width: 120px; height: 120px; + background: var(--peri); + border-radius: var(--r-org2); + top: auto; bottom: -30px; right: auto; left: -30px; + opacity: 0.18; +} +.hero-card.light .blob.b2 { background: var(--peri); opacity: 0.5; } + +.hero-mark { display: grid; place-items: center; flex: 1; position: relative; z-index: 1; } +.hero-tag { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--ink-subtle); + display: flex; justify-content: space-between; + position: relative; z-index: 1; +} +.hero-card.light .hero-tag { color: rgba(31,85,96,0.55); } +.hero-tag b { color: var(--ink-cream); font-weight: 500; text-transform: none; letter-spacing: 0.02em; } +.hero-card.light .hero-tag b { color: var(--teal-900); } + +/* ─── State row ──────────────────────────────────────────── */ +.state-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-5); } +.state-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-6); + display: flex; flex-direction: column; align-items: center; gap: var(--s-4); + position: relative; + overflow: hidden; +} +.state-card .stage { + width: 140px; height: 140px; + display: grid; place-items: center; + background: var(--teal-900); + border-radius: var(--r-sm); + border: 1px solid var(--border-softer); +} +.state-name { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); +} +.state-desc { + font-size: var(--t-small); + color: var(--ink-muted); + text-align: center; + line-height: 1.55; +} + +/* ─── Size scale ─────────────────────────────────────────── */ +.scale-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); +} +.scale-row { + display: flex; align-items: flex-end; gap: var(--s-7); justify-content: center; flex-wrap: wrap; +} +.scale-item { display: flex; flex-direction: column; align-items: center; gap: var(--s-3); } +.scale-item .stage { display: grid; place-items: center; } +.scale-item .px { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--ink-subtle); +} + +/* ─── Anatomy ─────────────────────────────────────────────── */ +.anatomy-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); + display: grid; grid-template-columns: 1fr 1.1fr; gap: var(--s-7); + align-items: center; + position: relative; + overflow: hidden; +} +.anatomy-card .blob { + position: absolute; + width: 280px; height: 280px; + background: var(--purple); + opacity: 0.14; + border-radius: var(--r-org); + filter: blur(8px); + top: -80px; left: -80px; + pointer-events: none; +} +.anatomy-stage { display: grid; place-items: center; position: relative; z-index: 1; } +.anatomy-list { display: flex; flex-direction: column; gap: var(--s-5); position: relative; z-index: 1; } +.anatomy-item { display: flex; gap: var(--s-4); } +.anatomy-num { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); + flex-shrink: 0; width: 32px; + padding-top: 2px; +} +.anatomy-text b { + display: block; color: var(--ink-cream); + font: 600 var(--t-body)/1.3 var(--font-sans); + margin-bottom: 4px; +} +.anatomy-text { font-size: var(--t-small); color: var(--ink-muted); line-height: 1.55; } + +/* ─── Intranet mockup ────────────────────────────────────── */ +.intra-card { + background: var(--cream); + border-radius: var(--r-md); + border: 1px solid rgba(0,0,0,0.04); + overflow: hidden; + min-height: 520px; + position: relative; +} +.intra-top { + height: 60px; + background: var(--paper); + border-bottom: 1px solid rgba(31,85,96,0.08); + display: flex; align-items: center; justify-content: space-between; + gap: var(--s-5); + padding: 0 var(--s-6); +} +.intra-logo { + font-family: "BallPill", var(--font-mono); + font-size: 32px; font-weight: 300; + color: var(--teal-900); + letter-spacing: -0.04em; + display: flex; align-items: center; gap: 0; + flex-shrink: 0; +} +.intra-logo .brace { color: var(--purple); } +.intra-logo .r { color: var(--purple); } +.intra-logo .nm { + font-family: var(--font-sans); font-weight: 600; + font-size: 16px; letter-spacing: -0.005em; + margin-left: 12px; color: var(--teal-900); + align-self: center; +} +.intra-nav { + display: flex; gap: var(--s-4); + font: 500 13px/1 var(--font-sans); + color: rgba(60,58,58,0.62); + flex-shrink: 0; +} +.intra-nav span.active { color: var(--teal-900); } + +.intra-body { + padding: var(--s-6) var(--s-7); + display: grid; grid-template-columns: 2fr 1fr; gap: var(--s-6); +} +.intra-h { + font: 700 22px/1.1 var(--font-sans); + color: var(--teal); + margin-bottom: 4px; + letter-spacing: -0.01em; +} +.intra-sub { font-size: 14px; color: rgba(60,58,58,0.6); margin-bottom: var(--s-5); } + +.intra-tile { + background: var(--paper); + border: 1px solid rgba(31,85,96,0.06); + border-radius: var(--r-sm); + padding: var(--s-4) var(--s-5); + margin-bottom: var(--s-3); +} +.intra-tile-h { font: 600 15px/1.3 var(--font-sans); color: var(--teal-900); margin-bottom: 4px; } +.intra-tile-meta { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(60,58,58,0.5); +} +.intra-tile-line { height: 6px; background: rgba(31,85,96,0.10); border-radius: 4px; margin-top: var(--s-3); } +.intra-tile-line.w60 { width: 60%; margin-top: 6px; } +.intra-tile-line.w80 { width: 80%; } + +.intra-side { display: flex; flex-direction: column; gap: var(--s-2); } +.intra-side-h { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(60,58,58,0.45); + margin-bottom: 4px; +} +.intra-pill { + background: var(--paper); + border: 1px solid rgba(31,85,96,0.06); + border-radius: var(--r-pill); + padding: 10px 16px; + font: 500 13px/1 var(--font-sans); + color: var(--ink); + display: flex; align-items: center; gap: 8px; +} +.intra-pill i { width: 6px; height: 6px; border-radius: 999px; background: var(--sage); } + +/* The chat bubble FAB */ +.bubble-trigger { + position: absolute; + right: var(--s-6); bottom: var(--s-6); + width: 64px; height: 64px; + border-radius: var(--r-pill); + background: var(--teal-900); + display: grid; place-items: center; + box-shadow: var(--shadow-pill); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 2px solid var(--cream); +} +.bubble-trigger:hover { transform: translateY(-2px); } +.bubble-trigger.open { + right: var(--s-6); bottom: 400px; + width: 48px; height: 48px; +} + +.bubble-window { + position: absolute; + right: var(--s-6); bottom: var(--s-6); + width: 360px; height: 380px; + background: var(--paper); + border-radius: var(--r-md); + border: 1px solid rgba(31,85,96,0.06); + box-shadow: var(--shadow-pill); + display: flex; flex-direction: column; + overflow: hidden; +} +.bubble-hd { + background: var(--teal-900); + color: var(--cream); + padding: var(--s-4) var(--s-5); + display: flex; align-items: center; gap: var(--s-3); +} +.bubble-hd .av { + width: 40px; height: 40px; border-radius: var(--r-pill); + background: var(--teal); + display: grid; place-items: center; +} +.bubble-hd-text { display: flex; flex-direction: column; gap: 2px; } +.bubble-hd-name { font: 600 14px/1 var(--font-sans); } +.bubble-hd-status { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(236,233,233,0.55); + display: flex; align-items: center; gap: 6px; +} +.bubble-hd-status i { width: 5px; height: 5px; border-radius: 999px; background: var(--sage); } +.bubble-hd-x { + margin-left: auto; color: rgba(236,233,233,0.6); + font-size: 22px; line-height: 1; cursor: pointer; +} + +.bubble-body { + flex: 1; padding: var(--s-4); + background: var(--cream); + display: flex; flex-direction: column; gap: var(--s-3); + overflow: hidden; +} +.msg { display: flex; gap: var(--s-2); align-items: flex-end; } +.msg .av-sm { + width: 26px; height: 26px; border-radius: var(--r-pill); + background: var(--teal-900); + display: grid; place-items: center; + flex-shrink: 0; +} +.msg .bub { + background: var(--paper); + border-radius: 16px 16px 16px 4px; + padding: 10px 14px; + font: 400 14px/1.45 var(--font-sans); + color: var(--ink); + max-width: 230px; + border: 1px solid rgba(31,85,96,0.06); +} +.msg.me { justify-content: flex-end; } +.msg.me .bub { + background: var(--teal); + color: var(--cream); + border: none; + border-radius: 16px 16px 4px 16px; +} + +.bubble-input { + padding: var(--s-3) var(--s-4); + background: var(--paper); + border-top: 1px solid rgba(31,85,96,0.06); + display: flex; align-items: center; gap: var(--s-2); +} +.bubble-input input { + flex: 1; + background: var(--cream); + border: none; outline: none; + border-radius: var(--r-pill); + padding: 10px 14px; + font: 400 14px/1 var(--font-sans); + color: var(--ink); +} +.bubble-input input::placeholder { color: rgba(60,58,58,0.4); } +.bubble-input button { + background: var(--purple); + color: var(--cream); + border: none; + border-radius: var(--r-pill); + padding: 10px 18px; + font: 600 13px/1 var(--font-sans); + cursor: pointer; + transition: background 0.15s ease; +} +.bubble-input button:hover { background: var(--purple-700); } + +/* ─── Names ──────────────────────────────────────────────── */ +.names { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); + position: relative; + overflow: hidden; +} +.names .blob { + position: absolute; + width: 200px; height: 200px; + background: var(--purple); + opacity: 0.16; + border-radius: var(--r-org2); + filter: blur(4px); + bottom: -60px; right: -40px; + pointer-events: none; +} +.names-lede { + font-size: var(--t-small); + color: var(--ink-muted); + line-height: 1.6; + max-width: 640px; + position: relative; z-index: 1; +} +.names-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s-3); + margin-top: var(--s-5); + position: relative; z-index: 1; +} +.name-card { + background: rgba(236,233,233,0.04); + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + padding: var(--s-4) var(--s-5); + display: flex; flex-direction: column; gap: 6px; +} +.name-card.picked { + border-color: var(--peri); + background: rgba(175,192,255,0.08); +} +.name-card .nm { + font: 600 22px/1.1 var(--font-sans); + color: var(--ink-cream); + letter-spacing: -0.01em; +} +.name-card .nm em { + font-style: normal; + color: var(--peri); + font-family: var(--font-mono); + font-weight: 500; +} +.name-card.picked .nm em { color: var(--peri); } +.name-card .ds { font-size: 12px; color: var(--ink-muted); line-height: 1.5; } +.name-card .picked-tag { + position: absolute; top: 10px; right: 10px; + font: 500 9px/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); +} +.name-card { position: relative; } + +/* ─── Palette card ───────────────────────────────────────── */ +.palette-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); +} +.palette-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--s-3); } +.swatch { + border-radius: var(--r-sm); + padding: var(--s-4); + min-height: 140px; + display: flex; flex-direction: column; justify-content: space-between; + font-family: var(--font-mono); +} +.swatch .nm { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; } +.swatch .hex { font-size: 11px; letter-spacing: 0.05em; opacity: 0.7; } + +/* ─── Footnote ──────────────────────────────────────────── */ +.footnote { + font-size: var(--t-small); color: var(--ink-muted); line-height: 1.6; + text-align: center; padding-top: var(--s-5); + max-width: 640px; margin: 0 auto; +} +.footnote code { + background: rgba(236,233,233,0.06); color: var(--ink-cream); + padding: 2px 8px; border-radius: 6px; + font: 500 12px/1 var(--font-mono); +} +.footnote b { color: var(--ink-cream); font-weight: 500; } + +/* ─── Mark-specific animations ───────────────────────────── */ +@keyframes caretBlink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } +.caret { animation: caretBlink 1s steps(1, end) infinite; } + +@keyframes dotBounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-4px); opacity: 1; } } +.dot { animation: dotBounce 1.2s ease-in-out infinite; transform-origin: center; } +.dot:nth-child(2) { animation-delay: 0.15s; } +.dot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-2px); } + 40%, 80% { transform: translateX(2px); } +} +.shake { animation: shake 0.5s ease-in-out; } diff --git a/styles-r3x.css b/styles-r3x.css new file mode 100644 index 0000000..ebd4e4f --- /dev/null +++ b/styles-r3x.css @@ -0,0 +1,589 @@ +@import url("design-system/respellion-tokens.css"); + +/* Brand display font — used for the { r } mark itself. */ +@font-face { + font-family: "BallPill"; + src: url("fonts/BallPill-light.otf") format("opentype"); + font-weight: 300; + font-style: normal; + font-display: block; +} + +/* ─── Page shell ─────────────────────────────────────────── */ +html, body { + margin: 0; min-height: 100%; + background: var(--teal-900); + font-family: var(--font-sans); + color: var(--cream); + -webkit-font-smoothing: antialiased; +} +* { box-sizing: border-box; } + +#root { min-height: 100vh; } + +.page { + max-width: var(--max); + margin: 0 auto; + padding: var(--s-8) var(--s-7) var(--s-9); + display: flex; flex-direction: column; gap: var(--s-8); +} + +/* tints used throughout the dark surface */ +:root { + --ink-cream: var(--cream); + --ink-muted: rgba(236,233,233,0.62); + --ink-subtle: rgba(236,233,233,0.32); + --border-soft: rgba(236,233,233,0.10); + --border-softer: rgba(236,233,233,0.06); + --surface-deep: #122F36; /* between teal and teal-900 */ + --surface-card: #163841; + --tint-purple: rgba(92,29,216,0.10); +} + +/* ─── Header ─────────────────────────────────────────────── */ +.hdr { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--s-7); flex-wrap: wrap; } +.hdr-eyebrow { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); + margin-bottom: var(--s-4); +} +.hdr h1 { + font: 700 var(--t-h2)/1.02 var(--font-sans); + letter-spacing: -0.015em; + max-width: 820px; + color: var(--ink-cream); +} +.hdr h1 em { + font-style: normal; + font-family: var(--font-mono); + font-weight: 500; + color: var(--peri); +} +.hdr-lede { + font-size: var(--t-lead); + color: var(--ink-muted); + margin-top: var(--s-5); + max-width: 620px; + line-height: 1.55; +} +.hdr-lede b { color: var(--ink-cream); font-weight: 500; } +.hdr-lede code { + font-family: var(--font-mono); + background: rgba(236,233,233,0.08); + color: var(--ink-cream); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.85em; +} + +.hdr-meta { + font: 500 var(--t-label)/1.7 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--ink-subtle); +} +.hdr-meta span { color: var(--ink-cream); text-transform: none; letter-spacing: 0.02em; } +.hdr-meta i { + display: inline-block; width: 6px; height: 6px; border-radius: 999px; + background: var(--sage); margin-right: 6px; vertical-align: middle; + animation: pulseDot 1.8s ease-in-out infinite; + box-shadow: 0 0 10px rgba(205,216,187,0.6); +} +@keyframes pulseDot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ─── Section header ─────────────────────────────────────── */ +.sec-h { + display: flex; align-items: baseline; gap: var(--s-5); + border-bottom: 1px solid var(--border-soft); + padding-bottom: var(--s-4); +} +.sec-num { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); +} +.sec-h h2 { + font: 700 var(--t-h3)/1.1 var(--font-sans); + letter-spacing: -0.005em; + color: var(--ink-cream); +} +.sec-h .sub { + font-size: var(--t-small); + color: var(--ink-muted); + margin-left: auto; +} + +/* ─── Hero (big mark) ────────────────────────────────────── */ +.hero { + display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-6); + align-items: stretch; +} +.hero-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-lg); + padding: var(--s-7); + display: flex; flex-direction: column; justify-content: space-between; + min-height: 460px; + position: relative; + overflow: hidden; +} +.hero-card.light { + background: var(--cream); + color: var(--teal-900); + border-color: rgba(0,0,0,0.05); +} +.hero-card .grid-bg { + position: absolute; inset: 0; + background-image: + linear-gradient(rgba(236,233,233,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(236,233,233,0.04) 1px, transparent 1px); + background-size: 32px 32px; + pointer-events: none; +} +.hero-card.light .grid-bg { + background-image: + linear-gradient(rgba(31,85,96,0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(31,85,96,0.06) 1px, transparent 1px); +} + +/* Organic blob on the hero for personality (responsible rebellion: structure + curve) */ +.hero-card .blob { + position: absolute; + width: 180px; height: 180px; + background: var(--purple); + opacity: 0.22; + border-radius: var(--r-org); + filter: blur(2px); + top: -40px; right: -50px; + pointer-events: none; +} +.hero-card.light .blob { background: var(--sage); opacity: 0.6; } +.hero-card .blob.b2 { + width: 120px; height: 120px; + background: var(--peri); + border-radius: var(--r-org2); + top: auto; bottom: -30px; right: auto; left: -30px; + opacity: 0.18; +} +.hero-card.light .blob.b2 { background: var(--peri); opacity: 0.5; } + +.hero-mark { display: grid; place-items: center; flex: 1; position: relative; z-index: 1; } +.hero-tag { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--ink-subtle); + display: flex; justify-content: space-between; + position: relative; z-index: 1; +} +.hero-card.light .hero-tag { color: rgba(31,85,96,0.55); } +.hero-tag b { color: var(--ink-cream); font-weight: 500; text-transform: none; letter-spacing: 0.02em; } +.hero-card.light .hero-tag b { color: var(--teal-900); } + +/* ─── State row ──────────────────────────────────────────── */ +.state-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-5); } +.state-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-6); + display: flex; flex-direction: column; align-items: center; gap: var(--s-4); + position: relative; + overflow: hidden; +} +.state-card .stage { + width: 140px; height: 140px; + display: grid; place-items: center; + background: var(--teal-900); + border-radius: var(--r-sm); + border: 1px solid var(--border-softer); +} +.state-name { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); +} +.state-desc { + font-size: var(--t-small); + color: var(--ink-muted); + text-align: center; + line-height: 1.55; +} + +/* ─── Size scale ─────────────────────────────────────────── */ +.scale-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); +} +.scale-row { + display: flex; align-items: flex-end; gap: var(--s-7); justify-content: center; flex-wrap: wrap; +} +.scale-item { display: flex; flex-direction: column; align-items: center; gap: var(--s-3); } +.scale-item .stage { display: grid; place-items: center; } +.scale-item .px { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--ink-subtle); +} + +/* ─── Anatomy ─────────────────────────────────────────────── */ +.anatomy-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); + display: grid; grid-template-columns: 1fr 1.1fr; gap: var(--s-7); + align-items: center; + position: relative; + overflow: hidden; +} +.anatomy-card .blob { + position: absolute; + width: 280px; height: 280px; + background: var(--purple); + opacity: 0.14; + border-radius: var(--r-org); + filter: blur(8px); + top: -80px; left: -80px; + pointer-events: none; +} +.anatomy-stage { display: grid; place-items: center; position: relative; z-index: 1; } +.anatomy-list { display: flex; flex-direction: column; gap: var(--s-5); position: relative; z-index: 1; } +.anatomy-item { display: flex; gap: var(--s-4); } +.anatomy-num { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); + flex-shrink: 0; width: 32px; + padding-top: 2px; +} +.anatomy-text b { + display: block; color: var(--ink-cream); + font: 600 var(--t-body)/1.3 var(--font-sans); + margin-bottom: 4px; +} +.anatomy-text { font-size: var(--t-small); color: var(--ink-muted); line-height: 1.55; } + +/* ─── Intranet mockup ────────────────────────────────────── */ +.intra-card { + background: var(--cream); + border-radius: var(--r-md); + border: 1px solid rgba(0,0,0,0.04); + overflow: hidden; + min-height: 520px; + position: relative; +} +.intra-top { + height: 60px; + background: var(--paper); + border-bottom: 1px solid rgba(31,85,96,0.08); + display: flex; align-items: center; justify-content: space-between; + gap: var(--s-5); + padding: 0 var(--s-6); +} +.intra-logo { + font-family: "BallPill", var(--font-mono); + font-size: 32px; font-weight: 300; + color: var(--teal-900); + letter-spacing: -0.04em; + display: flex; align-items: center; gap: 0; + flex-shrink: 0; +} +.intra-logo .brace { color: var(--purple); } +.intra-logo .r { color: var(--teal); } +.intra-logo .nm { + font-family: var(--font-sans); font-weight: 600; + font-size: 16px; letter-spacing: -0.005em; + margin-left: 12px; color: var(--teal-900); + align-self: center; +} +.intra-nav { + display: flex; gap: var(--s-4); + font: 500 13px/1 var(--font-sans); + color: rgba(60,58,58,0.62); + flex-shrink: 0; +} +.intra-nav span.active { color: var(--teal-900); } + +.intra-body { + padding: var(--s-6) var(--s-7); + display: grid; grid-template-columns: 2fr 1fr; gap: var(--s-6); +} +.intra-h { + font: 700 22px/1.1 var(--font-sans); + color: var(--teal); + margin-bottom: 4px; + letter-spacing: -0.01em; +} +.intra-sub { font-size: 14px; color: rgba(60,58,58,0.6); margin-bottom: var(--s-5); } + +.intra-tile { + background: var(--paper); + border: 1px solid rgba(31,85,96,0.06); + border-radius: var(--r-sm); + padding: var(--s-4) var(--s-5); + margin-bottom: var(--s-3); +} +.intra-tile-h { font: 600 15px/1.3 var(--font-sans); color: var(--teal-900); margin-bottom: 4px; } +.intra-tile-meta { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(60,58,58,0.5); +} +.intra-tile-line { height: 6px; background: rgba(31,85,96,0.10); border-radius: 4px; margin-top: var(--s-3); } +.intra-tile-line.w60 { width: 60%; margin-top: 6px; } +.intra-tile-line.w80 { width: 80%; } + +.intra-side { display: flex; flex-direction: column; gap: var(--s-2); } +.intra-side-h { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(60,58,58,0.45); + margin-bottom: 4px; +} +.intra-pill { + background: var(--paper); + border: 1px solid rgba(31,85,96,0.06); + border-radius: var(--r-pill); + padding: 10px 16px; + font: 500 13px/1 var(--font-sans); + color: var(--ink); + display: flex; align-items: center; gap: 8px; +} +.intra-pill i { width: 6px; height: 6px; border-radius: 999px; background: var(--sage); } + +/* The chat bubble FAB */ +.bubble-trigger { + position: absolute; + right: var(--s-6); bottom: var(--s-6); + width: 64px; height: 64px; + border-radius: var(--r-pill); + background: var(--teal-900); + display: grid; place-items: center; + box-shadow: var(--shadow-pill); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 2px solid var(--cream); +} +.bubble-trigger:hover { transform: translateY(-2px); } +.bubble-trigger.open { + right: var(--s-6); bottom: 400px; + width: 48px; height: 48px; +} + +.bubble-window { + position: absolute; + right: var(--s-6); bottom: var(--s-6); + width: 360px; height: 380px; + background: var(--paper); + border-radius: var(--r-md); + border: 1px solid rgba(31,85,96,0.06); + box-shadow: var(--shadow-pill); + display: flex; flex-direction: column; + overflow: hidden; +} +.bubble-hd { + background: var(--teal-900); + color: var(--cream); + padding: var(--s-4) var(--s-5); + display: flex; align-items: center; gap: var(--s-3); +} +.bubble-hd .av { + width: 40px; height: 40px; border-radius: var(--r-pill); + background: var(--teal); + display: grid; place-items: center; +} +.bubble-hd-text { display: flex; flex-direction: column; gap: 2px; } +.bubble-hd-name { font: 600 14px/1 var(--font-sans); } +.bubble-hd-status { + font: 500 var(--t-label)/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: rgba(236,233,233,0.55); + display: flex; align-items: center; gap: 6px; +} +.bubble-hd-status i { width: 5px; height: 5px; border-radius: 999px; background: var(--sage); } +.bubble-hd-x { + margin-left: auto; color: rgba(236,233,233,0.6); + font-size: 22px; line-height: 1; cursor: pointer; +} + +.bubble-body { + flex: 1; padding: var(--s-4); + background: var(--cream); + display: flex; flex-direction: column; gap: var(--s-3); + overflow: hidden; +} +.msg { display: flex; gap: var(--s-2); align-items: flex-end; } +.msg .av-sm { + width: 26px; height: 26px; border-radius: var(--r-pill); + background: var(--teal-900); + display: grid; place-items: center; + flex-shrink: 0; +} +.msg .bub { + background: var(--paper); + border-radius: 16px 16px 16px 4px; + padding: 10px 14px; + font: 400 14px/1.45 var(--font-sans); + color: var(--ink); + max-width: 230px; + border: 1px solid rgba(31,85,96,0.06); +} +.msg.me { justify-content: flex-end; } +.msg.me .bub { + background: var(--teal); + color: var(--cream); + border: none; + border-radius: 16px 16px 4px 16px; +} + +.bubble-input { + padding: var(--s-3) var(--s-4); + background: var(--paper); + border-top: 1px solid rgba(31,85,96,0.06); + display: flex; align-items: center; gap: var(--s-2); +} +.bubble-input input { + flex: 1; + background: var(--cream); + border: none; outline: none; + border-radius: var(--r-pill); + padding: 10px 14px; + font: 400 14px/1 var(--font-sans); + color: var(--ink); +} +.bubble-input input::placeholder { color: rgba(60,58,58,0.4); } +.bubble-input button { + background: var(--purple); + color: var(--cream); + border: none; + border-radius: var(--r-pill); + padding: 10px 18px; + font: 600 13px/1 var(--font-sans); + cursor: pointer; + transition: background 0.15s ease; +} +.bubble-input button:hover { background: var(--purple-700); } + +/* ─── Names ──────────────────────────────────────────────── */ +.names { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); + position: relative; + overflow: hidden; +} +.names .blob { + position: absolute; + width: 200px; height: 200px; + background: var(--purple); + opacity: 0.16; + border-radius: var(--r-org2); + filter: blur(4px); + bottom: -60px; right: -40px; + pointer-events: none; +} +.names-lede { + font-size: var(--t-small); + color: var(--ink-muted); + line-height: 1.6; + max-width: 640px; + position: relative; z-index: 1; +} +.names-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s-3); + margin-top: var(--s-5); + position: relative; z-index: 1; +} +.name-card { + background: rgba(236,233,233,0.04); + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + padding: var(--s-4) var(--s-5); + display: flex; flex-direction: column; gap: 6px; +} +.name-card.picked { + border-color: var(--peri); + background: rgba(175,192,255,0.08); +} +.name-card .nm { + font: 600 22px/1.1 var(--font-sans); + color: var(--ink-cream); + letter-spacing: -0.01em; +} +.name-card .nm em { + font-style: normal; + color: var(--peri); + font-family: var(--font-mono); + font-weight: 500; +} +.name-card.picked .nm em { color: var(--peri); } +.name-card .ds { font-size: 12px; color: var(--ink-muted); line-height: 1.5; } +.name-card .picked-tag { + position: absolute; top: 10px; right: 10px; + font: 500 9px/1 var(--font-mono); + letter-spacing: var(--label-ls); + text-transform: uppercase; + color: var(--peri); +} +.name-card { position: relative; } + +/* ─── Palette card ───────────────────────────────────────── */ +.palette-card { + background: var(--surface-deep); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: var(--s-7); +} +.palette-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--s-3); } +.swatch { + border-radius: var(--r-sm); + padding: var(--s-4); + min-height: 140px; + display: flex; flex-direction: column; justify-content: space-between; + font-family: var(--font-mono); +} +.swatch .nm { font-size: 12px; font-weight: 500; letter-spacing: 0.04em; } +.swatch .hex { font-size: 11px; letter-spacing: 0.05em; opacity: 0.7; } + +/* ─── Footnote ──────────────────────────────────────────── */ +.footnote { + font-size: var(--t-small); color: var(--ink-muted); line-height: 1.6; + text-align: center; padding-top: var(--s-5); + max-width: 640px; margin: 0 auto; +} +.footnote code { + background: rgba(236,233,233,0.06); color: var(--ink-cream); + padding: 2px 8px; border-radius: 6px; + font: 500 12px/1 var(--font-mono); +} +.footnote b { color: var(--ink-cream); font-weight: 500; } + +/* ─── Mark-specific animations ───────────────────────────── */ +@keyframes caretBlink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } +.caret { animation: caretBlink 1s steps(1, end) infinite; } + +@keyframes dotBounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-4px); opacity: 1; } } +.dot { animation: dotBounce 1.2s ease-in-out infinite; transform-origin: center; } +.dot:nth-child(2) { animation-delay: 0.15s; } +.dot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-2px); } + 40%, 80% { transform: translateX(2px); } +} +.shake { animation: shake 0.5s ease-in-out; } diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..12b3944 --- /dev/null +++ b/styles.css @@ -0,0 +1,143 @@ +@import url("design-system/colors_and_type.css"); + +:root { + /* Respellion rebel accent — emerald + this one warm extra */ + --rebel: #FF6B4A; + --rebel-soft: #FFE3DA; + --rebel-deep: #C5462B; +} + +html, body, #root { margin: 0; height: 100%; background: #f0eee9; font-family: var(--font-sans); color: var(--fg-primary); } + +* { box-sizing: border-box; } + +/* ─── Artboard shell ──────────────────────────────────────── */ +.ab { + width: 100%; height: 100%; + display: flex; flex-direction: column; + background: var(--bg-app); + padding: 28px 28px 24px; + gap: 22px; +} + +.ab-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.ab-name { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; color: var(--slate-900); } +.ab-tag { font-size: 13px; color: var(--fg-secondary); margin-top: 4px; max-width: 320px; line-height: 1.45; } +.ab-swatch { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } +.ab-swatch i { width: 18px; height: 18px; border-radius: 999px; display: block; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); } + +/* ─── Hero stage ──────────────────────────────────────────── */ +.ab-hero { + background: #fff; + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + height: 220px; + display: grid; place-items: center; + position: relative; + overflow: hidden; +} +.ab-hero::before { + content: ""; position: absolute; inset: 0; + background-image: radial-gradient(rgba(15,23,42,0.06) 1px, transparent 1px); + background-size: 14px 14px; + opacity: 0.5; +} +.ab-hero > * { position: relative; } + +/* ─── States row ──────────────────────────────────────────── */ +.ab-states { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.ab-state { + background: #fff; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 14px 10px 10px; + display: flex; flex-direction: column; align-items: center; gap: 8px; +} +.ab-state-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-muted); } + +/* ─── Intranet chat-bubble preview ────────────────────────── */ +.ab-context { + background: #fff; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + height: 132px; + overflow: hidden; + position: relative; + display: flex; flex-direction: column; +} +.intra-bar { + height: 28px; flex-shrink: 0; + border-bottom: 1px solid var(--border-default); + background: #fff; + display: flex; align-items: center; gap: 6px; + padding: 0 10px; +} +.intra-bar-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--slate-300); } +.intra-bar-title { font-size: 10px; color: var(--fg-muted); margin-left: 4px; } +.intra-body { flex: 1; background: var(--bg-app); padding: 10px 12px; position: relative; } +.intra-line { height: 6px; border-radius: 4px; background: var(--slate-200); margin-bottom: 6px; } +.intra-line.w70 { width: 70%; } +.intra-line.w50 { width: 50%; } +.intra-line.w85 { width: 85%; } +.intra-line.w40 { width: 40%; } +.intra-bubble { + position: absolute; right: 14px; bottom: 14px; + width: 52px; height: 52px; border-radius: 999px; + background: #fff; + box-shadow: 0 8px 22px rgba(15,23,42,0.18), 0 0 0 1px rgba(15,23,42,0.06); + display: grid; place-items: center; + overflow: hidden; +} + +/* ─── Why this direction ──────────────────────────────────── */ +.ab-why { + font-size: 12px; color: var(--fg-secondary); line-height: 1.5; + padding-top: 4px; +} +.ab-why b { color: var(--slate-700); font-weight: 600; } + +/* ─── Section intro card (cover) ──────────────────────────── */ +.cover { + width: 100%; height: 100%; + background: var(--bg-app); + display: flex; flex-direction: column; justify-content: space-between; + padding: 40px 36px; +} +.cover h1 { font-size: 40px; font-weight: 700; letter-spacing: -0.025em; line-height: 1.05; } +.cover h1 span { color: var(--rebel); } +.cover .lede { font-size: 15px; color: var(--fg-secondary); max-width: 520px; line-height: 1.55; margin-top: 14px; } +.cover .meta { display: flex; gap: 28px; } +.cover .meta div { font-size: 12px; } +.cover .meta b { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--fg-muted); margin-bottom: 4px; } +.cover .palette { display: flex; gap: 10px; align-items: center; margin-top: 12px; } +.cover .palette i { display: block; width: 28px; height: 28px; border-radius: 999px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); } +.cover .palette span { font-size: 11px; color: var(--fg-muted); margin-left: 4px; font-family: var(--font-mono); } + +/* ─── Animations ──────────────────────────────────────────── */ +@keyframes blinkCaret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } +.blink-caret { animation: blinkCaret 1s steps(1, end) infinite; } + +@keyframes typeDot { 0%, 80%, 100% { transform: translateY(0); opacity: .35; } 40% { transform: translateY(-3px); opacity: 1; } } +.type-dot { animation: typeDot 1.2s ease-in-out infinite; transform-origin: center; } +.type-dot:nth-child(2) { animation-delay: 0.15s; } +.type-dot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +.float { animation: float 2.6s ease-in-out infinite; } + +@keyframes blink { 0%, 92%, 100% { transform: scaleY(1); } 95% { transform: scaleY(0.1); } } +.eye-blink { animation: blink 5s ease-in-out infinite; transform-origin: center; transform-box: fill-box; } + +@keyframes pixelGlitch { + 0%, 100% { transform: translate(0, 0); } + 20% { transform: translate(-1px, 0); } + 40% { transform: translate(1px, -1px); } + 60% { transform: translate(0, 1px); } +} +.glitch { animation: pixelGlitch 0.6s steps(1) infinite; } + +@keyframes squish { + 0%, 100% { transform: scale(1, 1); } + 50% { transform: scale(1.05, 0.95); } +} +.squish { animation: squish 1.8s ease-in-out infinite; transform-origin: 50% 60%; }