arnaucube
arnaucube/study-labPublic Self-hosted repository
first version
arnaucube git@arnaucube.com
Mon, 17 Aug 2026 09:49:25 +0200
commit

41bbd4e5b86939878e81a483c147b4be3e748f83

5 files changed, 916 insertions(+), 0 deletions(-)

jump to
A README.md

@@ -0,0 +1,82 @@

+# Study Pal + +Study Pal is a local-first, branching AI chat for exploring technical concepts. Every question becomes a node in a concept map. Select any earlier node to continue from that point, or select text inside an answer and ask a focused follow-up. + +The app is intentionally frontend-only: there is no build step, package manager, backend, or database. Conversations and settings persist in the browser's `localStorage`, while responses stream directly from OpenAI's Responses API. + +## Features + +- Branching conversation tree with clickable question nodes +- Root-to-current-branch conversation view +- Ask about selected answer text with suggested or custom follow-ups +- Streaming responses +- Markdown, fenced code blocks, and LaTeX rendering through KaTeX +- Persistent sessions and API settings in `localStorage` +- Lightweight vanilla HTML, CSS, and JavaScript +- Responsive concept map for desktop and small screens +- Persistent light/dark theme with system-theme detection +- Large-answer optimizations: throttled stream painting and `content-visibility` + +## Run locally + +You need a modern browser, an internet connection, and an OpenAI API key. A ChatGPT subscription/password is not an API key; create a project key in the [OpenAI dashboard](https://platform.openai.com/api-keys). + +From this directory, start any static file server. Python is commonly preinstalled: + +```bash +python3 -m http.server 8080 +``` + +Then open <http://localhost:8080>. + +Alternatively, with Node.js: + +```bash +npx --yes serve . +``` + +Open the local URL printed by `serve`. + +1. Choose **Settings**. +2. Paste an OpenAI API key. +3. Keep `gpt-5-mini`, or enter another model available to your API project. +4. Save and ask a question. + +The endpoint is configurable under **Advanced**, which is useful for an OpenAI-compatible Responses API. Compatibility depends on whether that provider implements the Responses API and browser CORS. + +## How branching works + +Each node stores its parent ID. Clicking a node makes it the active context and displays only the path from the root to that node. The next question becomes its child. Selecting text within an answer and choosing **Ask** creates a child of the answer's node, even if another branch was active. + +The complete parent-path conversation is sent with each request. This makes every branch self-contained and avoids coupling local history to provider-side response IDs. + +## Storage and security + +The browser stores two local keys: + +- `study-pal:v1` — conversation nodes +- `study-pal:settings:v1` — API key, model, endpoint, and system instructions + +This design is convenient for a private tool on your own laptop, but browser storage is not a secure secret vault. Any JavaScript executing on the same origin can read it. Therefore: + +- Run the app only from files you trust. +- Do not publish or host this version as a public multi-user site. +- Prefer a restricted OpenAI project key with appropriate spend limits. +- Use **Settings → Forget key** before sharing the browser profile or local origin. +- Using a different port creates a different browser origin and therefore a separate session. + +For a public deployment, put API calls behind an authenticated backend and keep the key in a server environment variable. + +## Performance notes + +Only the active branch is added to the main document; sibling branches remain compact data in `localStorage`. Streaming UI updates are batched roughly every 70 ms instead of rerendering for every token. Off-screen messages use CSS `content-visibility`, and the concept map is built with a single document-fragment update. + +Very large sessions are ultimately limited by the browser's `localStorage` quota (usually a few megabytes). Start a new map when the saved tree becomes unusually large. + +## Browser support + +Use a current version of Chrome, Edge, Firefox, or Safari. LaTeX loads from the jsDelivr CDN; if it cannot load, equations remain visible as plain source text. The OpenAI API must allow requests from your local browser origin. If a corporate browser policy or custom endpoint blocks CORS, a small local proxy backend would be required. + +## API reference + +The app uses `POST /v1/responses` with `stream: true` and reads Server-Sent Events. See the official [OpenAI streaming Responses documentation](https://developers.openai.com/api/docs/guides/streaming-responses).
A app.js

@@ -0,0 +1,430 @@

+(() => { + 'use strict'; + + const STORAGE_KEY = 'study-pal:v1'; + const SETTINGS_KEY = 'study-pal:settings:v1'; + const DEFAULT_SETTINGS = { + apiKey: '', + model: 'gpt-5-mini', + apiUrl: 'https://api.openai.com/v1/responses', + systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Build on the conversation context and explain assumptions.' + }; + + const $ = (selector, root = document) => root.querySelector(selector); + const els = { + welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'), + prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'), + nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'), + branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'), + selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'), + apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'), + keyStatus: $('#key-status'), toast: $('#toast') + }; + + let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS); + let state = normalizeState(loadJSON(STORAGE_KEY, { nodes: [], activeId: null })); + let activeRequest = null; + let selectedText = null; + let saveTimer = 0; + let toastTimer = 0; + + function loadJSON(key, fallback) { + try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; } + catch { return { ...fallback }; } + } + + function normalizeState(value) { + const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => { + if (node.status !== 'loading') return node; + return { ...node, status: 'error', error: 'The page was closed before this response finished.' }; + }) : []; + const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null); + return { nodes, activeId }; + } + + function persistSoon() { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } + catch { showToast('Browser storage is full — this map could not be saved'); } + }, 180); + } + + function nodeById(id) { return state.nodes.find(node => node.id === id); } + + function pathTo(id) { + const path = []; + const seen = new Set(); + let node = nodeById(id); + while (node && !seen.has(node.id)) { + seen.add(node.id); + path.push(node); + node = nodeById(node.parentId); + } + return path.reverse(); + } + + function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); } + + function uid() { + return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + } + + function escapeHTML(value = '') { + return value.replace(/[&<>'"]/g, char => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[char]); + } + + function inlineMarkdown(text) { + let output = escapeHTML(text); + output = output.replace(/`([^`]+)`/g, '<code>$1</code>'); + output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); + output = output.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); + output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); + return output; + } + + function renderMath(source, displayMode) { + if (!globalThis.katex) return `<code class="math-fallback">${escapeHTML(source)}</code>`; + try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); } + catch { return `<code class="math-fallback">${escapeHTML(source)}</code>`; } + } + + function markdown(source = '') { + const stash = []; + const token = html => `\u0000${stash.push(html) - 1}\u0000`; + let text = source.replace(/\r\n?/g, '\n'); + text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`<pre data-language="${escapeHTML(lang)}"><code>${escapeHTML(code.trimEnd())}</code></pre>`)); + text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true))); + text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true))); + text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false))); + text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_, formula) => token(renderMath(formula.trim(), false))); + + const lines = text.split('\n'); + const html = []; + let paragraph = []; + let list = null; + const flushParagraph = () => { + if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`); + paragraph = []; + }; + const closeList = () => { if (list) html.push(`</${list}>`); list = null; }; + + for (const line of lines) { + const heading = line.match(/^(#{1,3})\s+(.+)/); + const bullet = line.match(/^\s*[-*+]\s+(.+)/); + const numbered = line.match(/^\s*\d+[.)]\s+(.+)/); + const quote = line.match(/^>\s?(.*)/); + const blockToken = line.match(/^\u0000(\d+)\u0000$/); + if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); } + else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); } + else if (bullet || numbered) { + flushParagraph(); + const wanted = bullet ? 'ul' : 'ol'; + if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; } + html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`); + } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); } + else if (!line.trim()) { flushParagraph(); closeList(); } + else paragraph.push(line); + } + flushParagraph(); closeList(); + return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]); + } + + function answerHTML(node) { + if (node.status === 'loading' && !node.answer) return '<span class="thinking" aria-label="Thinking"><i></i><i></i><i></i></span>'; + if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`; + return markdown(node.answer || ''); + } + + function renderConversation({ scroll = false } = {}) { + const path = pathTo(state.activeId); + els.welcome.hidden = path.length > 0; + els.conversation.hidden = path.length === 0; + const fragment = document.createDocumentFragment(); + for (const node of path) { + const article = document.createElement('article'); + article.className = 'message'; + article.dataset.nodeId = node.id; + article.innerHTML = ` + <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div> + <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-body">${answerHTML(node)}</div></div> + <div class="answer-actions"> + <button class="mini-action copy-answer" type="button">Copy answer</button> + <button class="mini-action branch-here" type="button">Branch here</button> + ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''} + </div>`; + fragment.append(article); + } + els.conversation.replaceChildren(fragment); + renderBranchContext(); + if (scroll) requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })); + } + + function renderTree() { + const ids = new Set(state.nodes.map(node => node.id)); + const grouped = new Map(); + for (const node of state.nodes) { + const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null; + if (!grouped.has(parentId)) grouped.set(parentId, []); + grouped.get(parentId).push(node); + } + const roots = grouped.get(null) || []; + const activePath = new Set(pathTo(state.activeId).map(node => node.id)); + const build = nodes => { + const ul = document.createElement('ul'); + for (const node of nodes) { + const li = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + button.className = `tree-node${node.id === state.activeId ? ' active' : ''}`; + button.dataset.nodeId = node.id; + button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false'); + const children = grouped.get(node.id) || []; + button.innerHTML = `<span class="node-dot">${node.status === 'loading' ? '…' : (activePath.has(node.id) ? '●' : '○')}</span><span class="node-copy"><span class="node-title">${escapeHTML(node.question)}</span><span class="node-meta">${children.length} ${children.length === 1 ? 'reply' : 'branches'}</span></span>`; + li.append(button); + if (children.length) li.append(build(children)); + ul.append(li); + } + return ul; + }; + els.tree.replaceChildren(build(roots)); + els.mapEmpty.hidden = state.nodes.length > 0; + els.tree.hidden = state.nodes.length === 0; + els.nodeCount.textContent = String(state.nodes.length); + let branchCount = 0; + for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1); + els.branchCount.textContent = String(branchCount); + } + + function renderBranchContext() { + const active = nodeById(state.activeId); + const latest = state.nodes.at(-1); + const branching = active && latest && active.id !== latest.id; + els.branchContext.hidden = !branching; + if (branching) els.branchLabel.textContent = active.question; + } + + function renderAll(options) { renderConversation(options); renderTree(); } + + function updateStreamedNode(node) { + if (state.activeId !== node.id) return; + const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation); + if (body) body.innerHTML = answerHTML(node); + } + + function inputFor(parentId, question) { + const messages = []; + for (const node of pathTo(parentId)) { + messages.push({ role: 'user', content: node.question }); + if (node.answer) messages.push({ role: 'assistant', content: node.answer }); + } + messages.push({ role: 'user', content: question }); + return messages; + } + + async function ask(question, parentId = state.activeId) { + question = question.trim(); + if (!question) return; + if (activeRequest) { showToast('Stop or finish the current response first'); return; } + if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; } + + const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() }; + state.nodes.push(node); + state.activeId = node.id; + persistSoon(); + renderAll({ scroll: true }); + setBusy(true); + const controller = new AbortController(); + activeRequest = controller; + + try { + const response = await fetch(settings.apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` }, + body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(parentId, question), stream: true }), + signal: controller.signal + }); + if (!response.ok) { + let message = `API request failed (${response.status})`; + try { const data = await response.json(); message = data.error?.message || message; } catch {} + throw new Error(message); + } + if (!response.body) throw new Error('This browser did not provide a streaming response body.'); + await consumeSSE(response.body, event => { + if (event.type === 'response.output_text.delta') node.answer += event.delta || ''; + if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.'); + }, () => updateStreamedNode(node)); + node.status = 'complete'; + } catch (error) { + node.status = 'error'; + node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message; + } finally { + activeRequest = null; + persistSoon(); + setBusy(false); + renderAll(); + } + } + + async function consumeSSE(stream, onEvent, onPaint) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let paintQueued = false; + const queuePaint = () => { + if (paintQueued) return; + paintQueued = true; + setTimeout(() => { paintQueued = false; onPaint(); }, 70); + }; + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop() || ''; + for (const block of blocks) { + for (const line of block.split(/\r?\n/)) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + onEvent(JSON.parse(data)); + queuePaint(); + } + } + if (done) break; + } + onPaint(); + } + + function setBusy(busy) { + els.prompt.disabled = busy; + els.send.textContent = busy ? '■' : '↑'; + els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message'); + } + + function openSettings() { + els.apiKey.value = settings.apiKey || ''; + els.model.value = settings.model; + els.apiUrl.value = settings.apiUrl; + els.systemPrompt.value = settings.systemPrompt; + els.settings.showModal(); + setTimeout(() => els.apiKey.focus(), 50); + } + + function showToast(message) { + els.toast.textContent = message; + els.toast.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200); + } + + function updateKeyStatus() { els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); } + + function updateThemeButton() { + const dark = document.documentElement.dataset.theme === 'dark'; + const button = $('#theme-toggle'); + button.querySelector('span').textContent = dark ? '☀' : '☾'; + button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`); + button.title = `Switch to ${dark ? 'light' : 'dark'} theme`; + } + + function toggleTheme() { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem('study-pal:theme', next); } catch {} + updateThemeButton(); + } + + function showSelectionMenu(text, nodeId, rect) { + selectedText = { text: text.slice(0, 5000), nodeId }; + els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text; + els.selectionMenu.hidden = false; + const width = 330; + const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2)); + const estimatedHeight = 235; + const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8); + Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` }); + } + + function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; } + + els.composer.addEventListener('submit', event => { + event.preventDefault(); + if (activeRequest) { activeRequest.abort(); return; } + const question = els.prompt.value; + els.prompt.value = ''; + els.prompt.style.height = ''; + ask(question); + }); + els.prompt.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); } + }); + els.prompt.addEventListener('input', () => { + els.prompt.style.height = 'auto'; + els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`; + }); + document.addEventListener('click', event => { + const starter = event.target.closest('.starter'); + if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); } + const treeNode = event.target.closest('.tree-node'); + if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll(); document.body.classList.remove('map-open'); scrollTo({ top: 0, behavior: 'smooth' }); } + const branch = event.target.closest('.branch-here'); + if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); } + const retry = event.target.closest('.retry-node'); + if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); } + const copy = event.target.closest('.copy-answer'); + if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); } + }); + document.addEventListener('mouseup', event => { + if (event.target.closest('.selection-menu')) return; + requestAnimationFrame(() => { + const selection = getSelection(); + const text = selection?.toString().trim(); + if (!text || text.length < 2 || !selection.rangeCount) return; + const answer = selection.anchorNode?.parentElement?.closest('.answer-body'); + if (!answer || !answer.contains(selection.focusNode)) return; + const message = answer.closest('.message'); + showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect()); + }); + }); + + $('#ask-selection').addEventListener('click', () => { + if (!selectedText) return; + const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value; + if (!action) { els.selectionCustom.focus(); return; } + const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`; + const parentId = selectedText.nodeId; + hideSelectionMenu(); + getSelection()?.removeAllRanges(); + ask(question, parentId); + }); + els.selectionAction.addEventListener('change', () => { + els.selectionCustom.hidden = els.selectionAction.value !== 'custom'; + if (!els.selectionCustom.hidden) els.selectionCustom.focus(); + }); + $('#close-selection').addEventListener('click', hideSelectionMenu); + window.addEventListener('scroll', hideSelectionMenu, { passive: true }); + $('#open-settings').addEventListener('click', openSettings); + $('#theme-toggle').addEventListener('click', toggleTheme); + $('#close-settings').addEventListener('click', () => els.settings.close()); + $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; }); + $('#settings-form').addEventListener('submit', event => { + event.preventDefault(); + settings = { apiKey: els.apiKey.value.trim(), model: els.model.value.trim() || DEFAULT_SETTINGS.model, apiUrl: els.apiUrl.value.trim() || DEFAULT_SETTINGS.apiUrl, systemPrompt: els.systemPrompt.value.trim() }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + updateKeyStatus(); els.settings.close(); showToast('Settings saved'); + }); + $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); }); + $('#new-session').addEventListener('click', () => { + if (!state.nodes.length || confirm('Start a new map? This will delete the current conversation tree from this browser.')) { + if (activeRequest) activeRequest.abort(); + state = { nodes: [], activeId: null }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); renderAll(); + } + }); + $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); renderAll({ scroll: true }); }); + $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; }); + $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; }); + + updateKeyStatus(); + updateThemeButton(); + renderAll(); +})();
A index.html

@@ -0,0 +1,163 @@

+<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="color-scheme" content="light dark"> + <title>Study Pal — Branch your thinking</title> + <script> + try { + document.documentElement.dataset.theme = localStorage.getItem('study-pal:theme') || + (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + } catch { document.documentElement.dataset.theme = 'light'; } + </script> + <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css"> + <link rel="stylesheet" href="styles.css"> +</head> +<body> + <header class="topbar"> + <a class="brand" href="#" aria-label="Study Pal home"> + <span class="brand-mark" aria-hidden="true">S</span> + <span>Study Pal</span> + </a> + <div class="top-actions"> + <button id="theme-toggle" class="theme-toggle" type="button" aria-label="Switch to dark theme" title="Switch theme"> + <span aria-hidden="true">☾</span> + </button> + <button id="new-session" class="button button-ghost" type="button">New map</button> + <button id="open-settings" class="button button-dark" type="button" aria-haspopup="dialog"> + <span class="status-dot" id="key-status" aria-hidden="true"></span> + Settings + </button> + </div> + </header> + + <main class="app-shell"> + <section class="workspace" aria-label="Conversation"> + <div id="welcome" class="welcome"> + <div class="eyebrow">A thinking space that branches</div> + <h1>Follow every<br><em>line of thought.</em></h1> + <p>Ask a question, explore the answer, then branch from any earlier idea without losing your way.</p> + <div class="starter-grid" aria-label="Example questions"> + <button class="starter" data-prompt="Explain gradient descent visually and mathematically."> + <span class="starter-icon">↘</span> + <strong>Learn a concept</strong> + <small>Explain gradient descent visually</small> + </button> + <button class="starter" data-prompt="Compare TCP and UDP with practical examples."> + <span class="starter-icon">⇄</span> + <strong>Compare ideas</strong> + <small>When should I use TCP or UDP?</small> + </button> + <button class="starter" data-prompt="Derive the quadratic formula step by step using LaTeX."> + <span class="starter-icon">∑</span> + <strong>Work through math</strong> + <small>Derive the quadratic formula</small> + </button> + </div> + </div> + + <div id="conversation" class="conversation" aria-live="polite"></div> + + <div class="composer-wrap"> + <div id="branch-context" class="branch-context" hidden> + <span>Branching from <strong id="branch-label"></strong></span> + <button id="clear-branch" type="button" aria-label="Return to latest node">×</button> + </div> + <form id="composer" class="composer"> + <textarea id="prompt" rows="1" placeholder="Ask anything…" aria-label="Message" maxlength="30000"></textarea> + <div class="composer-footer"> + <span class="hint"><kbd>Enter</kbd> send · <kbd>Shift</kbd> + <kbd>Enter</kbd> new line</span> + <button id="send" class="send" type="submit" aria-label="Send message">↑</button> + </div> + </form> + <p class="privacy-note">Your map stays in this browser.</p> + </div> + </section> + + <aside class="map-panel" aria-label="Concept map"> + <div class="map-heading"> + <div> + <span class="eyebrow">Your exploration</span> + <h2>Concept map</h2> + </div> + <button id="collapse-map" class="icon-button" type="button" aria-label="Collapse concept map">→</button> + </div> + <p id="map-help" class="map-help">Select a node to revisit it and start a new branch.</p> + <nav id="tree" class="tree" aria-label="Question branches"></nav> + <div id="map-empty" class="map-empty"> + <span class="empty-orbit">✦</span> + <p>Your questions will grow into a map here.</p> + </div> + <div class="map-stats"> + <span><strong id="node-count">0</strong> nodes</span> + <span><strong id="branch-count">0</strong> branches</span> + </div> + </aside> + <button id="expand-map" class="expand-map" type="button" hidden>← Concept map</button> + </main> + + <div id="selection-menu" class="selection-menu" role="dialog" aria-label="Ask about selected text" hidden> + <div class="selection-topline"> + <span>Ask about selection</span> + <button id="close-selection" type="button" aria-label="Close">×</button> + </div> + <blockquote id="selection-preview"></blockquote> + <select id="selection-action" aria-label="Question type"> + <option value="Explain this passage in simpler terms.">Explain simply</option> + <option value="Go deeper into this idea and its implications.">Go deeper</option> + <option value="Give me a concrete example of this.">Give an example</option> + <option value="What assumptions does this passage make?">Challenge assumptions</option> + <option value="custom">Write my own…</option> + </select> + <textarea id="selection-custom" rows="2" placeholder="What would you like to ask?" hidden></textarea> + <button id="ask-selection" class="button button-dark" type="button">Ask</button> + </div> + + <dialog id="settings-dialog" class="settings-dialog"> + <form id="settings-form" method="dialog"> + <div class="dialog-heading"> + <div> + <span class="eyebrow">Connection</span> + <h2>API settings</h2> + </div> + <button id="close-settings" class="icon-button" type="button" aria-label="Close settings">×</button> + </div> + <p class="settings-copy">Use an OpenAI API key (not a ChatGPT password). It is stored only in this browser and sent directly to the configured API.</p> + <label> + API key + <div class="secret-field"> + <input id="api-key" type="password" placeholder="sk-…" autocomplete="off" spellcheck="false"> + <button id="toggle-key" type="button" aria-label="Show API key">Show</button> + </div> + </label> + <label> + Model + <input id="model" type="text" value="gpt-5-mini" placeholder="gpt-5-mini" spellcheck="false"> + </label> + <details> + <summary>Advanced</summary> + <label> + Responses API endpoint + <input id="api-url" type="url" value="https://api.openai.com/v1/responses" spellcheck="false"> + </label> + <label> + System instructions + <textarea id="system-prompt" rows="3">You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Build on the conversation context and explain assumptions.</textarea> + </label> + </details> + <div class="security-callout"><strong>Local-use note</strong> — any script running on this origin can access local storage. Use a restricted project key and never deploy this frontend publicly with a saved key.</div> + <div class="dialog-actions"> + <button id="forget-key" class="button button-ghost danger" type="button">Forget key</button> + <button class="button button-dark" value="save" type="submit">Save settings</button> + </div> + </form> + </dialog> + + <div id="toast" class="toast" role="status" aria-live="polite"></div> + + <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script> + <script defer src="app.js"></script> +</body> +</html>
A instructions.md

@@ -0,0 +1,6 @@

+implement a webapp where I can configure my OpenAI/ChatGPT credentials/token and I can chat with the AI, but each question appears on a right panel as a node, and I can click on any node and start a new branch asking questions from that node. This will be useful to explore technical concepts and going back to continue from a previous conccept. +Also, I want to be able to select part of the text, and then a drop-down menu allows me to ask a question about that text, which will trigger also an answer from the LLM if the button 'ask' is hitten. +Ideally we do this without a backend, but if you need a backend to implement this, do it either in Go or in Rust. The frontend of the webapp must be in vanilla javascript + html + css, and with performance in mind. Also it must support LaTeX rendering, since most of the answers will contain LaTeX equations. +Session must prevail, store it either on localstorage of the browser, or if we use a backend, store it on a SQLite database. +Must be able to run this locally in my laptop. And again, performance is important; the rendered text will become large, and want to be able to run it on low-end laptops. +Also add instructions on how to run it at the README.md.
A styles.css

@@ -0,0 +1,235 @@

+:root { + --ink: #18201d; + --muted: #69716d; + --paper: #f8f7f2; + --panel: #eeeee7; + --card: #fff; + --line: #d9d9d0; + --sage: #8ba397; + --sage-dark: #4f6d60; + --orange: #e98557; + --shadow: 0 18px 50px rgba(30, 39, 35, .12); + --map-width: 340px; + color-scheme: light; +} + +:root[data-theme="dark"] { + --ink: #e8ece9; + --muted: #9da7a2; + --paper: #151a18; + --panel: #1b211e; + --card: #222925; + --line: #343c38; + --sage: #89a99a; + --sage-dark: #a6c9b8; + --orange: #ef986d; + --shadow: 0 18px 54px rgba(0, 0, 0, .42); + color-scheme: dark; +} + +* { box-sizing: border-box; } +html { height: 100%; background: var(--paper); } +body { + margin: 0; + min-height: 100%; + color: var(--ink); + background: var(--paper); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-rendering: optimizeLegibility; +} +button, textarea, input, select { font: inherit; } +button { color: inherit; } + +.topbar { + height: 68px; + padding: 0 22px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + background: rgba(248, 247, 242, .92); + backdrop-filter: blur(12px); + position: fixed; + inset: 0 0 auto 0; + z-index: 20; +} +.brand { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; font-weight: 760; letter-spacing: -.02em; } +.brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50% 48% 44% 52%; background: var(--ink); color: white; font-family: Georgia, serif; font-style: italic; } +.top-actions { display: flex; align-items: center; gap: 8px; } +.theme-toggle { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; font-size: 1rem; transition: transform .18s, background .18s; } +.theme-toggle:hover { background: var(--card); transform: rotate(8deg); } +.button { border: 1px solid transparent; border-radius: 999px; padding: 9px 15px; cursor: pointer; font-weight: 650; font-size: .86rem; transition: transform .15s, background .15s; } +.button:hover { transform: translateY(-1px); } +.button-dark { background: var(--ink); color: white; } +.button-dark:hover { background: #2b3631; } +.button-ghost { background: transparent; border-color: var(--line); } +.button-ghost:hover { background: #fff; } +.status-dot { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #a8aca9; } +.status-dot.ready { background: #78ad80; box-shadow: 0 0 0 3px rgba(120, 173, 128, .15); } + +.app-shell { padding-top: 68px; min-height: 100vh; } +.workspace { min-height: calc(100vh - 68px); margin-right: var(--map-width); position: relative; display: flex; flex-direction: column; } +.welcome { width: min(820px, calc(100% - 48px)); margin: auto; padding: 64px 0 180px; text-align: center; } +.eyebrow { text-transform: uppercase; letter-spacing: .16em; font-size: .67rem; font-weight: 800; color: var(--sage-dark); } +.welcome h1 { margin: 18px 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(3rem, 6vw, 5.8rem); line-height: .93; font-weight: 400; letter-spacing: -.055em; } +.welcome h1 em { color: var(--sage-dark); font-weight: 400; } +.welcome > p { color: var(--muted); max-width: 570px; margin: 0 auto 34px; line-height: 1.65; } +.starter-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; text-align: left; } +.starter { border: 1px solid var(--line); background: rgba(255,255,255,.58); padding: 18px; min-height: 120px; border-radius: 15px; cursor: pointer; display: grid; grid-template-columns: 32px 1fr; grid-template-rows: auto auto; column-gap: 10px; text-align: left; transition: border .15s, transform .15s, background .15s; } +.starter:hover { border-color: var(--sage); transform: translateY(-2px); background: white; } +.starter-icon { grid-row: 1 / span 2; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: #e4ebe7; color: var(--sage-dark); } +.starter strong { align-self: end; font-size: .86rem; } +.starter small { color: var(--muted); line-height: 1.4; } + +.conversation { width: min(850px, calc(100% - 50px)); margin: 0 auto; padding: 54px 0 220px; } +.message { content-visibility: auto; contain-intrinsic-size: auto 220px; margin-bottom: 34px; } +.question-row { display: flex; justify-content: flex-end; } +.question-bubble { max-width: min(680px, 88%); background: #e7ebe7; padding: 13px 17px; border-radius: 18px 18px 5px 18px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } +.answer { margin-top: 22px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 13px; } +.answer-mark { display: grid; place-items: center; align-self: start; width: 29px; height: 29px; border-radius: 9px; background: var(--ink); color: white; font-family: Georgia, serif; font-style: italic; font-size: .85rem; } +.answer-body { min-width: 0; line-height: 1.72; font-size: .98rem; overflow-wrap: anywhere; } +.answer-body > :first-child { margin-top: 1px; } +.answer-body > :last-child { margin-bottom: 0; } +.answer-body h1, .answer-body h2, .answer-body h3 { font-family: Georgia, serif; line-height: 1.25; margin: 1.5em 0 .55em; } +.answer-body h1 { font-size: 1.55rem; } .answer-body h2 { font-size: 1.3rem; } .answer-body h3 { font-size: 1.12rem; } +.answer-body p { margin: 0 0 1em; } +.answer-body ul, .answer-body ol { padding-left: 1.35em; } +.answer-body li { margin: .35em 0; } +.answer-body code { font-family: "SFMono-Regular", Consolas, monospace; background: #eaeae4; border-radius: 5px; padding: .12em .34em; font-size: .88em; } +.answer-body pre { overflow: auto; padding: 15px; background: #202724; color: #f3f5f2; border-radius: 12px; line-height: 1.5; tab-size: 2; } +.answer-body pre code { background: none; padding: 0; font-size: .84rem; } +.answer-body blockquote { border-left: 3px solid var(--sage); margin-left: 0; padding-left: 15px; color: var(--muted); } +.answer-body a { color: var(--sage-dark); text-underline-offset: 3px; } +.answer-body .katex-display { overflow-x: auto; overflow-y: hidden; padding: 5px 0; } +.answer-actions { display: flex; gap: 4px; margin: 12px 0 0 43px; opacity: .58; } +.mini-action { border: 0; background: transparent; border-radius: 7px; padding: 5px 8px; cursor: pointer; color: var(--muted); font-size: .75rem; } +.mini-action:hover { background: #e9e9e2; color: var(--ink); } +.thinking { display: inline-flex; gap: 5px; padding-top: 6px; } +.thinking i { width: 6px; height: 6px; border-radius: 50%; background: var(--sage); animation: pulse 1.1s infinite; } +.thinking i:nth-child(2) { animation-delay: .16s; }.thinking i:nth-child(3) { animation-delay: .32s; } +@keyframes pulse { 0%, 70%, 100% { opacity: .25; transform: translateY(0); } 35% { opacity: 1; transform: translateY(-3px); } } +.error-text { color: #a33f36; background: #f8e7e3; border-radius: 9px; padding: 10px 12px; } + +.composer-wrap { position: fixed; bottom: 0; left: 0; right: var(--map-width); z-index: 10; padding: 22px max(24px, calc((100% - var(--map-width) - 850px) / 2)) 12px; background: linear-gradient(transparent, var(--paper) 22%, var(--paper)); } +.composer { max-width: 850px; margin: 0 auto; padding: 11px 12px 9px 17px; border: 1px solid #cfd2cc; border-radius: 18px; background: white; box-shadow: 0 9px 32px rgba(34,43,39,.09); } +.composer:focus-within { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.1), 0 9px 32px rgba(34,43,39,.09); } +.composer textarea { display: block; width: 100%; max-height: 180px; resize: none; border: 0; outline: 0; background: transparent; line-height: 1.5; padding: 2px 5px 8px 0; } +.composer-footer { display: flex; justify-content: space-between; align-items: center; } +.hint { font-size: .68rem; color: #8b918e; } +kbd { font-family: inherit; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 1px 4px; background: #fafafa; } +.send { border: 0; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: var(--ink); color: white; cursor: pointer; font-size: 1.15rem; } +.send:disabled { opacity: .35; cursor: default; } +.privacy-note { max-width: 850px; margin: 7px auto 0; text-align: center; color: #8b918e; font-size: .66rem; } +.branch-context { max-width: 850px; margin: 0 auto 7px; padding: 7px 11px; border: 1px solid #cfd9d2; border-radius: 10px; background: #edf2ee; color: var(--sage-dark); display: flex; justify-content: space-between; font-size: .75rem; } +.branch-context[hidden] { display: none; } +.branch-context strong { display: inline-block; max-width: 48ch; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; white-space: nowrap; } +.branch-context button { border: 0; background: none; cursor: pointer; } + +.map-panel { position: fixed; z-index: 15; right: 0; top: 68px; bottom: 0; width: var(--map-width); background: var(--panel); border-left: 1px solid var(--line); display: flex; flex-direction: column; padding: 28px 20px 16px; transition: transform .2s; } +.map-heading { display: flex; align-items: start; justify-content: space-between; } +.map-heading h2, .dialog-heading h2 { font-family: Georgia, serif; font-weight: 400; margin: 5px 0 0; font-size: 1.55rem; } +.icon-button { border: 1px solid var(--line); background: rgba(255,255,255,.4); width: 31px; height: 31px; border-radius: 9px; cursor: pointer; } +.map-help { font-size: .75rem; color: var(--muted); line-height: 1.5; margin: 12px 0 18px; } +.tree { flex: 1; min-height: 0; overflow: auto; padding: 5px 4px 30px; scrollbar-width: thin; } +.tree ul { list-style: none; margin: 0; padding: 0 0 0 20px; position: relative; } +.tree > ul { padding-left: 0; } +.tree ul ul::before { content: ""; position: absolute; top: -6px; bottom: 13px; left: 7px; border-left: 1px solid #c2c8c2; } +.tree li { position: relative; padding: 4px 0; } +.tree ul ul > li::before { content: ""; position: absolute; width: 12px; left: -13px; top: 20px; border-top: 1px solid #c2c8c2; } +.tree-node { width: 100%; border: 1px solid transparent; border-radius: 11px; background: transparent; padding: 8px 9px; text-align: left; cursor: pointer; display: grid; grid-template-columns: 19px minmax(0,1fr); gap: 7px; align-items: start; } +.tree-node:hover { background: rgba(255,255,255,.55); } +.tree-node.active { background: white; border-color: #cbd4ce; box-shadow: 0 3px 12px rgba(40,48,44,.06); } +.node-dot { width: 17px; height: 17px; border-radius: 50%; display: grid; place-items: center; margin-top: 1px; background: #cad6cf; color: var(--sage-dark); font-size: .54rem; font-weight: 800; } +.tree-node.active .node-dot { background: var(--sage-dark); color: white; } +.node-copy { min-width: 0; } +.node-title { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: .76rem; font-weight: 680; } +.node-meta { color: #858c88; font-size: .62rem; } +.map-empty { margin: auto; width: 170px; text-align: center; color: var(--muted); font: 1rem/1.5 Georgia, serif; } +.empty-orbit { display: grid; place-items: center; width: 54px; height: 54px; margin: auto; border: 1px dashed var(--sage); border-radius: 50%; color: var(--sage-dark); } +.map-stats { border-top: 1px solid var(--line); padding-top: 13px; display: flex; gap: 18px; color: var(--muted); font-size: .7rem; } +.map-stats strong { color: var(--ink); } +.expand-map { position: fixed; z-index: 12; right: 0; top: 84px; border: 1px solid var(--line); border-right: 0; border-radius: 10px 0 0 10px; background: var(--panel); padding: 9px 12px; cursor: pointer; font-size: .75rem; } +body.map-collapsed .map-panel { transform: translateX(100%); } +body.map-collapsed .workspace { margin-right: 0; } +body.map-collapsed .composer-wrap { right: 0; } + +.selection-menu { position: fixed; z-index: 40; width: min(330px, calc(100vw - 24px)); padding: 13px; border: 1px solid #cdd2cd; border-radius: 14px; background: white; box-shadow: var(--shadow); } +.selection-menu[hidden] { display: none; } +.selection-topline { display: flex; justify-content: space-between; font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: .1em; } +.selection-topline button { border: 0; background: none; cursor: pointer; } +.selection-menu blockquote { margin: 10px 0; padding: 7px 9px; border-left: 2px solid var(--sage); background: #f4f5f1; color: var(--muted); font-size: .74rem; line-height: 1.4; max-height: 70px; overflow: hidden; } +.selection-menu select, .selection-menu textarea { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 8px; background: white; font-size: .78rem; } +.selection-menu .button { width: 100%; } + +.settings-dialog { width: min(500px, calc(100vw - 28px)); border: 0; border-radius: 20px; padding: 0; color: var(--ink); box-shadow: var(--shadow); } +.settings-dialog::backdrop { background: rgba(25,31,28,.36); backdrop-filter: blur(3px); } +.settings-dialog form { padding: 24px; } +.dialog-heading { display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px; } +.settings-copy { color: var(--muted); font-size: .8rem; line-height: 1.55; } +.settings-dialog label { display: block; margin: 14px 0; font-size: .73rem; font-weight: 750; } +.settings-dialog input, .settings-dialog textarea { width: 100%; margin-top: 6px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; outline: none; resize: vertical; font-weight: 400; } +.settings-dialog input:focus, .settings-dialog textarea:focus { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.1); } +.secret-field { display: flex; position: relative; } +.secret-field input { padding-right: 58px; } +.secret-field button { position: absolute; right: 6px; top: 12px; border: 0; background: transparent; color: var(--sage-dark); cursor: pointer; font-size: .72rem; } +.settings-dialog summary { cursor: pointer; color: var(--sage-dark); font-size: .78rem; font-weight: 700; } +.security-callout { background: #f4eee5; color: #6d5945; border-radius: 9px; padding: 10px 12px; font-size: .7rem; line-height: 1.5; } +.dialog-actions { display: flex; justify-content: space-between; margin-top: 18px; } +.danger { color: #9d443e; } +.toast { position: fixed; z-index: 60; left: 50%; bottom: 24px; transform: translate(-50%, 20px); background: var(--ink); color: white; padding: 9px 14px; border-radius: 9px; font-size: .76rem; opacity: 0; pointer-events: none; transition: .2s; } +.toast.show { opacity: 1; transform: translate(-50%, 0); } + +@media (max-width: 850px) { + :root { --map-width: min(88vw, 340px); } + .workspace, .composer-wrap { margin-right: 0; right: 0; } + .map-panel { box-shadow: -12px 0 38px rgba(25,31,28,.12); } + body:not(.map-open) .map-panel { transform: translateX(100%); } + body:not(.map-open) .expand-map { display: block; } + .starter-grid { grid-template-columns: 1fr; } + .starter { min-height: 78px; } + .welcome { padding-top: 45px; } +} +@media (max-width: 540px) { + .topbar { height: 60px; padding: 0 13px; } + .app-shell { padding-top: 60px; } + .map-panel { top: 60px; } + .top-actions .button { padding: 8px 10px; } + .welcome { width: calc(100% - 28px); } + .welcome h1 { font-size: 3.2rem; } + .conversation { width: calc(100% - 26px); padding-top: 25px; } + .composer-wrap { padding-left: 12px; padding-right: 12px; } + .hint { display: none; } + .answer { grid-template-columns: 25px minmax(0,1fr); gap: 9px; } + .answer-mark { width: 25px; height: 25px; } + .answer-actions { margin-left: 34px; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } } + +:root[data-theme="dark"] .topbar { background: rgba(21, 26, 24, .92); } +:root[data-theme="dark"] .brand-mark, +:root[data-theme="dark"] .answer-mark, +:root[data-theme="dark"] .send, +:root[data-theme="dark"] .button-dark { background: #e6ebe7; color: #18201d; } +:root[data-theme="dark"] .button-dark:hover { background: #fff; } +:root[data-theme="dark"] .button-ghost:hover, +:root[data-theme="dark"] .starter:hover, +:root[data-theme="dark"] .tree-node.active { background: var(--card); } +:root[data-theme="dark"] .starter { background: rgba(255,255,255,.025); } +:root[data-theme="dark"] .starter-icon { background: #29372f; } +:root[data-theme="dark"] .question-bubble { background: #29332e; } +:root[data-theme="dark"] .answer-body code { background: #29302d; } +:root[data-theme="dark"] .answer-body pre { background: #0d110f; color: #e9eeeb; } +:root[data-theme="dark"] .mini-action:hover { background: #252d29; } +:root[data-theme="dark"] .composer { background: var(--card); border-color: #3a4640; box-shadow: 0 9px 32px rgba(0,0,0,.22); } +:root[data-theme="dark"] .composer-wrap { background: linear-gradient(transparent, var(--paper) 22%, var(--paper)); } +:root[data-theme="dark"] kbd { background: #252c29; } +:root[data-theme="dark"] .branch-context { background: #223029; border-color: #35493f; } +:root[data-theme="dark"] .icon-button { background: rgba(255,255,255,.035); } +:root[data-theme="dark"] .tree-node:hover { background: rgba(255,255,255,.045); } +:root[data-theme="dark"] .selection-menu, +:root[data-theme="dark"] .settings-dialog, +:root[data-theme="dark"] .selection-menu select, +:root[data-theme="dark"] .selection-menu textarea { background: var(--card); } +:root[data-theme="dark"] .selection-menu blockquote { background: #1a211e; } +:root[data-theme="dark"] .security-callout { background: #30291f; color: #d1b99e; } +:root[data-theme="dark"] .error-text { color: #f0aaa2; background: #3c2422; }