diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 2d22c13..be8771b 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -33,6 +33,7 @@ export default defineConfig({
customCss: [
"./src/styles/custom.css",
"./src/styles/katex-fixes.css",
+ "./src/styles/chat-widget.css",
"katex/dist/katex.min.css",
],
head: [
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 2f4b673..d787e9c 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -17,7 +17,9 @@
"astro-mermaid": "^1.3.1",
"astro-opengraph-images": "^1.14.3",
"astro-seo-meta": "^5.2.0",
+ "dompurify": "^3.2.0",
"katex": "^0.16.28",
+ "marked": "^15.0.0",
"react": "^19.2.4",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
@@ -25,6 +27,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
+ "@types/dompurify": "^3.2.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
@@ -3072,6 +3075,17 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/dompurify": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
+ "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
+ "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3168,8 +3182,7 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
@@ -5902,7 +5915,6 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
- "peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -7771,16 +7783,15 @@
}
},
"node_modules/marked": {
- "version": "16.4.2",
- "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
- "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
- "peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
- "node": ">= 20"
+ "node": ">= 18"
}
},
"node_modules/mdast-util-definitions": {
@@ -8170,6 +8181,19 @@
"uuid": "^11.1.0"
}
},
+ "node_modules/mermaid/node_modules/marked": {
+ "version": "16.4.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
+ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
diff --git a/docs/package.json b/docs/package.json
index 418bef1..b76aac0 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -22,10 +22,13 @@
"react": "^19.2.4",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
+ "dompurify": "^3.2.0",
+ "marked": "^15.0.0",
"sharp": "^0.33.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
+ "@types/dompurify": "^3.2.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
diff --git a/docs/src/components/ChatWidget.astro b/docs/src/components/ChatWidget.astro
new file mode 100644
index 0000000..9554a1d
--- /dev/null
+++ b/docs/src/components/ChatWidget.astro
@@ -0,0 +1,10 @@
+---
+// Chat widget — floating panel for docs Q&A + live celestial queries
+// Appends to document.body to escape Starlight stacking contexts
+---
+
+
diff --git a/docs/src/components/Head.astro b/docs/src/components/Head.astro
index de76a82..c444d66 100644
--- a/docs/src/components/Head.astro
+++ b/docs/src/components/Head.astro
@@ -9,6 +9,7 @@
*/
import Default from "@astrojs/starlight/components/Head.astro";
import { getImagePath } from "astro-opengraph-images";
+import ChatWidget from "./ChatWidget.astro";
const route = Astro.locals.starlightRoute;
const title = route?.entry?.data?.title ?? "pg_orrery";
@@ -23,3 +24,4 @@ const ogImageUrl = getImagePath({ url: Astro.url, site: Astro.site });
+
diff --git a/docs/src/components/chat-widget.ts b/docs/src/components/chat-widget.ts
new file mode 100644
index 0000000..df9578b
--- /dev/null
+++ b/docs/src/components/chat-widget.ts
@@ -0,0 +1,1074 @@
+/**
+ * Chat widget for pg_orrery documentation.
+ * Floating panel with conversation history management.
+ * Powered by the RAG pipeline via POST /api/chat + live pg_orrery SQL.
+ */
+
+import { Marked } from 'marked';
+import DOMPurify from 'dompurify';
+
+interface ChatSource {
+ title: string;
+ slug: string;
+ url: string;
+ score: number;
+}
+
+interface ChatMessage {
+ role: 'user' | 'assistant';
+ text: string;
+ sources?: ChatSource[];
+}
+
+interface ConversationMeta {
+ id: string;
+ title: string;
+ createdAt: number;
+ updatedAt: number;
+ messageCount: number;
+}
+
+// ---- Storage keys and limits ----
+
+const STORAGE_KEY_INDEX = 'orrery-chat-conversations';
+const STORAGE_KEY_ACTIVE = 'orrery-chat-active';
+const STORAGE_KEY_PREFIX = 'orrery-chat-conv-';
+const MAX_CONVERSATIONS = 20;
+const MAX_MESSAGES = 50;
+const TITLE_MAX_LENGTH = 60;
+
+const SUGGESTIONS_DEFAULT = [
+ 'Where is Jupiter right now?',
+ 'How do I predict satellite passes?',
+ 'What functions handle rise/set prediction?',
+];
+
+function getSuggestions(): string[] {
+ return SUGGESTIONS_DEFAULT;
+}
+
+// ---- Utility functions ----
+
+function escapeHtml(text: string): string {
+ const el = document.createElement('span');
+ el.textContent = text;
+ return el.innerHTML;
+}
+
+// ---- Markdown rendering (scoped instance — no global mutation) ----
+
+const chatMarkdown = new Marked({ breaks: true, gfm: true, async: false });
+
+DOMPurify.addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute('href')) {
+ const href = node.getAttribute('href');
+ if (href && /^https?:\/\//i.test(href)) {
+ node.setAttribute('target', '_blank');
+ node.setAttribute('rel', 'noopener noreferrer');
+ }
+ }
+ if (node.tagName === 'INPUT') {
+ if (node.getAttribute('type') !== 'checkbox') {
+ node.remove();
+ }
+ }
+});
+
+function renderMarkdownSafe(text: string): string {
+ try {
+ const rawHtml = chatMarkdown.parse(text) as string;
+ return DOMPurify.sanitize(rawHtml, {
+ ADD_ATTR: ['target'],
+ ALLOWED_TAGS: [
+ 'p', 'br', 'strong', 'em', 'del', 's',
+ 'code', 'pre',
+ 'ul', 'ol', 'li',
+ 'blockquote',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'a', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
+ 'hr', 'input',
+ ],
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'title', 'type', 'checked', 'disabled'],
+ });
+ } catch {
+ return escapeHtml(text).replace(/\n/g, '
');
+ }
+}
+
+// ---- Storage helpers ----
+
+function loadIndex(): ConversationMeta[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_INDEX);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveIndex(index: ConversationMeta[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_INDEX, JSON.stringify(index));
+ } catch {
+ // Storage full or unavailable
+ }
+}
+
+function loadConversation(id: string): ChatMessage[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_PREFIX + id);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.slice(-MAX_MESSAGES) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveConversation(id: string, msgs: ChatMessage[]): void {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY_PREFIX + id,
+ JSON.stringify(msgs.slice(-MAX_MESSAGES)),
+ );
+ } catch {
+ // Storage full or unavailable
+ }
+}
+
+function removeConversationData(id: string): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY_PREFIX + id);
+ } catch {
+ // Silently continue
+ }
+}
+
+function getActiveId(): string | null {
+ try {
+ return localStorage.getItem(STORAGE_KEY_ACTIVE);
+ } catch {
+ return null;
+ }
+}
+
+function setActiveId(id: string): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_ACTIVE, id);
+ } catch {
+ // Silently continue
+ }
+}
+
+// ---- ID generation and title derivation ----
+
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+}
+
+function deriveTitle(msgs: ChatMessage[]): string {
+ const firstUser = msgs.find((m) => m.role === 'user');
+ if (!firstUser) return 'New conversation';
+ const text = firstUser.text.trim();
+ if (text.length <= TITLE_MAX_LENGTH) return text;
+ const truncated = text.slice(0, TITLE_MAX_LENGTH);
+ const lastSpace = truncated.lastIndexOf(' ');
+ return (lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated) + '\u2026';
+}
+
+function formatRelativeDate(timestamp: number): string {
+ const diff = Date.now() - timestamp;
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ if (hours < 24) return `${hours}h ago`;
+ if (days < 7) return `${days}d ago`;
+
+ return new Date(timestamp).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+// ---- Lucide SVG icon paths ----
+
+const ICON_MESSAGE_PLUS =
+ '' +
+ '';
+
+const ICON_HISTORY =
+ '' +
+ '';
+
+const ICON_CHEVRON_LEFT = '';
+
+const ICON_TRASH =
+ '' +
+ '' +
+ '';
+
+function svgIcon(paths: string): string {
+ return ``;
+}
+
+// ---- Main widget ----
+
+let cleanupPreviousInit: (() => void) | null = null;
+
+export function initChatWidget(): void {
+ if (document.getElementById('orrery-chat-fab')) return;
+
+ if (cleanupPreviousInit) {
+ cleanupPreviousInit();
+ cleanupPreviousInit = null;
+ }
+
+ let activeConversationId: string | null = getActiveId();
+ let messages: ChatMessage[] = [];
+ let abortController: AbortController | null = null;
+ let panelOpen = false;
+ let viewMode: 'chat' | 'history' = 'chat';
+ let pendingDeleteId: string | null = null;
+ let pendingDeleteTimer: number | null = null;
+
+ let streamingText = '';
+ let streamingSources: ChatSource[] = [];
+ let conversationSwitchInProgress = false;
+
+ let rafPending = false;
+ let rafBubble: HTMLElement | null = null;
+ let rafText = '';
+ let rafHandle: number | null = null;
+
+ function scheduleStreamRender(bubble: HTMLElement, text: string): void {
+ rafBubble = bubble;
+ rafText = text;
+ if (!rafPending) {
+ rafPending = true;
+ rafHandle = requestAnimationFrame(() => {
+ rafPending = false;
+ rafHandle = null;
+ if (rafBubble) {
+ rafBubble.innerHTML = renderMarkdownSafe(rafText);
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+ });
+ }
+ }
+
+ function cancelStreamRender(): void {
+ if (rafHandle !== null) {
+ cancelAnimationFrame(rafHandle);
+ rafHandle = null;
+ }
+ rafPending = false;
+ rafBubble = null;
+ rafText = '';
+ }
+
+ // ---- Conversation lifecycle ----
+
+ function ensureActiveConversation(): void {
+ const index = loadIndex();
+ if (activeConversationId && index.some((c) => c.id === activeConversationId)) return;
+
+ if (index.length > 0) {
+ activeConversationId = index[0].id;
+ } else {
+ createNewConversation();
+ return;
+ }
+ setActiveId(activeConversationId!);
+ }
+
+ function createNewConversation(): string {
+ const id = generateId();
+ const now = Date.now();
+ const index = loadIndex();
+ index.unshift({
+ id,
+ title: 'New conversation',
+ createdAt: now,
+ updatedAt: now,
+ messageCount: 0,
+ });
+
+ while (index.length > MAX_CONVERSATIONS) {
+ const removed = index.pop()!;
+ removeConversationData(removed.id);
+ }
+
+ saveIndex(index);
+ saveConversation(id, []);
+ activeConversationId = id;
+ setActiveId(id);
+ return id;
+ }
+
+ function saveHistory(): void {
+ if (!activeConversationId) return;
+ saveConversation(activeConversationId, messages);
+
+ const index = loadIndex();
+ const entry = index.find((c) => c.id === activeConversationId);
+ if (entry) {
+ entry.updatedAt = Date.now();
+ entry.messageCount = messages.length;
+ if (entry.title === 'New conversation') {
+ const newTitle = deriveTitle(messages);
+ if (newTitle !== 'New conversation') entry.title = newTitle;
+ }
+ saveIndex(index);
+ }
+ }
+
+ function startNewConversation(): void {
+ if (messages.length === 0 && !streamingText) return;
+
+ if (abortController && streamingText) {
+ messages.push({
+ role: 'assistant',
+ text: streamingText,
+ sources: streamingSources.length ? streamingSources : undefined,
+ });
+ streamingText = '';
+ streamingSources = [];
+ }
+
+ if (abortController) {
+ conversationSwitchInProgress = true;
+ abortController.abort();
+ abortController = null;
+ }
+
+ saveHistory();
+ createNewConversation();
+ messages = [];
+ cancelStreamRender();
+
+ setViewMode('chat');
+ renderMessages();
+ input.focus();
+ }
+
+ function switchToConversation(id: string): void {
+ if (id === activeConversationId) {
+ setViewMode('chat');
+ return;
+ }
+
+ if (abortController && streamingText) {
+ messages.push({
+ role: 'assistant',
+ text: streamingText,
+ sources: streamingSources.length ? streamingSources : undefined,
+ });
+ streamingText = '';
+ streamingSources = [];
+ }
+
+ if (abortController) {
+ conversationSwitchInProgress = true;
+ abortController.abort();
+ abortController = null;
+ }
+
+ saveHistory();
+
+ activeConversationId = id;
+ setActiveId(id);
+ messages = loadConversation(id);
+ cancelStreamRender();
+
+ setViewMode('chat');
+ renderMessages();
+ input.focus();
+ }
+
+ ensureActiveConversation();
+ messages = activeConversationId ? loadConversation(activeConversationId) : [];
+
+ // ---- Build DOM ----
+
+ // FAB — telescope/orbit icon
+ const fab = document.createElement('button');
+ fab.id = 'orrery-chat-fab';
+ fab.className = 'chat-fab';
+ fab.setAttribute('aria-label', 'Open orrery chat');
+ fab.setAttribute('aria-expanded', 'false');
+ fab.innerHTML = `
+
+
+ `;
+
+ // Panel
+ const panel = document.createElement('div');
+ panel.className = 'chat-panel';
+ panel.setAttribute('role', 'dialog');
+ panel.setAttribute('aria-label', 'Orrery chat');
+ panel.dataset.open = 'false';
+ panel.dataset.view = 'chat';
+
+ // Header
+ const header = document.createElement('div');
+ header.className = 'chat-header';
+
+ const backBtn = document.createElement('button');
+ backBtn.className = 'chat-header-btn chat-header-back';
+ backBtn.setAttribute('aria-label', 'Back to chat');
+ backBtn.innerHTML = svgIcon(ICON_CHEVRON_LEFT);
+ backBtn.style.display = 'none';
+
+ const headerDot = document.createElement('span');
+ headerDot.className = 'chat-header-dot';
+
+ const headerTitle = document.createElement('span');
+ headerTitle.className = 'chat-header-title';
+ headerTitle.textContent = 'Ask the Orrery';
+
+ const headerActions = document.createElement('div');
+ headerActions.className = 'chat-header-actions';
+
+ const newBtn = document.createElement('button');
+ newBtn.className = 'chat-header-btn';
+ newBtn.setAttribute('aria-label', 'New conversation');
+ newBtn.innerHTML = svgIcon(ICON_MESSAGE_PLUS);
+
+ const historyBtn = document.createElement('button');
+ historyBtn.className = 'chat-header-btn';
+ historyBtn.setAttribute('aria-label', 'Conversation history');
+ historyBtn.innerHTML = svgIcon(ICON_HISTORY);
+
+ headerActions.appendChild(newBtn);
+ headerActions.appendChild(historyBtn);
+
+ header.appendChild(backBtn);
+ header.appendChild(headerDot);
+ header.appendChild(headerTitle);
+ header.appendChild(headerActions);
+
+ // Messages container
+ const messagesEl = document.createElement('div');
+ messagesEl.className = 'chat-messages';
+ messagesEl.setAttribute('aria-live', 'polite');
+
+ // Input area
+ const inputArea = document.createElement('div');
+ inputArea.className = 'chat-input-area';
+
+ const input = document.createElement('input');
+ input.className = 'chat-input';
+ input.type = 'text';
+ input.placeholder = 'Ask about celestial mechanics...';
+ input.setAttribute('aria-label', 'Type your question');
+ input.maxLength = 1000;
+
+ const sendBtn = document.createElement('button');
+ sendBtn.className = 'chat-send';
+ sendBtn.textContent = 'Ask';
+ sendBtn.disabled = true;
+
+ inputArea.appendChild(input);
+ inputArea.appendChild(sendBtn);
+
+ panel.appendChild(header);
+ panel.appendChild(messagesEl);
+ panel.appendChild(inputArea);
+
+ document.body.appendChild(panel);
+ document.body.appendChild(fab);
+
+ // ---- View management ----
+
+ function setViewMode(mode: 'chat' | 'history'): void {
+ viewMode = mode;
+ panel.dataset.view = mode;
+
+ if (mode === 'history') {
+ backBtn.style.display = '';
+ headerDot.style.display = 'none';
+ headerTitle.textContent = 'Conversations';
+ renderHistoryList();
+ } else {
+ backBtn.style.display = 'none';
+ headerDot.style.display = '';
+ headerTitle.textContent = 'Ask the Orrery';
+ clearPendingDelete();
+ renderMessages();
+ }
+ }
+
+ function clearPendingDelete(): void {
+ if (pendingDeleteTimer !== null) {
+ clearTimeout(pendingDeleteTimer);
+ pendingDeleteTimer = null;
+ }
+ pendingDeleteId = null;
+ }
+
+ // ---- Rendering helpers ----
+
+ function renderSourceLinks(sources: ChatSource[]): string {
+ if (!sources.length) return '';
+ const links = sources
+ .filter((s) => /^https?:\/\//i.test(s.url) || s.url.startsWith('/'))
+ .map(
+ (s) =>
+ `${escapeHtml(s.title)}`,
+ )
+ .join('');
+ return links ? `
${links}
` : '';
+ }
+
+ function renderMessages(): void {
+ messagesEl.innerHTML = '';
+
+ if (messages.length === 0) {
+ const suggestions = document.createElement('div');
+ suggestions.className = 'chat-suggestions';
+ for (const text of getSuggestions()) {
+ const chip = document.createElement('button');
+ chip.className = 'chat-suggestion';
+ chip.textContent = text;
+ chip.addEventListener('click', () => {
+ input.value = text;
+ sendQuestion();
+ });
+ suggestions.appendChild(chip);
+ }
+ messagesEl.appendChild(suggestions);
+ return;
+ }
+
+ for (const msg of messages) {
+ const bubble = document.createElement('div');
+ bubble.className = `chat-msg chat-msg-${msg.role}`;
+ bubble.innerHTML = renderMarkdownSafe(msg.text);
+ if (msg.role === 'assistant' && msg.sources?.length) {
+ bubble.insertAdjacentHTML('beforeend', renderSourceLinks(msg.sources));
+ }
+ messagesEl.appendChild(bubble);
+ }
+
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+
+ function renderHistoryList(): void {
+ messagesEl.innerHTML = '';
+
+ const index = loadIndex();
+
+ if (index.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'chat-history-empty';
+ empty.innerHTML = `
+
+ No past conversations yet.
+ `;
+ messagesEl.appendChild(empty);
+ return;
+ }
+
+ const list = document.createElement('div');
+ list.className = 'chat-history-list';
+
+ for (const conv of index) {
+ const item = document.createElement('div');
+ item.className = 'chat-history-item';
+ item.setAttribute('role', 'button');
+ item.setAttribute('tabindex', '0');
+ if (conv.id === activeConversationId) item.dataset.active = '';
+ if (conv.id === pendingDeleteId) item.dataset.confirm = '';
+
+ const content = document.createElement('div');
+ content.className = 'chat-history-item-content';
+
+ const title = document.createElement('div');
+ title.className = 'chat-history-item-title';
+ title.textContent = conv.title;
+
+ const meta = document.createElement('div');
+ meta.className = 'chat-history-item-meta';
+
+ const count = document.createElement('span');
+ count.className = 'chat-history-item-count';
+ count.textContent = `${conv.messageCount} message${conv.messageCount !== 1 ? 's' : ''}`;
+
+ const date = document.createElement('span');
+ date.className = 'chat-history-item-date';
+ date.textContent = formatRelativeDate(conv.updatedAt);
+
+ meta.appendChild(count);
+ meta.appendChild(document.createTextNode(' \u00b7 '));
+ meta.appendChild(date);
+
+ content.appendChild(title);
+ content.appendChild(meta);
+
+ const deleteBtn = document.createElement('button');
+ deleteBtn.className = 'chat-history-delete';
+ deleteBtn.setAttribute('aria-label', 'Delete conversation');
+
+ if (conv.id === pendingDeleteId) {
+ deleteBtn.innerHTML = 'delete?';
+ } else {
+ deleteBtn.innerHTML = svgIcon(ICON_TRASH);
+ }
+
+ item.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('.chat-history-delete')) return;
+ switchToConversation(conv.id);
+ });
+
+ item.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ if ((e.target as HTMLElement).closest('.chat-history-delete')) return;
+ switchToConversation(conv.id);
+ }
+ });
+
+ deleteBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ handleDelete(conv.id);
+ });
+
+ item.appendChild(content);
+ item.appendChild(deleteBtn);
+ list.appendChild(item);
+ }
+
+ messagesEl.appendChild(list);
+ }
+
+ // ---- Two-click delete ----
+
+ function handleDelete(id: string): void {
+ if (pendingDeleteId === id) {
+ clearPendingDelete();
+ performDelete(id);
+ } else {
+ clearPendingDelete();
+ pendingDeleteId = id;
+ pendingDeleteTimer = window.setTimeout(() => {
+ pendingDeleteId = null;
+ pendingDeleteTimer = null;
+ if (viewMode === 'history') renderHistoryList();
+ }, 3000);
+ renderHistoryList();
+ }
+ }
+
+ function performDelete(id: string): void {
+ let index = loadIndex();
+ index = index.filter((c) => c.id !== id);
+ saveIndex(index);
+ removeConversationData(id);
+
+ if (id === activeConversationId) {
+ if (index.length > 0) {
+ activeConversationId = index[0].id;
+ setActiveId(activeConversationId);
+ messages = loadConversation(activeConversationId);
+ } else {
+ createNewConversation();
+ messages = [];
+ setViewMode('chat');
+ return;
+ }
+ }
+
+ renderHistoryList();
+ }
+
+ // ---- Thinking indicator ----
+
+ function showThinking(): HTMLElement {
+ const el = document.createElement('div');
+ el.className = 'chat-thinking';
+ el.innerHTML = `
+
+
+
+
+ `;
+ messagesEl.appendChild(el);
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+
+ const timer = setTimeout(() => {
+ const hint = el.querySelector('.chat-thinking-text');
+ if (hint) hint.textContent = 'This can take a moment\u2026';
+ }, 5000);
+ el.dataset.timer = String(timer);
+
+ return el;
+ }
+
+ function removeThinking(el: HTMLElement): void {
+ clearTimeout(Number(el.dataset.timer));
+ el.remove();
+ }
+
+ // ---- Page context ----
+
+ function getPageContext(): { title: string; path: string; description: string } | null {
+ const title = document.querySelector('main h1')?.textContent?.trim();
+ if (!title) return null;
+ return {
+ title,
+ path: location.pathname,
+ description:
+ document.querySelector('meta[name="description"]')?.content || '',
+ };
+ }
+
+ // ---- SSE helpers ----
+
+ function updateThinkingStatus(el: HTMLElement, text: string): void {
+ const hint = el.querySelector('.chat-thinking-text');
+ if (hint) hint.textContent = text;
+ }
+
+ function tryParseJSON(raw: string): unknown | null {
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ }
+
+ function parseSSEBlock(raw: string): { event: string; data: string } | null {
+ let event = '';
+ let data = '';
+ for (const line of raw.split('\n')) {
+ if (line.startsWith('event: ')) event = line.slice(7);
+ else if (line.startsWith('data: ')) data = line.slice(6);
+ }
+ return event && data ? { event, data } : null;
+ }
+
+ // ---- Send question ----
+
+ async function sendQuestion(): Promise {
+ const question = input.value.trim();
+ if (!question) return;
+
+ conversationSwitchInProgress = false;
+
+ if (question.length > 1000) {
+ messages.push({
+ role: 'assistant',
+ text: 'That question is too long (max 1,000 characters). Please shorten it and try again.',
+ });
+ saveHistory();
+ renderMessages();
+ return;
+ }
+
+ if (abortController && streamingText) {
+ messages.push({
+ role: 'assistant',
+ text: streamingText,
+ sources: streamingSources.length ? streamingSources : undefined,
+ });
+ streamingText = '';
+ streamingSources = [];
+ }
+
+ if (abortController) {
+ conversationSwitchInProgress = true;
+ abortController.abort();
+ }
+ abortController = new AbortController();
+ cancelStreamRender();
+ const timeout = setTimeout(() => abortController!.abort(), 120_000);
+
+ messages.push({ role: 'user', text: question });
+ saveHistory();
+ input.value = '';
+ sendBtn.disabled = true;
+ renderMessages();
+
+ const thinking = showThinking();
+
+ let fullText = '';
+ let reasoningText = '';
+ let reasoningStart = 0;
+ let localSources: ChatSource[] = [];
+ let bubble: HTMLElement | null = null;
+
+ try {
+ const body: Record = { question };
+ const page = getPageContext();
+ if (page) body.page = page;
+
+ const resp = await fetch('/api/chat/stream', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: abortController.signal,
+ });
+
+ if (!resp.ok || !resp.body) {
+ clearTimeout(timeout);
+ removeThinking(thinking);
+ messages.push({
+ role: 'assistant',
+ text:
+ resp.status >= 400 && resp.status < 500
+ ? 'Invalid request. Try rephrasing your question.'
+ : 'The orrery is temporarily unavailable. Please try again in a moment.',
+ });
+ saveHistory();
+ renderMessages();
+ streamingText = '';
+ streamingSources = [];
+ return;
+ }
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ let boundary: number;
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
+ const raw = buffer.slice(0, boundary);
+ buffer = buffer.slice(boundary + 2);
+
+ const evt = parseSSEBlock(raw);
+ if (!evt) continue;
+
+ if (evt.event === 'status') {
+ const parsed = tryParseJSON(evt.data) as { text?: string } | null;
+ if (parsed?.text) updateThinkingStatus(thinking, parsed.text);
+ } else if (evt.event === 'sources') {
+ const parsed = tryParseJSON(evt.data);
+ if (Array.isArray(parsed)) {
+ localSources = parsed;
+ streamingSources = parsed;
+ }
+ } else if (evt.event === 'reasoning') {
+ const parsed = tryParseJSON(evt.data) as { text?: string } | null;
+ if (!parsed?.text) continue;
+ if (!reasoningStart) reasoningStart = Date.now();
+ reasoningText += parsed.text;
+ updateThinkingStatus(thinking, reasoningText.slice(-80).replace(/\n/g, ' '));
+ } else if (evt.event === 'token') {
+ const parsed = tryParseJSON(evt.data) as { text?: string } | null;
+ if (!parsed?.text) continue;
+ fullText += parsed.text;
+
+ streamingText = fullText;
+
+ if (!bubble) {
+ removeThinking(thinking);
+ if (reasoningText) {
+ const secs = Math.round((Date.now() - reasoningStart) / 1000);
+ const details = document.createElement('details');
+ details.className = 'chat-reasoning';
+ details.innerHTML =
+ `Thought for ${secs}s` +
+ `${escapeHtml(reasoningText).replace(/\n/g, '
')}
`;
+ messagesEl.appendChild(details);
+ }
+ bubble = document.createElement('div');
+ bubble.className = 'chat-msg chat-msg-assistant';
+ messagesEl.appendChild(bubble);
+ }
+ scheduleStreamRender(bubble, fullText);
+ } else if (evt.event === 'done') {
+ clearTimeout(timeout);
+ if (bubble) {
+ cancelStreamRender();
+ bubble.innerHTML = renderMarkdownSafe(fullText);
+ if (localSources.length) {
+ bubble.insertAdjacentHTML('beforeend', renderSourceLinks(localSources));
+ }
+ } else {
+ removeThinking(thinking);
+ fullText = 'The model was unable to generate an answer. Try rephrasing your question.';
+ bubble = document.createElement('div');
+ bubble.className = 'chat-msg chat-msg-assistant';
+ bubble.innerHTML = escapeHtml(fullText);
+ messagesEl.appendChild(bubble);
+ }
+ messages.push({
+ role: 'assistant',
+ text: fullText,
+ sources: localSources.length ? localSources : undefined,
+ });
+ saveHistory();
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ streamingText = '';
+ streamingSources = [];
+ return;
+ } else if (evt.event === 'error') {
+ const parsed = tryParseJSON(evt.data) as { text?: string } | null;
+ clearTimeout(timeout);
+ removeThinking(thinking);
+ messages.push({
+ role: 'assistant',
+ text: parsed?.text || 'An error occurred.',
+ });
+ saveHistory();
+ renderMessages();
+ streamingText = '';
+ streamingSources = [];
+ return;
+ }
+ }
+ }
+
+ clearTimeout(timeout);
+ if (bubble) {
+ bubble.innerHTML = renderMarkdownSafe(fullText);
+ if (localSources.length) {
+ bubble.insertAdjacentHTML('beforeend', renderSourceLinks(localSources));
+ }
+ } else {
+ removeThinking(thinking);
+ }
+ if (fullText) {
+ messages.push({
+ role: 'assistant',
+ text: fullText,
+ sources: localSources.length ? localSources : undefined,
+ });
+ } else {
+ messages.push({
+ role: 'assistant',
+ text: 'The response ended unexpectedly. Please try again.',
+ });
+ }
+ saveHistory();
+ renderMessages();
+ streamingText = '';
+ streamingSources = [];
+ } catch (err) {
+ clearTimeout(timeout);
+ streamingText = '';
+ streamingSources = [];
+
+ if (conversationSwitchInProgress) {
+ conversationSwitchInProgress = false;
+ return;
+ }
+
+ if (bubble && fullText) {
+ bubble.innerHTML = renderMarkdownSafe(fullText);
+ messages.push({
+ role: 'assistant',
+ text: fullText,
+ sources: localSources.length ? localSources : undefined,
+ });
+ } else {
+ removeThinking(thinking);
+ if (err instanceof DOMException && err.name === 'AbortError') return;
+ messages.push({
+ role: 'assistant',
+ text: 'Unable to reach the orrery. Please check your connection and try again.',
+ });
+ }
+ saveHistory();
+ renderMessages();
+ }
+ }
+
+ // ---- Toggle panel ----
+
+ function openPanel(): void {
+ panelOpen = true;
+ panel.dataset.open = 'true';
+ fab.setAttribute('aria-expanded', 'true');
+ if (viewMode === 'chat') {
+ renderMessages();
+ input.focus();
+ } else {
+ renderHistoryList();
+ }
+ }
+
+ function closePanel(): void {
+ panelOpen = false;
+ panel.dataset.open = 'false';
+ fab.setAttribute('aria-expanded', 'false');
+ clearPendingDelete();
+ fab.focus();
+ }
+
+ fab.addEventListener('click', () => {
+ if (panelOpen) closePanel();
+ else openPanel();
+ });
+
+ // ---- Header button handlers ----
+
+ newBtn.addEventListener('click', startNewConversation);
+
+ historyBtn.addEventListener('click', () => {
+ if (viewMode === 'history') {
+ setViewMode('chat');
+ } else {
+ setViewMode('history');
+ }
+ });
+
+ backBtn.addEventListener('click', () => {
+ setViewMode('chat');
+ });
+
+ // ---- Input handlers ----
+
+ input.addEventListener('input', () => {
+ sendBtn.disabled = !input.value.trim();
+ });
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && input.value.trim()) {
+ e.preventDefault();
+ sendQuestion();
+ }
+ });
+
+ sendBtn.addEventListener('click', sendQuestion);
+
+ // ---- Keyboard: Escape ----
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && panelOpen) {
+ e.preventDefault();
+ if (viewMode === 'history') {
+ setViewMode('chat');
+ } else {
+ closePanel();
+ }
+ }
+ };
+ document.addEventListener('keydown', handleEscape);
+
+ cleanupPreviousInit = () => {
+ document.removeEventListener('keydown', handleEscape);
+ };
+
+ renderMessages();
+}
diff --git a/docs/src/styles/chat-widget.css b/docs/src/styles/chat-widget.css
new file mode 100644
index 0000000..f7e8d80
--- /dev/null
+++ b/docs/src/styles/chat-widget.css
@@ -0,0 +1,889 @@
+/* ============================================
+ pg_orrery — Chat Widget
+ FAB + sliding panel for docs Q&A
+ ============================================ */
+
+/* ---- FAB (floating action button) ---- */
+.chat-fab {
+ position: fixed;
+ bottom: 1.5rem;
+ right: 1.5rem;
+ z-index: 1000;
+ width: 3.25rem;
+ height: 3.25rem;
+ border-radius: 50%;
+ border: none;
+ background: var(--sl-color-accent);
+ color: var(--sl-color-black);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+}
+
+.chat-fab:hover {
+ transform: scale(1.08);
+ box-shadow: 0 4px 20px rgba(245, 158, 11, 0.3);
+}
+
+.chat-fab:active {
+ transform: scale(0.96);
+}
+
+.chat-fab svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+/* X icon when panel is open */
+.chat-fab[aria-expanded="true"] svg.icon-message { display: none; }
+.chat-fab[aria-expanded="true"] svg.icon-close { display: block; }
+.chat-fab[aria-expanded="false"] svg.icon-message { display: block; }
+.chat-fab[aria-expanded="false"] svg.icon-close { display: none; }
+
+/* ---- Panel ---- */
+.chat-panel {
+ position: fixed;
+ bottom: 5.5rem;
+ right: 1.5rem;
+ z-index: 999;
+ width: 24rem;
+ max-height: 70vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--sl-color-bg-nav);
+ border: 1px solid var(--sl-color-hairline-light);
+ border-radius: 0.75rem;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ transform: translateY(0.5rem);
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 0.2s ease, opacity 0.2s ease;
+}
+
+.chat-panel[data-open="true"] {
+ transform: translateY(0);
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* ---- Panel header ---- */
+.chat-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--sl-color-hairline-shade);
+ font-family: var(--sl-font-headings);
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--sl-color-white);
+}
+
+.chat-header-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ background: var(--sl-color-accent);
+ flex-shrink: 0;
+}
+
+.chat-header-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chat-header-actions {
+ display: flex;
+ gap: 0.25rem;
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.chat-header-btn {
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--sl-color-gray-3);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.25rem;
+ transition: color 0.15s ease, background 0.15s ease;
+}
+
+.chat-header-btn:hover {
+ color: var(--sl-color-white);
+ background: var(--sl-color-gray-5);
+}
+
+.chat-header-btn:active {
+ transform: scale(0.92);
+}
+
+.chat-header-btn svg {
+ width: 1rem;
+ height: 1rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+/* ---- Messages area ---- */
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-height: 8rem;
+ scroll-behavior: smooth;
+}
+
+.chat-messages::-webkit-scrollbar {
+ width: 4px;
+}
+
+.chat-messages::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.chat-messages::-webkit-scrollbar-thumb {
+ background: var(--sl-color-gray-4);
+ border-radius: 2px;
+}
+
+/* ---- Message bubbles ---- */
+.chat-msg {
+ max-width: 88%;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ color: var(--sl-color-white);
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.chat-msg a {
+ color: var(--sl-color-accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.chat-msg a:hover {
+ color: var(--sl-color-accent-high);
+}
+
+.chat-msg-user {
+ align-self: flex-end;
+ background: var(--sl-color-accent-low);
+ border: 1px solid var(--sl-color-hairline-light);
+}
+
+.chat-msg-assistant {
+ align-self: flex-start;
+ background: var(--sl-color-gray-6);
+ max-width: 100%;
+}
+
+.chat-msg-assistant strong {
+ color: var(--sl-color-accent-high);
+}
+
+.chat-msg-assistant code {
+ font-family: var(--sl-font-mono);
+ font-size: 0.82em;
+ background: var(--sl-color-gray-5);
+ padding: 0.1em 0.3em;
+ border-radius: 0.2rem;
+}
+
+/* ---- Markdown elements in assistant bubbles ---- */
+.chat-msg-assistant p {
+ margin: 0 0 0.4em;
+}
+
+.chat-msg-assistant p:last-child {
+ margin-bottom: 0;
+}
+
+.chat-msg-assistant h1,
+.chat-msg-assistant h2,
+.chat-msg-assistant h3,
+.chat-msg-assistant h4,
+.chat-msg-assistant h5,
+.chat-msg-assistant h6 {
+ font-family: var(--sl-font-headings);
+ margin: 0.6em 0 0.3em;
+ line-height: 1.3;
+ color: var(--sl-color-accent-high);
+}
+
+.chat-msg-assistant h1 { font-size: 1.1em; }
+.chat-msg-assistant h2 { font-size: 1.05em; }
+.chat-msg-assistant h3 { font-size: 1em; }
+.chat-msg-assistant h4,
+.chat-msg-assistant h5,
+.chat-msg-assistant h6 { font-size: 0.9em; }
+
+.chat-msg-assistant h1:first-child,
+.chat-msg-assistant h2:first-child,
+.chat-msg-assistant h3:first-child {
+ margin-top: 0;
+}
+
+.chat-msg-assistant ul,
+.chat-msg-assistant ol {
+ margin: 0.3em 0;
+ padding-left: 1.4em;
+}
+
+.chat-msg-assistant li {
+ margin: 0.15em 0;
+}
+
+.chat-msg-assistant li > ul,
+.chat-msg-assistant li > ol {
+ margin: 0.1em 0;
+}
+
+.chat-msg-assistant blockquote {
+ margin: 0.4em 0;
+ padding: 0.3em 0.6em;
+ border-left: 3px solid var(--sl-color-accent);
+ background: var(--sl-color-gray-5);
+ border-radius: 0 0.25rem 0.25rem 0;
+ font-style: italic;
+}
+
+.chat-msg-assistant blockquote p {
+ margin: 0;
+}
+
+.chat-msg-assistant pre {
+ margin: 0.4em 0;
+ padding: 0.5em 0.65em;
+ background: var(--sl-color-gray-5);
+ border-radius: 0.375rem;
+ overflow-x: auto;
+ font-size: 0.82em;
+ line-height: 1.45;
+}
+
+.chat-msg-assistant pre code {
+ background: none;
+ padding: 0;
+ border-radius: 0;
+ font-size: inherit;
+}
+
+.chat-msg-assistant table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.4em 0;
+ font-size: 0.82em;
+}
+
+.chat-msg-assistant th,
+.chat-msg-assistant td {
+ padding: 0.3em 0.5em;
+ border: 1px solid var(--sl-color-hairline-shade);
+ text-align: left;
+}
+
+.chat-msg-assistant th {
+ background: var(--sl-color-gray-5);
+ font-weight: 600;
+}
+
+.chat-msg-assistant tbody tr:nth-child(even) {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.chat-msg-assistant hr {
+ border: none;
+ border-top: 1px solid var(--sl-color-hairline-shade);
+ margin: 0.5em 0;
+}
+
+.chat-msg-assistant del,
+.chat-msg-assistant s {
+ opacity: 0.65;
+}
+
+.chat-msg-assistant input[type="checkbox"] {
+ pointer-events: none;
+ margin-right: 0.3em;
+}
+
+/* ---- Sources below assistant message ---- */
+.chat-sources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-top: 0.35rem;
+ padding-top: 0.35rem;
+ border-top: 1px solid var(--sl-color-hairline-shade);
+}
+
+.chat-source-link {
+ font-size: 0.72rem;
+ color: var(--sl-color-accent);
+ text-decoration: none;
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ background: var(--sl-color-accent-low);
+ transition: background 0.1s ease;
+ white-space: nowrap;
+}
+
+.chat-source-link:hover {
+ background: var(--sl-color-hairline-light);
+ color: var(--sl-color-accent-high);
+}
+
+/* ---- Thinking indicator ---- */
+.chat-thinking {
+ align-self: flex-start;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+}
+
+.chat-thinking-dots {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.chat-thinking-dots span {
+ width: 0.4rem;
+ height: 0.4rem;
+ border-radius: 50%;
+ background: var(--sl-color-gray-3);
+ animation: chat-pulse 1.4s ease-in-out infinite;
+}
+
+.chat-thinking-dots span:nth-child(2) {
+ animation-delay: 0.2s;
+}
+
+.chat-thinking-dots span:nth-child(3) {
+ animation-delay: 0.4s;
+}
+
+@keyframes chat-pulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+.chat-thinking-text {
+ font-size: 0.75rem;
+ color: var(--sl-color-gray-3);
+ transition: opacity 0.15s ease;
+ max-width: 16rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-style: italic;
+}
+
+/* ---- Reasoning (collapsed thinking) ---- */
+.chat-reasoning {
+ align-self: flex-start;
+ margin: 0.25rem 0;
+ font-size: 0.75rem;
+ color: var(--sl-color-gray-3);
+}
+
+.chat-reasoning summary {
+ cursor: pointer;
+ font-style: italic;
+ user-select: none;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.chat-reasoning summary::before {
+ content: '\25B6';
+ font-size: 0.5rem;
+ transition: transform 0.15s ease;
+}
+
+.chat-reasoning[open] summary::before {
+ transform: rotate(90deg);
+}
+
+.chat-reasoning-body {
+ margin-top: 0.35rem;
+ padding: 0.5rem 0.625rem;
+ background: var(--sl-color-gray-6);
+ border-radius: 0.375rem;
+ font-size: 0.7rem;
+ line-height: 1.45;
+ max-height: 10rem;
+ overflow-y: auto;
+ color: var(--sl-color-gray-2);
+ font-style: italic;
+}
+
+/* ---- Suggestion chips ---- */
+.chat-suggestions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ padding: 0.25rem 0;
+}
+
+.chat-suggestion {
+ background: none;
+ border: 1px solid var(--sl-color-hairline-light);
+ border-radius: 0.5rem;
+ padding: 0.45rem 0.65rem;
+ font-size: 0.8rem;
+ color: var(--sl-color-gray-2);
+ cursor: pointer;
+ text-align: left;
+ font-family: var(--sl-font);
+ transition: border-color 0.15s ease, color 0.15s ease;
+}
+
+.chat-suggestion:hover {
+ border-color: var(--sl-color-accent);
+ color: var(--sl-color-accent);
+}
+
+/* ---- Input area ---- */
+.chat-input-area {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.65rem 0.75rem;
+ border-top: 1px solid var(--sl-color-hairline-shade);
+}
+
+.chat-input {
+ flex: 1;
+ background: var(--sl-color-gray-6);
+ border: 1px solid var(--sl-color-hairline-light);
+ border-radius: 0.4rem;
+ padding: 0.45rem 0.65rem;
+ font-size: 0.85rem;
+ font-family: var(--sl-font);
+ color: var(--sl-color-white);
+ outline: none;
+ transition: border-color 0.15s ease;
+}
+
+.chat-input::placeholder {
+ color: var(--sl-color-gray-3);
+}
+
+.chat-input:focus {
+ border-color: var(--sl-color-accent);
+}
+
+.chat-send {
+ background: var(--sl-color-accent);
+ color: var(--sl-color-black);
+ border: none;
+ border-radius: 0.4rem;
+ padding: 0.45rem 0.65rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: var(--sl-font);
+ transition: opacity 0.1s ease;
+}
+
+.chat-send:hover {
+ opacity: 0.9;
+}
+
+.chat-send:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* ---- History list ---- */
+.chat-history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.chat-history-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.6rem 0.75rem;
+ border-radius: 0.4rem;
+ cursor: pointer;
+ border-left: 3px solid transparent;
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.chat-history-item:hover {
+ background: var(--sl-color-gray-6);
+}
+
+.chat-history-item:focus-visible {
+ outline: 2px solid var(--sl-color-accent);
+ outline-offset: -2px;
+}
+
+.chat-history-item[data-active] {
+ border-left-color: var(--sl-color-accent);
+}
+
+.chat-history-item[data-confirm] {
+ background: rgba(220, 50, 50, 0.08);
+ border-left-color: #dc3232;
+}
+
+.chat-history-item-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.chat-history-item-title {
+ font-size: 0.82rem;
+ color: var(--sl-color-white);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.4;
+}
+
+.chat-history-item-meta {
+ font-size: 0.72rem;
+ color: var(--sl-color-gray-3);
+ margin-top: 0.1rem;
+}
+
+.chat-history-delete {
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--sl-color-gray-4);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.15s ease, color 0.15s ease;
+ flex-shrink: 0;
+}
+
+.chat-history-item:hover .chat-history-delete,
+.chat-history-item[data-confirm] .chat-history-delete {
+ opacity: 1;
+}
+
+.chat-history-delete:hover {
+ color: #dc3232;
+}
+
+.chat-history-delete svg {
+ width: 0.85rem;
+ height: 0.85rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.chat-history-delete-confirm {
+ font-size: 0.7rem;
+ color: #dc3232;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+/* ---- Empty state ---- */
+.chat-history-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1rem;
+ gap: 0.75rem;
+}
+
+.chat-history-empty-icon {
+ width: 2rem;
+ height: 2rem;
+ fill: none;
+ stroke: var(--sl-color-gray-4);
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ opacity: 0.6;
+}
+
+.chat-history-empty-hint {
+ font-size: 0.82rem;
+ color: var(--sl-color-gray-3);
+}
+
+/* ---- View toggle: hide input in history mode ---- */
+.chat-panel[data-view="history"] .chat-input-area {
+ display: none;
+}
+
+/* ---- Mobile: full-screen panel ---- */
+@media (max-width: 40rem) {
+ .chat-fab {
+ bottom: 1rem;
+ right: 1rem;
+ width: 2.75rem;
+ height: 2.75rem;
+ }
+
+ .chat-fab svg {
+ width: 1.1rem;
+ height: 1.1rem;
+ }
+
+ .chat-panel {
+ inset: 0;
+ width: 100%;
+ max-height: 100%;
+ border-radius: 0;
+ bottom: 0;
+ right: 0;
+ }
+
+ .chat-panel[data-open="true"] {
+ transform: translateY(0);
+ }
+
+ .chat-panel[data-open="true"] ~ .chat-fab {
+ bottom: 0.45rem;
+ }
+
+ .chat-input-area {
+ padding-right: 4.25rem;
+ }
+}
+
+/* ---- Light theme overrides ---- */
+:root[data-theme='light'] .chat-panel {
+ background: #fdfcf9;
+ border-color: rgba(245, 158, 11, 0.2);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
+}
+
+:root[data-theme='light'] .chat-header {
+ color: #1a1a2e;
+ border-bottom-color: rgba(245, 158, 11, 0.15);
+}
+
+:root[data-theme='light'] .chat-header-btn {
+ color: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-header-btn:hover {
+ color: #1a1a2e;
+ background: #f1f5f9;
+}
+
+:root[data-theme='light'] .chat-msg {
+ color: #1a1a2e;
+}
+
+:root[data-theme='light'] .chat-msg-user {
+ background: #fef3c7;
+ border-color: rgba(245, 158, 11, 0.2);
+}
+
+:root[data-theme='light'] .chat-msg-assistant {
+ background: #f8fafc;
+}
+
+:root[data-theme='light'] .chat-msg-assistant strong {
+ color: #b45309;
+}
+
+:root[data-theme='light'] .chat-msg-assistant code {
+ background: #f1f5f9;
+}
+
+:root[data-theme='light'] .chat-msg-assistant h1,
+:root[data-theme='light'] .chat-msg-assistant h2,
+:root[data-theme='light'] .chat-msg-assistant h3,
+:root[data-theme='light'] .chat-msg-assistant h4,
+:root[data-theme='light'] .chat-msg-assistant h5,
+:root[data-theme='light'] .chat-msg-assistant h6 {
+ color: #b45309;
+}
+
+:root[data-theme='light'] .chat-msg-assistant blockquote {
+ background: #f1f5f9;
+ border-left-color: #f59e0b;
+}
+
+:root[data-theme='light'] .chat-msg-assistant pre {
+ background: #f1f5f9;
+}
+
+:root[data-theme='light'] .chat-msg-assistant th {
+ background: #f1f5f9;
+}
+
+:root[data-theme='light'] .chat-msg-assistant th,
+:root[data-theme='light'] .chat-msg-assistant td {
+ border-color: rgba(245, 158, 11, 0.2);
+}
+
+:root[data-theme='light'] .chat-msg-assistant tbody tr:nth-child(even) {
+ background: rgba(245, 158, 11, 0.04);
+}
+
+:root[data-theme='light'] .chat-msg-assistant hr {
+ border-top-color: rgba(245, 158, 11, 0.15);
+}
+
+:root[data-theme='light'] .chat-msg a {
+ color: #d97706;
+}
+
+:root[data-theme='light'] .chat-msg a:hover {
+ color: #92400e;
+}
+
+:root[data-theme='light'] .chat-sources {
+ border-top-color: rgba(245, 158, 11, 0.15);
+}
+
+:root[data-theme='light'] .chat-source-link {
+ color: #d97706;
+ background: #fef3c7;
+}
+
+:root[data-theme='light'] .chat-source-link:hover {
+ background: rgba(245, 158, 11, 0.15);
+ color: #92400e;
+}
+
+:root[data-theme='light'] .chat-input {
+ background: #f8fafc;
+ border-color: rgba(245, 158, 11, 0.2);
+ color: #1a1a2e;
+}
+
+:root[data-theme='light'] .chat-input::placeholder {
+ color: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-input-area {
+ border-top-color: rgba(245, 158, 11, 0.15);
+}
+
+:root[data-theme='light'] .chat-send {
+ background: #f59e0b;
+ color: #1a1a2e;
+}
+
+:root[data-theme='light'] .chat-suggestion {
+ color: #475569;
+ border-color: rgba(245, 158, 11, 0.2);
+}
+
+:root[data-theme='light'] .chat-suggestion:hover {
+ border-color: #f59e0b;
+ color: #d97706;
+}
+
+:root[data-theme='light'] .chat-thinking-dots span {
+ background: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-thinking-text {
+ color: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-reasoning {
+ color: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-reasoning-body {
+ background: #f8fafc;
+ color: #64748b;
+}
+
+:root[data-theme='light'] .chat-messages::-webkit-scrollbar-thumb {
+ background: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-history-item:hover {
+ background: #f8fafc;
+}
+
+:root[data-theme='light'] .chat-history-item[data-active] {
+ border-left-color: #f59e0b;
+}
+
+:root[data-theme='light'] .chat-history-item[data-confirm] {
+ background: rgba(220, 50, 50, 0.06);
+}
+
+:root[data-theme='light'] .chat-history-item-title {
+ color: #1a1a2e;
+}
+
+:root[data-theme='light'] .chat-history-item-meta {
+ color: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-history-delete {
+ color: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-history-empty-icon {
+ stroke: #94a3b8;
+}
+
+:root[data-theme='light'] .chat-history-empty-hint {
+ color: #94a3b8;
+}
+
+/* ---- Reduced motion ---- */
+@media (prefers-reduced-motion: reduce) {
+ .chat-fab,
+ .chat-panel,
+ .chat-header-btn,
+ .chat-history-item,
+ .chat-history-delete {
+ transition-duration: 1ms;
+ }
+
+ .chat-thinking-dots span {
+ animation-duration: 1ms;
+ }
+
+ .chat-messages {
+ scroll-behavior: auto;
+ }
+}