feat: implement R42 chat infrastructure with Anthropic API integration and custom design system
This commit is contained in:
23
AI_AGENT.md
23
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:anthropic_key` — Anthropic API key.
|
||||||
* `respellion:admin:model` — Model override.
|
* `respellion:admin:model` — Model override.
|
||||||
* `respellion:admin:use_simulation` — Simulation mode toggle.
|
* `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`).
|
**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.
|
* **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`).
|
* **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`).
|
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`.
|
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`).
|
3. **Build UI:** Create components using `Card`, `Button`, and `framer-motion` for smooth entry (`animate-in fade-in slide-in-from-bottom-4`).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 380 B |
BIN
public/fonts/BallPill-light.otf
Normal file
BIN
public/fonts/BallPill-light.otf
Normal file
Binary file not shown.
@@ -2,6 +2,8 @@ import React from 'react'
|
|||||||
import { Routes, Route, Navigate, Link } from 'react-router-dom'
|
import { Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||||
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
|
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
|
||||||
import { useApp } from './store/AppContext'
|
import { useApp } from './store/AppContext'
|
||||||
|
import Mark from './components/ui/Mark'
|
||||||
|
import ChatLauncher from './components/chat/ChatLauncher'
|
||||||
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
@@ -29,8 +31,9 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
|||||||
<div className="min-h-screen bg-bg text-fg font-sans flex flex-col pb-16 md:pb-0">
|
<div className="min-h-screen bg-bg text-fg font-sans flex flex-col pb-16 md:pb-0">
|
||||||
{/* Top Navigation Bar */}
|
{/* Top Navigation Bar */}
|
||||||
<nav className="bg-bg-dark text-paper p-4 shadow-soft flex justify-between items-center sticky top-0 z-50">
|
<nav className="bg-bg-dark text-paper p-4 shadow-soft flex justify-between items-center sticky top-0 z-50">
|
||||||
<Link to="/" className="flex items-center">
|
<Link to="/" className="flex items-center gap-2" aria-label="Respellion home">
|
||||||
<img src="/images/logo-light.png" alt="Respellion Logo" className="h-6 md:h-8 object-contain" />
|
<Mark state="idle" size={28} brace="#ECE9E9" letter="#ECE9E9" />
|
||||||
|
<span className="text-paper font-semibold tracking-tight text-base md:text-lg">respellion</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Links */}
|
{/* Desktop Links */}
|
||||||
@@ -74,6 +77,8 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
|
|||||||
<main className="flex-1 w-full max-w-[var(--max)] mx-auto">
|
<main className="flex-1 w-full max-w-[var(--max)] mx-auto">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ChatLauncher />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 * as d3 from 'd3';
|
||||||
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
||||||
import * as db from '../../lib/db';
|
import * as db from '../../lib/db';
|
||||||
import { anthropicApi } from '../../lib/api';
|
import { anthropicApi } from '../../lib/api';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import SuggestionsQueue from './SuggestionsQueue';
|
||||||
|
|
||||||
const KnowledgeGraph = () => {
|
const KnowledgeGraph = () => {
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
@@ -19,11 +20,18 @@ const KnowledgeGraph = () => {
|
|||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
const [relations, setRelations] = useState([]);
|
const [relations, setRelations] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const reloadKb = useCallback(() => {
|
||||||
db.getTopics().then(setTopics);
|
db.getTopics().then(setTopics);
|
||||||
db.getRelations().then(setRelations);
|
db.getRelations().then(setRelations);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadKb();
|
||||||
|
const handler = () => reloadKb();
|
||||||
|
window.addEventListener('respellion:kb-updated', handler);
|
||||||
|
return () => window.removeEventListener('respellion:kb-updated', handler);
|
||||||
|
}, [reloadKb]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wrapperRef.current) return;
|
if (!wrapperRef.current) return;
|
||||||
const { width, height } = wrapperRef.current.getBoundingClientRect();
|
const { width, height } = wrapperRef.current.getBoundingClientRect();
|
||||||
@@ -290,6 +298,11 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* R42 chatbot suggestions queue */}
|
||||||
|
<div className="mb-6 pb-4 border-b border-bg-warm">
|
||||||
|
<SuggestionsQueue onApplied={reloadKb} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-bold text-lg text-teal">Node Details</h3>
|
<h3 className="font-bold text-lg text-teal">Node Details</h3>
|
||||||
{selectedNode && !isEditing && (
|
{selectedNode && !isEditing && (
|
||||||
|
|||||||
89
src/components/admin/SuggestionsQueue.jsx
Normal file
89
src/components/admin/SuggestionsQueue.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="text-xs text-fg-muted flex items-center gap-2">
|
||||||
|
<Sparkles size={14} /> Geen openstaande voorstellen van R42.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-fg-muted uppercase tracking-wider font-mono flex items-center gap-2">
|
||||||
|
<Sparkles size={14} /> R42-voorstellen ({pending.length})
|
||||||
|
</div>
|
||||||
|
{pending.map(s => (
|
||||||
|
<div key={s.id} className="bg-bg rounded-[var(--r-sm)] border border-bg-warm p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-xs text-fg-muted flex items-center gap-2">
|
||||||
|
<Clock size={12} />
|
||||||
|
{new Date(s.ts).toLocaleString()}
|
||||||
|
{s.proposedByName && <> · door {s.proposedByName}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{s.reason && <p className="mb-2 text-fg">{s.reason}</p>}
|
||||||
|
{(s.topics?.length > 0 || s.relations?.length > 0) && (
|
||||||
|
<ul className="text-xs space-y-1 mb-3">
|
||||||
|
{s.topics?.map(t => (
|
||||||
|
<li key={`t-${t.id}`}>
|
||||||
|
<strong>{t.label}</strong>{' '}
|
||||||
|
<span className="text-fg-muted">({t.type})</span>
|
||||||
|
{t.description && <> — {t.description}</>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{s.relations?.map((r, i) => (
|
||||||
|
<li key={`r-${i}`}>
|
||||||
|
<code className="text-teal">{r.source}</code>{' → '}
|
||||||
|
<span className="text-fg-muted">{r.type}</span>{' → '}
|
||||||
|
<code className="text-teal">{r.target}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await kbStore.approveSuggestion(s.id);
|
||||||
|
refresh();
|
||||||
|
onApplied?.();
|
||||||
|
}}
|
||||||
|
className="text-xs py-1 px-3 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Check size={14} /> Goedkeuren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
kbStore.rejectSuggestion(s.id);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
className="text-xs py-1 px-3 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X size={14} /> Afwijzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/chat/ChatLauncher.jsx
Normal file
59
src/components/chat/ChatLauncher.jsx
Normal file
@@ -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 && (
|
||||||
|
<ChatWindow user={user} isAdmin={isAdmin} onClose={() => setOpen(false)} />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`r42-fab ${open ? 'open' : ''}`}
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
aria-label={open ? STRINGS.closeAria : STRINGS.openAria}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Mark state="idle" size={open ? 28 : 36} brace="#ECE9E9" letter="#ECE9E9" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/chat/ChatMessage.jsx
Normal file
84
src/components/chat/ChatMessage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="r42-msg me">
|
||||||
|
<div className="bub">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="r42-msg error">
|
||||||
|
<div className="av-sm">
|
||||||
|
<Mark state="error" size={20} brace="#ECE9E9" letter="#ECE9E9" />
|
||||||
|
</div>
|
||||||
|
<div className="bub">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// assistant
|
||||||
|
const s = msg.suggestion;
|
||||||
|
return (
|
||||||
|
<div className="r42-msg">
|
||||||
|
<div className="av-sm">
|
||||||
|
<Mark state="idle" size={20} brace="#ECE9E9" letter="#ECE9E9" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bub">{msg.content}</div>
|
||||||
|
{s && (
|
||||||
|
<div className="r42-suggestion" role="group" aria-label="Voorstel kennisgraaf">
|
||||||
|
<div className="r42-suggestion-title">{STRINGS.suggestionTitle}</div>
|
||||||
|
{s.reason && <div style={{ marginBottom: 6 }}>{s.reason}</div>}
|
||||||
|
{(s.topics.length > 0 || s.relations.length > 0) && (
|
||||||
|
<ul className="r42-suggestion-items">
|
||||||
|
{s.topics.map(t => (
|
||||||
|
<li key={`t-${t.id}`}>
|
||||||
|
<strong>{t.label}</strong>{' '}
|
||||||
|
<span style={{ opacity: 0.7 }}>({t.type})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{s.relations.map((r, i) => (
|
||||||
|
<li key={`r-${i}`}>
|
||||||
|
<code>{r.source}</code>{' '}
|
||||||
|
<span style={{ opacity: 0.7 }}>{r.type}</span>{' → '}
|
||||||
|
<code>{r.target}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{s.status === 'pending' ? (
|
||||||
|
<div className="r42-suggestion-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => onAcceptSuggestion(msg.id)}
|
||||||
|
>
|
||||||
|
{STRINGS.suggestionAccept}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onRejectSuggestion(msg.id)}>
|
||||||
|
{STRINGS.suggestionReject}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="r42-suggestion-status">
|
||||||
|
{s.status === 'applied' && STRINGS.suggestionAppliedAdmin}
|
||||||
|
{s.status === 'queued' && STRINGS.suggestionQueuedUser}
|
||||||
|
{s.status === 'rejected' && STRINGS.suggestionDismissed}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/chat/ChatWindow.jsx
Normal file
131
src/components/chat/ChatWindow.jsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="r42-window"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`${BOT_NAME} chatbot`}
|
||||||
|
aria-modal="false"
|
||||||
|
>
|
||||||
|
<header className="r42-window-hd">
|
||||||
|
<div className="av">
|
||||||
|
<Mark state={isThinking ? 'typing' : 'idle'} size={28} brace="#ECE9E9" letter="#ECE9E9" />
|
||||||
|
</div>
|
||||||
|
<div className="r42-window-hd-text">
|
||||||
|
<div className="r42-window-hd-name">{BOT_NAME}</div>
|
||||||
|
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="r42-window-hd-x"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={STRINGS.closeAria}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="r42-window-body" ref={bodyRef}>
|
||||||
|
{renderedMessages.map(m => (
|
||||||
|
<ChatMessage
|
||||||
|
key={m.id}
|
||||||
|
msg={m}
|
||||||
|
onAcceptSuggestion={handleAccept}
|
||||||
|
onRejectSuggestion={handleReject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{isThinking && (
|
||||||
|
<div className="r42-msg">
|
||||||
|
<div className="av-sm">
|
||||||
|
<Mark state="typing" size={20} brace="#ECE9E9" letter="#ECE9E9" />
|
||||||
|
</div>
|
||||||
|
<div className="bub" style={{ background: 'transparent', border: 'none', padding: '6px 0' }}>
|
||||||
|
<Mark state="typing" size={28} brace="#1F5560" letter="#1F5560" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="r42-window-input" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder={STRINGS.placeholder}
|
||||||
|
disabled={isThinking}
|
||||||
|
aria-label={STRINGS.placeholder}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={isThinking || !draft.trim()}>
|
||||||
|
{STRINGS.send}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
src/components/chat/chat.css
Normal file
233
src/components/chat/chat.css
Normal file
@@ -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; }
|
||||||
110
src/components/chat/prompts.js
Normal file
110
src/components/chat/prompts.js
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
};
|
||||||
127
src/components/chat/rag.js
Normal file
127
src/components/chat/rag.js
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
137
src/components/chat/useChat.js
Normal file
137
src/components/chat/useChat.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
71
src/components/ui/Mark.jsx
Normal file
71
src/components/ui/Mark.jsx
Normal file
@@ -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 (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`r42-mark ${state === 'error' ? 'shake' : ''}`}
|
||||||
|
>
|
||||||
|
{showFrame && theme !== 'none' && (
|
||||||
|
<rect x="2" y="2" width="96" height="96" rx="22" fill={bg} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="50"
|
||||||
|
y="50"
|
||||||
|
fontFamily="BallPill, ui-monospace, 'JetBrains Mono', monospace"
|
||||||
|
fontSize="78"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontWeight="300"
|
||||||
|
style={{ letterSpacing: '-0.04em', paintOrder: 'stroke fill' }}
|
||||||
|
>
|
||||||
|
<tspan fill={brace} stroke={brace} strokeWidth="0.6">{'{'}</tspan>
|
||||||
|
{middle && <tspan fill={letter} stroke={letter} strokeWidth="0.6">{middle}</tspan>}
|
||||||
|
{state === 'typing' && <tspan fill={letter}>{' '}</tspan>}
|
||||||
|
<tspan fill={brace} stroke={brace} strokeWidth="0.6">{'}'}</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{state === 'typing' && (
|
||||||
|
<g>
|
||||||
|
<circle className="dot" cx="40" cy="50" r="3.8" fill={letter} />
|
||||||
|
<circle className="dot" cx="50" cy="50" r="3.8" fill={letter} />
|
||||||
|
<circle className="dot" cx="60" cy="50" r="3.8" fill={letter} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 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";
|
@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 {
|
@theme {
|
||||||
--font-sans: "Space Grotesk", system-ui, sans-serif;
|
--font-sans: "Space Grotesk", system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
--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; }
|
.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); }
|
::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; }
|
||||||
|
|||||||
@@ -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() {
|
async function simulateResponse() {
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|||||||
117
src/lib/kbStore.js
Normal file
117
src/lib/kbStore.js
Normal file
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,9 +10,21 @@ import Button from '../components/ui/Button';
|
|||||||
import Tag from '../components/ui/Tag';
|
import Tag from '../components/ui/Tag';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService';
|
||||||
|
import { storage } from '../lib/storage';
|
||||||
|
|
||||||
const TIMER_SECONDS = 300; // 5 minutes
|
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 ──────────────────────────────────
|
// ─── Helper: format mm:ss ──────────────────────────────────
|
||||||
function formatTime(sec) {
|
function formatTime(sec) {
|
||||||
const m = Math.floor(sec / 60);
|
const m = Math.floor(sec / 60);
|
||||||
@@ -65,6 +77,19 @@ const Testen = () => {
|
|||||||
return () => clearInterval(timerRef.current);
|
return () => clearInterval(timerRef.current);
|
||||||
}, [phase]);
|
}, [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 ──
|
// ── Start quiz ──
|
||||||
const startQuiz = async () => {
|
const startQuiz = async () => {
|
||||||
setPhase('loading');
|
setPhase('loading');
|
||||||
@@ -76,6 +101,7 @@ const Testen = () => {
|
|||||||
setAnswers({});
|
setAnswers({});
|
||||||
setShowFeedback(false);
|
setShowFeedback(false);
|
||||||
setTimeLeft(TIMER_SECONDS);
|
setTimeLeft(TIMER_SECONDS);
|
||||||
|
setQuizActive(currentUser.id, true);
|
||||||
setPhase('quiz');
|
setPhase('quiz');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
|
|||||||
589
styles-r3x-v2.css
Normal file
589
styles-r3x-v2.css
Normal file
@@ -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; }
|
||||||
589
styles-r3x.css
Normal file
589
styles-r3x.css
Normal file
@@ -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; }
|
||||||
143
styles.css
Normal file
143
styles.css
Normal file
@@ -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%; }
|
||||||
Reference in New Issue
Block a user