arnaucube
arnaucube/study-labPublic Self-hosted repository
improve UX, allow upload pdf, add persistent map library, multiple maps/conversations

- add sidebar navigation for creating, renaming, and deleting maps
- preserve multiple conversations with localStorage migration
- support per-map PDF uploads and Responses API file context
- improve node navigation and map scrolling behavior
- add history clearing and streamlined new-map controls
- document map management, PDF limits, storage, and security
arnaucube git@arnaucube.com
Mon, 17 Aug 2026 10:49:10 +0200
commit

b4d768ae76756bdb60ef7c4794709b5cdf66eb86

parent

281be5315c990f4f9b3d101748a68d20ed1a6ff8

5 files changed, 491 insertions(+), 62 deletions(-)

jump to
M README.md → README.md

@@ -16,6 +16,9 @@ - Lightweight vanilla HTML, CSS, and JavaScript

- Responsive concept map for desktop and small screens - Persistent light/dark theme with system-theme detection - Monthly token tracking with per-model details and a local billing estimate +- Independent chat-history clearing and per-conversation deletion +- Multiple persistent maps with a browsable conversation sidebar +- Per-map PDF context for books and papers, uploaded once through the Files API - Large-answer optimizations: throttled stream painting and `content-visibility` ## Run locally

@@ -43,6 +46,8 @@ 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. +To ask about a book or paper, choose **+ PDF** beside the composer and select one or more PDFs. The files are attached to the current map and included with every new question in that map, including questions asked from older branches. Each file must be under 50 MB, and all attached files in a map must total under 50 MB. + 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

@@ -51,11 +56,25 @@ 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. +## Multiple maps + +Choose **New map** in the header or conversation sidebar to open a blank map. The current map is retained automatically. Use the left **Conversations** sidebar to switch between saved maps; each entry shows its title, node count, and last activity date. Use ✎ to rename a map or × to delete its entire conversation. A custom title remains unchanged as new questions are added; submitting a blank title restores automatic naming. Existing sessions from the original single-map storage format are migrated automatically. + +**Settings → Clear chat history** is the explicit destructive action that removes all saved maps. It does not remove the API key, other settings, or the separate API-usage ledger. + +## PDF context + +PDFs are uploaded directly from the browser to the Files endpoint derived from the configured Responses API URL. Only their file IDs, names, and sizes are stored in `localStorage`; the PDF bytes are not copied into browser storage. Page images use low visual detail to reduce token use, while extracted PDF text remains available to the model. Use a vision-capable model that supports PDF inputs. + +PDF parsing adds extracted text and page images to the prompt, so large books can consume substantial input tokens on every question. The header usage tracker includes tokens reported for these requests. For very large collections or frequent querying, OpenAI recommends retrieval with File Search; this lightweight local app intentionally uses direct PDF input and does not create vector stores. + +Removing a PDF chip or clearing local chat history detaches the file locally but does **not** delete the uploaded copy from OpenAI. Delete retained files separately through your OpenAI account if needed. Custom API providers must expose a compatible `/files` endpoint next to their configured `/responses` endpoint. + ## Storage and security The browser stores three local keys: -- `study-pal:v1` — conversation nodes +- `study-pal:v1` — multi-map conversation workspace, PDF file references, and active-map selection - `study-pal:settings:v1` — API key, model, endpoint, and system instructions - `study-pal:usage:v1` — token usage and estimated request costs recorded by this browser
M app.js → app.js

@@ -4,6 +4,7 @@

const STORAGE_KEY = 'study-pal:v1'; const SETTINGS_KEY = 'study-pal:settings:v1'; const USAGE_KEY = 'study-pal:usage:v1'; + const PDF_REQUEST_LIMIT = 50 * 1024 * 1024; // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17. const MODEL_PRICES = [ { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },

@@ -23,7 +24,7 @@ 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.' + systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. Build on the conversation context and explain assumptions.' }; const $ = (selector, root = document) => root.querySelector(selector);

@@ -38,13 +39,18 @@ keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'),

monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'), usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'), usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'), - usageRecent: $('#usage-recent'), usageNote: $('#usage-note') + usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'), + historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'), + attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input') }; let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS); - let state = normalizeState(loadJSON(STORAGE_KEY, { nodes: [], activeId: null })); + let workspace = loadWorkspace(); + let state = currentMap(); let usageRecords = loadUsage(); let activeRequest = null; + let uploadingPDFs = false; + let uploadTargetMapId = null; let selectedText = null; let saveTimer = 0; let toastTimer = 0;

@@ -61,6 +67,61 @@ return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];

} catch { return []; } } + function blankMap() { + const now = Date.now(); + return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], activeId: null, createdAt: now, updatedAt: now }; + } + + function titleFromNodes(nodes) { + const first = nodes.find(node => !node.parentId) || nodes[0]; + if (!first) return 'Untitled map'; + const title = String(first.question).replace(/\s+/g, ' ').trim(); + return title.length > 54 ? `${title.slice(0, 54)}…` : title; + } + + function normalizeMap(value = {}) { + const normalized = normalizeState(value); + const attachments = Array.isArray(value.attachments) ? value.attachments + .filter(file => file && file.id && file.name) + .map(file => ({ + id: String(file.id), name: String(file.name), size: Number(file.size) || 0, + createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low' + })) : []; + const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now(); + return { + id: String(value.id || uid()), + title: String(value.title || titleFromNodes(normalized.nodes)), + titleCustom: Boolean(value.titleCustom), + ...normalized, + attachments, + createdAt, + updatedAt: Number(value.updatedAt) || Number(normalized.nodes.at(-1)?.createdAt) || createdAt + }; + } + + function loadWorkspace() { + let raw = null; + try { raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch {} + let maps = []; + let activeMapId = null; + if (raw && Array.isArray(raw.maps)) { + maps = raw.maps.filter(Boolean).map(normalizeMap); + activeMapId = raw.activeMapId; + } else if (raw && Array.isArray(raw.nodes) && raw.nodes.length) { + // Migrate the original single-map schema in place. + const migrated = normalizeMap(raw); + maps = [migrated]; + activeMapId = migrated.id; + } + if (!maps.length) maps.push(blankMap()); + if (!maps.some(map => map.id === activeMapId)) activeMapId = maps[0].id; + return { version: 2, maps, activeMapId }; + } + + function currentMap() { + return workspace.maps.find(map => map.id === workspace.activeMapId) || workspace.maps[0]; + } + 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;

@@ -73,21 +134,33 @@

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'); } + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); } + catch { showToast('Browser storage is full — conversations could not be saved'); } }, 180); } - function nodeById(id) { return state.nodes.find(node => node.id === id); } + function persistNow() { + clearTimeout(saveTimer); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); return true; } + catch { showToast('Browser storage is full — conversations could not be saved'); return false; } + } + + function touchMap(map = state) { map.updatedAt = Date.now(); } + + function refreshMapTitle(map = state) { + if (!map.titleCustom) map.title = titleFromNodes(map.nodes); + } + + function nodeById(id, map = state) { return map.nodes.find(node => node.id === id); } - function pathTo(id) { + function pathTo(id, map = state) { const path = []; const seen = new Set(); - let node = nodeById(id); + let node = nodeById(id, map); while (node && !seen.has(node.id)) { seen.add(node.id); path.push(node); - node = nodeById(node.parentId); + node = nodeById(node.parentId, map); } return path.reverse(); }

@@ -185,7 +258,14 @@ fragment.append(article);

} els.conversation.replaceChildren(fragment); renderBranchContext(); - if (scroll) requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })); + if (scroll && state.activeId) requestAnimationFrame(() => { + if (scroll === 'bottom') { + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }); + return; + } + const answer = $(`.message[data-node-id="${CSS.escape(state.activeId)}"] .answer`, els.conversation); + answer?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }); } function renderTree() {

@@ -232,33 +312,177 @@ els.branchContext.hidden = !branching;

if (branching) els.branchLabel.textContent = active.question; } - function renderAll(options) { renderConversation(options); renderTree(); } + function renderHistory() { + const maps = workspace.maps + .filter(map => map.nodes.length > 0 || map.attachments.length > 0) + .sort((a, b) => b.updatedAt - a.updatedAt); + const fragment = document.createDocumentFragment(); + for (const map of maps) { + const row = document.createElement('div'); + row.className = 'history-row'; + const button = document.createElement('button'); + button.type = 'button'; + button.className = `history-item${map.id === workspace.activeMapId ? ' active' : ''}`; + button.dataset.mapId = map.id; + button.setAttribute('aria-current', map.id === workspace.activeMapId ? 'page' : 'false'); + const date = new Date(map.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const pdfMeta = map.attachments.length ? ` · ${map.attachments.length} PDF${map.attachments.length === 1 ? '' : 's'}` : ''; + button.innerHTML = `<span class="history-glyph" aria-hidden="true">${map.nodes.length || map.attachments.length ? '◇' : '+'}</span><span class="history-copy"><span class="history-title">${escapeHTML(map.title)}</span><span class="history-meta">${map.nodes.length} ${map.nodes.length === 1 ? 'node' : 'nodes'}${pdfMeta} · ${date}</span></span>`; + const rename = document.createElement('button'); + rename.type = 'button'; + rename.className = 'rename-map'; + rename.dataset.mapId = map.id; + rename.setAttribute('aria-label', `Rename conversation: ${map.title}`); + rename.title = 'Rename this conversation'; + rename.textContent = '✎'; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'delete-map'; + remove.dataset.mapId = map.id; + remove.setAttribute('aria-label', `Delete conversation: ${map.title}`); + remove.title = 'Delete this conversation'; + remove.textContent = '×'; + row.append(button, rename, remove); + fragment.append(row); + } + els.historyList.replaceChildren(fragment); + els.historyEmpty.hidden = maps.length > 0; + els.historyList.hidden = maps.length === 0; + } + + function formatFileSize(bytes) { + if (!bytes) return ''; + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; + } + + function renderAttachments() { + const fragment = document.createDocumentFragment(); + for (const file of state.attachments) { + const chip = document.createElement('span'); + chip.className = 'attachment-chip'; + chip.title = `${file.name}${file.size ? ` · ${formatFileSize(file.size)}` : ''} · Included with every question in this map`; + chip.innerHTML = `<span class="pdf-badge" aria-hidden="true">PDF</span><span class="attachment-name">${escapeHTML(file.name)}</span><button class="remove-attachment" type="button" data-file-id="${escapeHTML(file.id)}" aria-label="Remove ${escapeHTML(file.name)} from this map">×</button>`; + fragment.append(chip); + } + if (uploadingPDFs && state.id === uploadTargetMapId) { + const status = document.createElement('span'); + status.className = 'attachment-chip upload-status'; + status.innerHTML = '<span class="upload-spinner" aria-hidden="true"></span> Uploading…'; + fragment.append(status); + } + els.attachments.replaceChildren(fragment); + els.attachments.hidden = !state.attachments.length && !(uploadingPDFs && state.id === uploadTargetMapId); + els.uploadPDF.disabled = uploadingPDFs || Boolean(activeRequest); + } + + function renderAll(options) { renderConversation(options); renderTree(); renderHistory(); renderAttachments(); } function updateStreamedNode(node) { - if (state.activeId !== node.id) return; + if (state.activeId !== node.id || !state.nodes.includes(node)) 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) { + function inputFor(map, parentId, question) { const messages = []; - for (const node of pathTo(parentId)) { + for (const node of pathTo(parentId, map)) { messages.push({ role: 'user', content: node.question }); if (node.answer) messages.push({ role: 'assistant', content: node.answer }); } - messages.push({ role: 'user', content: question }); + const content = map.attachments.map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' })); + content.push({ type: 'input_text', text: question }); + messages.push({ role: 'user', content: map.attachments.length ? content : question }); return messages; } + function filesEndpoint() { + const url = new URL(settings.apiUrl); + if (!/\/responses\/?$/.test(url.pathname)) throw new Error('The configured endpoint is not a Responses API URL, so its Files endpoint cannot be derived.'); + url.pathname = url.pathname.replace(/\/responses\/?$/, '/files'); + url.search = ''; + url.hash = ''; + return url.toString(); + } + + async function uploadPDFs(fileList) { + const files = [...fileList]; + if (!files.length) return; + if (activeRequest || uploadingPDFs) { showToast('Finish the current operation first'); return; } + if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key before uploading'); return; } + + const valid = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')); + if (valid.length !== files.length) { showToast('Only PDF files can be added'); return; } + if (valid.some(file => file.size >= PDF_REQUEST_LIMIT)) { showToast('Each PDF must be smaller than 50 MB'); return; } + const existingBytes = state.attachments.reduce((total, file) => total + file.size, 0); + const addedBytes = valid.reduce((total, file) => total + file.size, 0); + if (existingBytes + addedBytes >= PDF_REQUEST_LIMIT) { showToast('PDFs in one map must total less than 50 MB'); return; } + + const targetMap = state; + uploadingPDFs = true; + uploadTargetMapId = targetMap.id; + renderAttachments(); + let uploaded = 0; + try { + const endpoint = filesEndpoint(); + for (const file of valid) { + const form = new FormData(); + form.append('purpose', 'user_data'); + form.append('file', file, file.name); + const response = await fetch(endpoint, { + method: 'POST', headers: { 'Authorization': `Bearer ${settings.apiKey}` }, body: form + }); + if (!response.ok) { + let message = `PDF upload failed (${response.status})`; + try { const data = await response.json(); message = data.error?.message || message; } catch {} + throw new Error(message); + } + const data = await response.json(); + if (!data.id) throw new Error('The Files API did not return a file ID.'); + targetMap.attachments.push({ + id: String(data.id), name: String(data.filename || file.name), + size: Number(data.bytes) || file.size, createdAt: Date.now(), detail: 'low' + }); + uploaded += 1; + touchMap(targetMap); + persistNow(); + if (state === targetMap) renderAll(); + } + showToast(`${uploaded} PDF${uploaded === 1 ? '' : 's'} added to this map`); + } catch (error) { + showToast(error.message || 'PDF upload failed'); + } finally { + uploadingPDFs = false; + uploadTargetMapId = null; + els.pdfInput.value = ''; + renderAll(); + } + } + + function removeAttachment(fileId) { + const file = state.attachments.find(candidate => candidate.id === fileId); + if (!file) return; + if (!confirm(`Remove “${file.name}” from this map? The uploaded copy is not deleted from the API provider.`)) return; + state.attachments = state.attachments.filter(candidate => candidate.id !== fileId); + touchMap(); + persistNow(); + renderAll(); + showToast('PDF removed from this map'); + } + 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 (uploadingPDFs) { showToast('Wait for the PDF upload to finish'); return; } if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; } + const requestMap = state; const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() }; - state.nodes.push(node); - state.activeId = node.id; + requestMap.nodes.push(node); + requestMap.activeId = node.id; + refreshMapTitle(requestMap); + touchMap(requestMap); persistSoon(); renderAll({ scroll: true }); setBusy(true);

@@ -270,7 +494,7 @@ 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 }), + body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(requestMap, parentId, question), stream: true }), signal: controller.signal }); if (!response.ok) {

@@ -293,6 +517,7 @@ node.status = 'error';

node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message; } finally { activeRequest = null; + touchMap(requestMap); persistSoon(); setBusy(false); renderAll();

@@ -334,6 +559,7 @@ function setBusy(busy) {

els.prompt.disabled = busy; els.send.textContent = busy ? '■' : '↑'; els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message'); + els.uploadPDF.disabled = busy || uploadingPDFs; } function openSettings() {

@@ -484,6 +710,106 @@ renderUsage();

els.usageDialog.showModal(); } + function createNewMap() { + const existingBlank = workspace.maps.find(map => map.nodes.length === 0 && map.attachments.length === 0); + const map = existingBlank || blankMap(); + if (!existingBlank) workspace.maps.push(map); + workspace.activeMapId = map.id; + state = map; + persistNow(); + renderAll(); + closeHistoryOnSmallScreen(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + els.prompt.focus(); + } + + function switchMap(mapId) { + const map = workspace.maps.find(candidate => candidate.id === mapId); + if (!map) { closeHistoryOnSmallScreen(); return; } + workspace.activeMapId = map.id; + state = map; + state.activeId = state.nodes.at(-1)?.id || null; + persistSoon(); + hideSelectionMenu(); + renderAll({ scroll: state.activeId ? 'bottom' : false }); + closeHistoryOnSmallScreen(); + } + + function closeHistoryOnSmallScreen() { + if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open'); + updateHistoryToggle(); + } + + function updateHistoryToggle() { + const small = matchMedia('(max-width: 1100px)').matches; + const open = small ? document.body.classList.contains('history-open') : !document.body.classList.contains('history-collapsed'); + els.historyToggle.setAttribute('aria-expanded', String(open)); + } + + function toggleHistory() { + if (matchMedia('(max-width: 1100px)').matches) { + document.body.classList.remove('history-collapsed'); + document.body.classList.toggle('history-open'); + if (document.body.classList.contains('history-open')) document.body.classList.remove('map-open'); + } else document.body.classList.toggle('history-collapsed'); + updateHistoryToggle(); + } + + function clearChatHistory(message = 'Clear all saved conversations? Your API key and usage records will be kept. Uploaded API files will not be deleted.') { + if (!workspace.maps.some(map => map.nodes.length || map.attachments.length)) { showToast('Chat history is already empty'); return false; } + if (!confirm(message)) return false; + if (activeRequest) activeRequest.abort(); + const map = blankMap(); + workspace = { version: 2, maps: [map], activeMapId: map.id }; + state = map; + persistNow(); + renderAll(); + showToast('All conversations cleared'); + return true; + } + + function deleteMap(mapId) { + const map = workspace.maps.find(candidate => candidate.id === mapId); + if (!map) return; + if (uploadingPDFs && uploadTargetMapId === map.id) { showToast('Wait for this map’s PDF upload to finish'); return; } + const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : ''; + if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return; + if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort(); + + workspace.maps = workspace.maps.filter(candidate => candidate.id !== map.id); + if (!workspace.maps.length) workspace.maps.push(blankMap()); + if (state === map) { + const next = [...workspace.maps] + .filter(candidate => candidate.nodes.length || candidate.attachments.length) + .sort((a, b) => b.updatedAt - a.updatedAt)[0] || workspace.maps[0]; + workspace.activeMapId = next.id; + state = next; + state.activeId = state.nodes.at(-1)?.id || null; + } + persistNow(); + renderAll({ scroll: state.activeId ? 'bottom' : false }); + showToast('Conversation deleted'); + } + + function renameMap(mapId) { + const map = workspace.maps.find(candidate => candidate.id === mapId); + if (!map) return; + const value = prompt('Rename this conversation. Leave blank to restore its automatic title.', map.title); + if (value === null) return; + const title = value.replace(/\s+/g, ' ').trim(); + if (title) { + map.title = title.slice(0, 120); + map.titleCustom = true; + } else { + map.titleCustom = false; + refreshMapTitle(map); + } + touchMap(map); + persistNow(); + renderHistory(); + showToast(title ? 'Conversation renamed' : 'Automatic title restored'); + } + function showSelectionMenu(text, nodeId, rect) { selectedText = { text: text.slice(0, 5000), nodeId }; els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;

@@ -515,8 +841,16 @@ });

document.addEventListener('click', event => { const starter = event.target.closest('.starter'); if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); } + const historyItem = event.target.closest('.history-item'); + if (historyItem) { switchMap(historyItem.dataset.mapId); return; } + const renameConversation = event.target.closest('.rename-map'); + if (renameConversation) { renameMap(renameConversation.dataset.mapId); return; } + const deleteConversation = event.target.closest('.delete-map'); + if (deleteConversation) { deleteMap(deleteConversation.dataset.mapId); return; } + const removeFile = event.target.closest('.remove-attachment'); + if (removeFile) { removeAttachment(removeFile.dataset.fileId); return; } 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' }); } + if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll({ scroll: true }); document.body.classList.remove('map-open'); } 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');

@@ -556,6 +890,11 @@ window.addEventListener('scroll', hideSelectionMenu, { passive: true });

$('#open-settings').addEventListener('click', openSettings); $('#open-usage').addEventListener('click', openUsage); $('#theme-toggle').addEventListener('click', toggleTheme); + $('#toggle-history').addEventListener('click', toggleHistory); + $('#study-pal-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); }); + $('#new-map-sidebar').addEventListener('click', createNewMap); + els.uploadPDF.addEventListener('click', () => els.pdfInput.click()); + els.pdfInput.addEventListener('change', () => uploadPDFs(els.pdfInput.files)); $('#close-settings').addEventListener('click', () => els.settings.close()); $('#close-usage').addEventListener('click', () => els.usageDialog.close()); $('#reset-usage').addEventListener('click', () => {

@@ -575,18 +914,16 @@ 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-history').addEventListener('click', () => clearChatHistory()); $('#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; }); + $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); }); + addEventListener('resize', updateHistoryToggle, { passive: true }); updateKeyStatus(); updateThemeButton(); updateUsageButton(); - renderAll(); + updateHistoryToggle(); + state.activeId = state.nodes.at(-1)?.id || null; + renderAll({ scroll: state.activeId ? 'bottom' : false }); })();
M index.html → index.html

@@ -17,10 +17,13 @@ <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="brand-group"> + <button id="toggle-history" class="history-toggle" type="button" aria-label="Toggle conversations sidebar" aria-expanded="true">☰</button> + <a id="study-pal-home" class="brand" href="#" aria-label="Open a new map"> + <span class="brand-mark" aria-hidden="true">S</span> + <span>Study Pal</span> + </a> + </div> <div class="top-actions"> <button id="open-usage" class="usage-button" type="button" aria-haspopup="dialog" aria-label="Open API usage details"> <span id="month-tokens">0</span> <span class="usage-caption">tokens</span>

@@ -28,7 +31,6 @@ </button>

<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> <span class="settings-label">Settings</span>

@@ -37,26 +39,39 @@ </div>

</header> <main class="app-shell"> + <aside class="history-panel" aria-label="Past conversations"> + <div class="history-heading"> + <div> + <span class="eyebrow">Your library</span> + <h2>Conversations</h2> + </div> + </div> + <button id="new-map-sidebar" class="new-map-sidebar" type="button"><span aria-hidden="true">+</span> New map</button> + <nav id="history-list" class="history-list" aria-label="Saved conversations"></nav> + <p id="history-empty" class="history-empty" hidden>No saved conversations yet.</p> + <p class="history-storage"><span aria-hidden="true">●</span> Stored in this browser</p> + </aside> + <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 class="starter" data-prompt="Explain and prove the Tower Law for finite field extensions K ⊆ F ⊆ L. Show how bases compose, then work through the example Q ⊆ Q(√2) ⊆ Q(√2, √3) and relate it to Galois theory."> + <span class="starter-icon">⊆</span> + <strong>Explain the Tower Law from Galois Theory</strong> + <small>Degrees of extensions in Galois theory</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 class="starter" data-prompt="Over an algebraically closed field k, explain and prove the affine algebraic set–radical ideal correspondence from Hilbert's Nullstellensatz, carefully showing how the algebra and geometry reverse inclusions."> + <span class="starter-icon">V</span> + <strong>Connect ideals and geometry</strong> + <small>Understand the Nullstellensatz correspondence</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 class="starter" data-prompt="Let f: X → Y be a dominant morphism of irreducible algebraic varieties. Prove the fiber dimension theorem, explain why the generic fiber has dimension dim(X) − dim(Y), and give an example where special fibers have larger dimension."> + <span class="starter-icon">f</span> + <strong>Study fibers of algebraic varieties</strong> + <small>Generic and special fibers of varieties</small> </button> </div> </div>

@@ -69,13 +84,20 @@ <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"> + <div id="attachments" class="attachments" aria-label="PDFs in this map" hidden></div> <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> + <div class="composer-tools"> + <button id="upload-pdf" class="upload-pdf" type="button" title="Add PDFs to this map" aria-label="Add PDFs to this map"> + <span aria-hidden="true">+</span> PDF + </button> + <span class="hint"><kbd>Enter</kbd> send · <kbd>Shift</kbd> + <kbd>Enter</kbd> new line</span> + </div> <button id="send" class="send" type="submit" aria-label="Send message">↑</button> </div> + <input id="pdf-input" type="file" accept="application/pdf,.pdf" multiple hidden> </form> - <p class="privacy-note">Your map stays in this browser.</p> + <p class="privacy-note">Your map stays in this browser. Added PDFs are uploaded to the configured API.</p> </div> </section>

@@ -147,11 +169,12 @@ <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> + <textarea id="system-prompt" rows="3">You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. 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="clear-history" class="button button-ghost danger" type="button">Clear chat history</button> <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>
D instructions.md

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

-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.
M styles.css → styles.css

@@ -10,6 +10,7 @@ --sage-dark: #4f6d60;

--orange: #e98557; --shadow: 0 18px 50px rgba(30, 39, 35, .12); --map-width: 340px; + --history-width: 270px; color-scheme: light; }

@@ -54,7 +55,10 @@ 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-group { display: flex; align-items: center; gap: 9px; } .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; } +.history-toggle { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 9px; background: transparent; cursor: pointer; font-size: 1rem; } +.history-toggle:hover { background: var(--card); } .top-actions { display: flex; align-items: center; gap: 8px; } .usage-button { border: 1px solid var(--line); border-radius: 999px; background: var(--card); padding: 8px 12px; cursor: pointer; color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; white-space: nowrap; transition: transform .15s, border-color .15s; } .usage-button:hover { transform: translateY(-1px); border-color: var(--sage); }

@@ -71,7 +75,33 @@ .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; } +.workspace { min-height: calc(100vh - 68px); margin-left: var(--history-width); margin-right: var(--map-width); position: relative; display: flex; flex-direction: column; transition: margin-left .2s; } +.history-panel { position: fixed; z-index: 16; left: 0; top: 68px; bottom: 0; width: var(--history-width); display: flex; flex-direction: column; padding: 27px 15px 14px; border-right: 1px solid var(--line); background: var(--panel); transition: transform .2s; } +.history-heading { display: flex; align-items: start; justify-content: space-between; padding: 0 5px; } +.history-heading h2 { margin: 5px 0 0; font: 400 1.4rem/1.2 Georgia, serif; } +.new-map-sidebar { width: 100%; margin: 18px 0 12px; padding: 10px 12px; border: 1px dashed var(--sage); border-radius: 11px; background: rgba(255,255,255,.28); color: var(--sage-dark); cursor: pointer; text-align: left; font-size: .78rem; font-weight: 750; } +.new-map-sidebar:hover { background: var(--card); } +.new-map-sidebar span { margin-right: 7px; font-size: 1rem; } +.history-list { flex: 1; min-height: 0; overflow-y: auto; padding: 2px 0 18px; scrollbar-width: thin; } +.history-row { display: flex; align-items: center; gap: 2px; min-width: 0; } +.history-item { flex: 1; min-width: 0; display: grid; grid-template-columns: 24px minmax(0,1fr); gap: 8px; padding: 10px 9px; border: 1px solid transparent; border-radius: 11px; background: transparent; cursor: pointer; text-align: left; } +.history-item:hover { background: rgba(255,255,255,.45); } +.history-item.active { border-color: #cbd4ce; background: var(--card); box-shadow: 0 3px 12px rgba(40,48,44,.05); } +.rename-map, .delete-map { flex: 0 0 auto; width: 25px; height: 27px; display: grid; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; opacity: .22; font-size: .88rem; transition: opacity .15s, background .15s; } +.delete-map { color: #a34d45; font-size: 1rem; } +.history-row:hover .rename-map, .history-row:hover .delete-map, .rename-map:focus-visible, .delete-map:focus-visible { opacity: 1; } +.rename-map:hover { background: var(--card); color: var(--ink); } +.delete-map:hover { background: #f4dfdc; } +.history-glyph { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 7px; background: #dfe6e1; color: var(--sage-dark); font-size: .7rem; } +.history-item.active .history-glyph { background: var(--sage-dark); color: var(--paper); } +.history-copy { min-width: 0; } +.history-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .76rem; font-weight: 680; } +.history-meta { display: block; margin-top: 2px; color: var(--muted); font-size: .62rem; } +.history-empty { margin: auto; padding: 20px; color: var(--muted); text-align: center; font: .9rem/1.45 Georgia, serif; } +.history-storage { margin: 0; padding: 12px 6px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: .64rem; } +.history-storage span { color: #78ad80; font-size: .55rem; } +body.history-collapsed .history-panel { transform: translateX(-100%); } +body.history-collapsed .workspace { margin-left: 0; } .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; }

@@ -88,7 +118,7 @@ .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 { margin-top: 22px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 13px; scroll-margin-top: 88px; } .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; }

@@ -113,11 +143,26 @@ .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-wrap { position: fixed; bottom: 0; left: var(--history-width); right: var(--map-width); z-index: 10; padding: 22px max(24px, calc((100% - var(--history-width) - var(--map-width) - 850px) / 2)) 12px; background: linear-gradient(transparent, var(--paper) 22%, var(--paper)); transition: left .2s; } +body.history-collapsed .composer-wrap { left: 0; } .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; } +.composer-tools { display: flex; align-items: center; gap: 10px; min-width: 0; } +.upload-pdf { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 8px; background: transparent; padding: 5px 8px; color: var(--sage-dark); cursor: pointer; font-size: .68rem; font-weight: 750; } +.upload-pdf:hover { background: var(--paper); } +.upload-pdf:disabled { opacity: .45; cursor: default; } +.attachments { display: flex; gap: 6px; overflow-x: auto; padding: 0 4px 9px 0; scrollbar-width: thin; } +.attachments[hidden] { display: none; } +.attachment-chip { flex: 0 1 auto; min-width: 0; max-width: 260px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 7px; border: 1px solid var(--line); border-radius: 9px; background: var(--paper); color: var(--muted); font-size: .67rem; } +.pdf-badge { flex: 0 0 auto; padding: 2px 4px; border-radius: 4px; background: #a84b42; color: #fff; font-size: .52rem; font-weight: 800; letter-spacing: .04em; } +.attachment-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.remove-attachment { flex: 0 0 auto; width: 18px; height: 18px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--muted); cursor: pointer; line-height: 1; } +.remove-attachment:hover { background: var(--line); color: var(--ink); } +.upload-status { flex: 0 0 auto; } +.upload-spinner { width: 10px; height: 10px; border: 2px solid var(--line); border-top-color: var(--sage-dark); border-radius: 50%; animation: spin .7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } .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; }

@@ -139,7 +184,7 @@ .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 { width: 100%; min-width: 0; flex: 1; 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; }

@@ -177,7 +222,8 @@ .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; } +.dialog-actions { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 18px; } +#settings-dialog .dialog-actions .button-dark { margin-left: auto; } .danger { color: #9d443e; } .usage-dialog { width: min(620px, calc(100vw - 28px)); } .usage-summary { display: grid; grid-template-columns: 1.25fr 1fr .75fr; gap: 9px; margin: 18px 0 20px; }

@@ -215,12 +261,17 @@ .starter-grid { grid-template-columns: 1fr; }

.starter { min-height: 78px; } .welcome { padding-top: 45px; } } +@media (max-width: 1100px) { + .workspace, .composer-wrap { margin-left: 0; left: 0; } + .history-panel { box-shadow: 12px 0 38px rgba(25,31,28,.14); } + body:not(.history-open) .history-panel { transform: translateX(-100%); } + body.history-open .history-panel { transform: translateX(0); } +} @media (max-width: 540px) { .topbar { height: 60px; padding: 0 13px; } .app-shell { padding-top: 60px; } - .map-panel { top: 60px; } + .map-panel, .history-panel { top: 60px; } .top-actions .button { padding: 8px 10px; } - #new-session { display: none; } .welcome { width: calc(100% - 28px); } .welcome h1 { font-size: 3.2rem; } .conversation { width: calc(100% - 26px); padding-top: 25px; }

@@ -252,7 +303,8 @@ :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"] .tree-node.active, +:root[data-theme="dark"] .history-item.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; }

@@ -265,6 +317,10 @@ :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"] .history-item:hover { background: rgba(255,255,255,.045); } +:root[data-theme="dark"] .history-glyph { background: #29372f; } +:root[data-theme="dark"] .delete-map:hover { background: #3c2422; } +:root[data-theme="dark"] .rename-map:hover { background: #29302d; } :root[data-theme="dark"] .selection-menu, :root[data-theme="dark"] .settings-dialog, :root[data-theme="dark"] .selection-menu select,