arnaucube
arnaucube/study-labPublic Self-hosted repository
improve navigation (vim shortcuts), add search, collapsible branches, tree zoom
arnaucube git@arnaucube.com
Mon, 17 Aug 2026 15:45:40 +0200
commit

0cfab61fd9d785d83931baf19f80f36dffaa40da

parent

b4d768ae76756bdb60ef7c4794709b5cdf66eb86

4 files changed, 421 insertions(+), 35 deletions(-)

jump to
M README.md → README.md

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

-# Study Pal +# Study Lab -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. +Study Lab 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.

@@ -19,6 +19,9 @@ - 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 +- Debounced search across map titles, questions, answers, and PDF names +- Collapsible, zoomable concept trees with active-path highlighting +- Vim-style keyboard navigation for nodes, maps, search, and the composer - Large-answer optimizations: throttled stream painting and `content-visibility` ## Run locally

@@ -62,6 +65,24 @@ 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. +## Search and navigation + +Use the search field in the conversation sidebar—or press `/` outside an input—to search all saved map titles, questions, answers, and PDF names. Results link directly to the matching map or question node. Search is debounced and capped at 60 displayed results to keep large workspaces responsive. + +The concept-map toolbar can expand or collapse all branches and zoom the tree from 80% to 140%. Individual branches have disclosure arrows, and their collapsed state persists with the map. The current root-to-node path remains highlighted. + +Vim-style shortcuts work whenever a text field or dialog control is not focused: + +- `j` / `k` — next / previous visible concept node +- `h` / `l` — parent / first child; `l` expands a collapsed active node first +- `gg` / `G` — first / last visible node +- `J` / `K` — next / previous conversation +- `Space` — collapse or expand the active node +- `/` — focus cross-conversation search +- `i` — focus the question composer +- `Esc` — leave input or search mode +- `?` — open the in-app shortcut reference + ## 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.

@@ -72,11 +93,15 @@ 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: +The browser stores these local keys: -- `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 +- `study-lab:v1` — multi-map conversation workspace, PDF file references, collapsed branches, and active-map selection +- `study-lab:settings:v1` — API key, model, endpoint, and system instructions +- `study-lab:usage:v1` — token usage and estimated request costs recorded by this browser +- `study-lab:theme` — light/dark theme preference +- `study-lab:tree-zoom` — concept-map zoom preference + +Data saved by releases before the rename is copied into the new storage namespace automatically on first load. The usage button in the header counts tokens reported by completed Responses API requests made from this browser. Its estimated USD amount uses a local pricing table and is not an invoice: it cannot see other browsers or applications, historical requests made before tracking was added, credits, taxes, or special pricing. Use the linked OpenAI usage dashboard as the source of truth for billing.
M app.js → app.js

@@ -1,9 +1,12 @@

(() => { 'use strict'; - const STORAGE_KEY = 'study-pal:v1'; - const SETTINGS_KEY = 'study-pal:settings:v1'; - const USAGE_KEY = 'study-pal:usage:v1'; + const APP_PREFIX = 'study-lab'; + const STORAGE_KEY = `${APP_PREFIX}:v1`; + const SETTINGS_KEY = `${APP_PREFIX}:settings:v1`; + const USAGE_KEY = `${APP_PREFIX}:usage:v1`; + const TREE_ZOOM_KEY = `${APP_PREFIX}:tree-zoom`; + const THEME_KEY = `${APP_PREFIX}:theme`; const PDF_REQUEST_LIMIT = 50 * 1024 * 1024; // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17. const MODEL_PRICES = [

@@ -41,9 +44,12 @@ usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'),

usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'), usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'), historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'), - attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input') + attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input'), + conversationSearch: $('#conversation-search'), searchResults: $('#search-results'), + treeZoom: $('#tree-zoom'), shortcutsDialog: $('#shortcuts-dialog') }; + migrateLegacyStorage(); let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS); let workspace = loadWorkspace(); let state = currentMap();

@@ -54,12 +60,31 @@ let uploadTargetMapId = null;

let selectedText = null; let saveTimer = 0; let toastTimer = 0; + let searchTimer = 0; + let searchIndex = null; + let pendingG = false; + let pendingGTimer = 0; + let treeZoom = loadTreeZoom(); function loadJSON(key, fallback) { try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; } catch { return { ...fallback }; } } + function migrateLegacyStorage() { + const legacyPrefix = ['study', 'pal'].join('-'); + const suffixes = [':v1', ':settings:v1', ':usage:v1', ':theme', ':tree-zoom']; + for (const suffix of suffixes) { + const currentKey = `${APP_PREFIX}${suffix}`; + const legacyKey = `${legacyPrefix}${suffix}`; + try { + if (localStorage.getItem(currentKey) === null && localStorage.getItem(legacyKey) !== null) { + localStorage.setItem(currentKey, localStorage.getItem(legacyKey)); + } + } catch {} + } + } + function loadUsage() { try { const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]');

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

} catch { return []; } } + function loadTreeZoom() { + try { return Math.max(.8, Math.min(1.4, Number(localStorage.getItem(TREE_ZOOM_KEY)) || 1)); } + catch { return 1; } + } + function blankMap() { const now = Date.now(); - return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], activeId: null, createdAt: now, updatedAt: now }; + return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], collapsedIds: [], activeId: null, createdAt: now, updatedAt: now }; } function titleFromNodes(nodes) {

@@ -94,6 +124,7 @@ title: String(value.title || titleFromNodes(normalized.nodes)),

titleCustom: Boolean(value.titleCustom), ...normalized, attachments, + collapsedIds: Array.isArray(value.collapsedIds) ? [...new Set(value.collapsedIds.map(String))] : [], createdAt, updatedAt: Number(value.updatedAt) || Number(normalized.nodes.at(-1)?.createdAt) || createdAt };

@@ -145,7 +176,7 @@ 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 touchMap(map = state) { map.updatedAt = Date.now(); searchIndex = null; } function refreshMapTitle(map = state) { if (!map.titleCustom) map.title = titleFromNodes(map.nodes);

@@ -278,19 +309,34 @@ grouped.get(parentId).push(node);

} const roots = grouped.get(null) || []; const activePath = new Set(pathTo(state.activeId).map(node => node.id)); + const collapsed = new Set(state.collapsedIds); const build = nodes => { const ul = document.createElement('ul'); for (const node of nodes) { const li = document.createElement('li'); + li.dataset.nodeId = node.id; + const wrap = document.createElement('div'); + wrap.className = 'tree-node-wrap'; const button = document.createElement('button'); button.type = 'button'; - button.className = `tree-node${node.id === state.activeId ? ' active' : ''}`; + button.className = `tree-node${node.id === state.activeId ? ' active' : ''}${activePath.has(node.id) ? ' on-path' : ''}`; 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)); + const isCollapsed = children.length > 0 && collapsed.has(node.id); + const toggle = document.createElement(children.length ? 'button' : 'span'); + toggle.className = children.length ? 'toggle-subtree' : 'toggle-placeholder'; + if (children.length) { + toggle.type = 'button'; + toggle.dataset.nodeId = node.id; + toggle.setAttribute('aria-label', `${isCollapsed ? 'Expand' : 'Collapse'} branch: ${node.question}`); + toggle.setAttribute('aria-expanded', String(!isCollapsed)); + toggle.textContent = isCollapsed ? '▸' : '▾'; + } + 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'}${isCollapsed ? ' · hidden' : ''}</span></span>`; + wrap.append(toggle, button); + li.append(wrap); + if (children.length && !isCollapsed) li.append(build(children)); ul.append(li); } return ul;

@@ -304,6 +350,44 @@ for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);

els.branchCount.textContent = String(branchCount); } + function revealNode(map, nodeId) { + if (!nodeId) return; + const hidden = new Set(map.collapsedIds); + for (const node of pathTo(nodeId, map).slice(0, -1)) hidden.delete(node.id); + map.collapsedIds = [...hidden]; + } + + function toggleSubtree(nodeId, force) { + if (!nodeById(nodeId)) return; + const collapsed = new Set(state.collapsedIds); + const shouldCollapse = force ?? !collapsed.has(nodeId); + if (shouldCollapse) collapsed.add(nodeId); + else collapsed.delete(nodeId); + state.collapsedIds = [...collapsed]; + persistSoon(); + renderTree(); + } + + function setAllSubtrees(collapsed) { + if (!collapsed) state.collapsedIds = []; + else { + const parents = new Set(state.nodes.map(node => node.parentId).filter(Boolean)); + state.collapsedIds = [...parents]; + } + persistSoon(); + renderTree(); + } + + function updateTreeZoom(delta = 0) { + treeZoom = Math.round(Math.max(.8, Math.min(1.4, treeZoom + delta)) * 10) / 10; + els.tree.style.fontSize = `${.76 * treeZoom}rem`; + els.tree.style.setProperty('--tree-indent', `${20 * treeZoom}px`); + els.tree.style.setProperty('--tree-dot-size', `${17 * treeZoom}px`); + els.tree.style.setProperty('--tree-node-pad-y', `${8 * treeZoom}px`); + els.treeZoom.textContent = `${Math.round(treeZoom * 100)}%`; + try { localStorage.setItem(TREE_ZOOM_KEY, String(treeZoom)); } catch {} + } + function renderBranchContext() { const active = nodeById(state.activeId); const latest = state.nodes.at(-1);

@@ -348,6 +432,72 @@ }

els.historyList.replaceChildren(fragment); els.historyEmpty.hidden = maps.length > 0; els.historyList.hidden = maps.length === 0; + if (els.conversationSearch.value.trim()) renderSearchResults(); + else els.searchResults.hidden = true; + } + + function buildSearchIndex() { + const entries = []; + for (const map of workspace.maps) { + if (!map.nodes.length && !map.attachments.length) continue; + entries.push({ mapId: map.id, nodeId: null, kind: 'Map', label: map.title, fields: [map.title] }); + for (const node of map.nodes) { + entries.push({ + mapId: map.id, nodeId: node.id, kind: 'Node', label: node.question, + fields: [node.question, node.answer || ''], mapTitle: map.title + }); + } + for (const file of map.attachments) { + entries.push({ mapId: map.id, nodeId: null, kind: 'PDF', label: file.name, fields: [file.name], mapTitle: map.title }); + } + } + searchIndex = entries; + return entries; + } + + function searchExcerpt(fields, expression) { + for (const field of fields) { + const source = String(field || '').replace(/\s+/g, ' ').trim(); + const index = source.search(expression); + if (index < 0) continue; + const start = Math.max(0, index - 45); + const end = Math.min(source.length, index + 115); + return `${start ? '…' : ''}${source.slice(start, end)}${end < source.length ? '…' : ''}`; + } + return ''; + } + + function renderSearchResults() { + const query = els.conversationSearch.value.trim(); + if (!query) { + els.searchResults.hidden = true; + els.historyList.hidden = false; + els.historyEmpty.hidden = workspace.maps.some(map => map.nodes.length || map.attachments.length); + return; + } + const expression = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + const matches = []; + for (const entry of searchIndex || buildSearchIndex()) { + const excerpt = searchExcerpt(entry.fields, expression); + if (!excerpt) continue; + matches.push({ ...entry, excerpt }); + if (matches.length >= 60) break; + } + els.searchResults.innerHTML = matches.length ? matches.map(entry => ` + <button class="search-result" type="button" data-map-id="${escapeHTML(entry.mapId)}"${entry.nodeId ? ` data-node-id="${escapeHTML(entry.nodeId)}"` : ''}> + <span class="search-result-top"><strong>${escapeHTML(entry.label)}</strong><small>${escapeHTML(entry.kind)} · ${escapeHTML(entry.mapTitle || entry.label)}</small></span> + <span class="search-excerpt">${escapeHTML(entry.excerpt)}</span> + </button>`).join('') : `<p class="search-empty">No results for “${escapeHTML(query)}”.</p>`; + els.searchResults.hidden = false; + els.historyList.hidden = true; + els.historyEmpty.hidden = true; + } + + function clearSearch() { + clearTimeout(searchTimer); + els.conversationSearch.value = ''; + renderSearchResults(); + renderHistory(); } function formatFileSize(bytes) {

@@ -481,6 +631,7 @@ const requestMap = state;

const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() }; requestMap.nodes.push(node); requestMap.activeId = node.id; + revealNode(requestMap, node.id); refreshMapTitle(requestMap); touchMap(requestMap); persistSoon();

@@ -591,7 +742,7 @@

function toggleTheme() { const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = next; - try { localStorage.setItem('study-pal:theme', next); } catch {} + try { localStorage.setItem(THEME_KEY, next); } catch {} updateThemeButton(); }

@@ -716,6 +867,7 @@ const map = existingBlank || blankMap();

if (!existingBlank) workspace.maps.push(map); workspace.activeMapId = map.id; state = map; + els.conversationSearch.value = ''; persistNow(); renderAll(); closeHistoryOnSmallScreen();

@@ -723,18 +875,115 @@ window.scrollTo({ top: 0, behavior: 'smooth' });

els.prompt.focus(); } - function switchMap(mapId) { + function switchMap(mapId, nodeId = null) { 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; + state.activeId = nodeId && nodeById(nodeId, state) ? nodeId : (state.nodes.at(-1)?.id || null); + revealNode(state, state.activeId); persistSoon(); hideSelectionMenu(); - renderAll({ scroll: state.activeId ? 'bottom' : false }); + renderAll({ scroll: state.activeId ? (nodeId ? true : 'bottom') : false }); closeHistoryOnSmallScreen(); } + function activateNode(nodeId, scroll = true) { + if (!nodeById(nodeId)) return; + state.activeId = nodeId; + revealNode(state, nodeId); + persistSoon(); + renderAll({ scroll }); + document.body.classList.remove('map-open'); + } + + function visibleTreeNodeIds() { + return [...els.tree.querySelectorAll('.tree-node')].map(button => button.dataset.nodeId); + } + + function moveVisibleNode(offset) { + const ids = visibleTreeNodeIds(); + if (!ids.length) return; + const current = Math.max(0, ids.indexOf(state.activeId)); + activateNode(ids[Math.max(0, Math.min(ids.length - 1, current + offset))]); + } + + function moveMap(offset) { + const maps = workspace.maps + .filter(map => map.nodes.length || map.attachments.length) + .sort((a, b) => b.updatedAt - a.updatedAt); + if (!maps.length) return; + const current = Math.max(0, maps.findIndex(map => map.id === state.id)); + switchMap(maps[Math.max(0, Math.min(maps.length - 1, current + offset))].id); + } + + function focusConversationSearch() { + document.body.classList.remove('history-collapsed', 'map-open'); + if (matchMedia('(max-width: 1100px)').matches) document.body.classList.add('history-open'); + updateHistoryToggle(); + els.conversationSearch.focus(); + els.conversationSearch.select(); + } + + function handleVimNavigation(event) { + if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return; + const editable = event.target.closest?.('input, textarea, select, [contenteditable="true"]'); + if (editable) { + if (event.key === 'Escape') { + event.preventDefault(); + if (editable === els.conversationSearch) clearSearch(); + editable.blur(); + } + return; + } + if ($('dialog[open]')) return; + if (pendingG && event.key !== 'g') { + clearTimeout(pendingGTimer); + pendingG = false; + } + if (event.key === '?') { event.preventDefault(); els.shortcutsDialog.showModal(); return; } + if (event.key === '/') { event.preventDefault(); focusConversationSearch(); return; } + if (event.key === 'i') { event.preventDefault(); els.prompt.focus(); return; } + if (event.key === 'j') { event.preventDefault(); moveVisibleNode(1); return; } + if (event.key === 'k') { event.preventDefault(); moveVisibleNode(-1); return; } + if (event.key === 'J') { event.preventDefault(); moveMap(1); return; } + if (event.key === 'K') { event.preventDefault(); moveMap(-1); return; } + if (event.key === 'h') { + const parentId = nodeById(state.activeId)?.parentId; + if (parentId) { event.preventDefault(); activateNode(parentId); } + return; + } + if (event.key === 'l') { + const children = childrenOf(state.activeId); + if (!children.length) return; + event.preventDefault(); + if (state.collapsedIds.includes(state.activeId)) toggleSubtree(state.activeId, false); + else activateNode(children[0].id); + return; + } + if (event.key === ' ') { + if (childrenOf(state.activeId).length) { event.preventDefault(); toggleSubtree(state.activeId); } + return; + } + if (event.key === 'G') { + const ids = visibleTreeNodeIds(); + if (ids.length) { event.preventDefault(); activateNode(ids.at(-1)); } + return; + } + if (event.key === 'g') { + event.preventDefault(); + if (pendingG) { + clearTimeout(pendingGTimer); + pendingG = false; + const ids = visibleTreeNodeIds(); + if (ids.length) activateNode(ids[0]); + } else { + pendingG = true; + pendingGTimer = setTimeout(() => { pendingG = false; }, 900); + } + } + } + function closeHistoryOnSmallScreen() { if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open'); updateHistoryToggle();

@@ -762,6 +1011,8 @@ if (activeRequest) activeRequest.abort();

const map = blankMap(); workspace = { version: 2, maps: [map], activeMapId: map.id }; state = map; + searchIndex = null; + els.conversationSearch.value = ''; persistNow(); renderAll(); showToast('All conversations cleared');

@@ -777,6 +1028,7 @@ 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); + searchIndex = null; if (!workspace.maps.length) workspace.maps.push(blankMap()); if (state === map) { const next = [...workspace.maps]

@@ -785,6 +1037,7 @@ .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; + revealNode(state, state.activeId); } persistNow(); renderAll({ scroll: state.activeId ? 'bottom' : false });

@@ -843,14 +1096,23 @@ 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 searchResult = event.target.closest('.search-result'); + if (searchResult) { + const { mapId, nodeId } = searchResult.dataset; + clearSearch(); + switchMap(mapId, nodeId || null); + 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 subtreeToggle = event.target.closest('.toggle-subtree'); + if (subtreeToggle) { toggleSubtree(subtreeToggle.dataset.nodeId); return; } const treeNode = event.target.closest('.tree-node'); - if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll({ scroll: true }); document.body.classList.remove('map-open'); } + if (treeNode) { activateNode(treeNode.dataset.nodeId); return; } 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');

@@ -891,12 +1153,27 @@ $('#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(); }); + els.conversationSearch.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(renderSearchResults, 110); + }); + els.conversationSearch.addEventListener('keydown', event => { + if (event.key !== 'Enter') return; + const first = $('.search-result', els.searchResults); + if (first) { event.preventDefault(); first.click(); } + }); + $('#study-lab-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()); + $('#open-shortcuts').addEventListener('click', () => els.shortcutsDialog.showModal()); + $('#close-shortcuts').addEventListener('click', () => els.shortcutsDialog.close()); + $('#expand-all-nodes').addEventListener('click', () => setAllSubtrees(false)); + $('#collapse-all-nodes').addEventListener('click', () => setAllSubtrees(true)); + $('#zoom-out-tree').addEventListener('click', () => updateTreeZoom(-.1)); + $('#zoom-in-tree').addEventListener('click', () => updateTreeZoom(.1)); $('#reset-usage').addEventListener('click', () => { if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) { usageRecords = [];

@@ -915,15 +1192,18 @@ 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'); }); $('#clear-history').addEventListener('click', () => clearChatHistory()); - $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); renderAll({ scroll: true }); }); + $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; revealNode(state, state.activeId); 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', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); }); addEventListener('resize', updateHistoryToggle, { passive: true }); + document.addEventListener('keydown', handleVimNavigation); updateKeyStatus(); updateThemeButton(); updateUsageButton(); updateHistoryToggle(); + updateTreeZoom(); state.activeId = state.nodes.at(-1)?.id || null; + revealNode(state, state.activeId); renderAll({ scroll: state.activeId ? 'bottom' : false }); })();
M index.html → index.html

@@ -4,10 +4,14 @@ <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> + <title>Study Lab — Branch your thinking</title> <script> try { - document.documentElement.dataset.theme = localStorage.getItem('study-pal:theme') || + const themeKey = 'study-lab:theme'; + const legacyThemeKey = `${['study', 'pal'].join('-')}:theme`; + const savedTheme = localStorage.getItem(themeKey) || localStorage.getItem(legacyThemeKey); + if (!localStorage.getItem(themeKey) && savedTheme) localStorage.setItem(themeKey, savedTheme); + document.documentElement.dataset.theme = savedTheme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } catch { document.documentElement.dataset.theme = 'light'; } </script>

@@ -19,12 +23,16 @@ <body>

<header class="topbar"> <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"> + <a id="study-lab-home" class="brand" href="#" aria-label="Open a new map"> <span class="brand-mark" aria-hidden="true">S</span> - <span>Study Pal</span> + <span>Study Lab</span> </a> </div> <div class="top-actions"> + <a class="github-button" href="https://github.com/arnaucube/study-lab" target="_blank" rel="noopener noreferrer" aria-label="Open Study Lab on GitHub" title="View source on GitHub"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .7a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2.23c-3.22.7-3.9-1.37-3.9-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.78 1.2 1.78 1.2 1.04 1.77 2.72 1.26 3.38.96.1-.75.4-1.26.74-1.55-2.57-.29-5.27-1.28-5.27-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.16 1.18a10.96 10.96 0 0 1 5.76 0c2.19-1.49 3.16-1.18 3.16-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.41-2.71 5.38-5.29 5.67.42.36.79 1.06.79 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .7Z"/></svg> + </a> + <button id="open-shortcuts" class="vim-shortcuts-button" type="button" aria-label="Show Vim navigation shortcuts" title="Vim navigation shortcuts">Vim shortcuts</button> <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> </button>

@@ -47,6 +55,12 @@ <h2>Conversations</h2>

</div> </div> <button id="new-map-sidebar" class="new-map-sidebar" type="button"><span aria-hidden="true">+</span> New map</button> + <label class="history-search" for="conversation-search"> + <span aria-hidden="true">⌕</span> + <input id="conversation-search" type="search" placeholder="Search conversations…" autocomplete="off" spellcheck="false"> + <kbd>/</kbd> + </label> + <div id="search-results" class="search-results" aria-live="polite" hidden></div> <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>

@@ -55,7 +69,7 @@

<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> + <h1>StudyLab - 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 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.">

@@ -110,6 +124,14 @@ </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> + <div class="map-toolbar" aria-label="Concept map controls"> + <button id="expand-all-nodes" type="button" title="Expand all branches">Expand</button> + <button id="collapse-all-nodes" type="button" title="Collapse all branches">Collapse</button> + <span class="map-toolbar-spacer"></span> + <button id="zoom-out-tree" type="button" aria-label="Zoom concept map out">−</button> + <output id="tree-zoom" aria-label="Concept map zoom">100%</output> + <button id="zoom-in-tree" type="button" aria-label="Zoom concept map in">+</button> + </div> <nav id="tree" class="tree" aria-label="Question branches"></nav> <div id="map-empty" class="map-empty"> <span class="empty-orbit">✦</span>

@@ -131,6 +153,7 @@ </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="What is">What is</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>

@@ -233,6 +256,30 @@ <button id="reset-usage" class="button button-ghost danger" type="button">Reset local history</button>

<a class="button button-ghost usage-dashboard" href="https://platform.openai.com/usage" target="_blank" rel="noopener noreferrer">Official dashboard ↗</a> <button class="button button-dark" value="close" type="submit">Done</button> </div> + </form> + </dialog> + + <dialog id="shortcuts-dialog" class="settings-dialog shortcuts-dialog"> + <form method="dialog"> + <div class="dialog-heading"> + <div> + <span class="eyebrow">Navigation</span> + <h2>Keyboard shortcuts</h2> + </div> + <button id="close-shortcuts" class="icon-button" type="button" aria-label="Close keyboard shortcuts">×</button> + </div> + <div class="shortcut-list"> + <span><kbd>j</kbd> / <kbd>k</kbd></span><span>Next / previous visible node</span> + <span><kbd>h</kbd> / <kbd>l</kbd></span><span>Parent / first child or expand</span> + <span><kbd>g</kbd> <kbd>g</kbd> / <kbd>G</kbd></span><span>First / last visible node</span> + <span><kbd>J</kbd> / <kbd>K</kbd></span><span>Next / previous conversation</span> + <span><kbd>/</kbd></span><span>Search all conversations</span> + <span><kbd>i</kbd></span><span>Focus the question composer</span> + <span><kbd>Space</kbd></span><span>Collapse or expand the active branch</span> + <span><kbd>Esc</kbd></span><span>Leave search or input mode</span> + <span><kbd>?</kbd></span><span>Show this shortcut reference</span> + </div> + <div class="dialog-actions"><button class="button button-dark" value="close" type="submit">Done</button></div> </form> </dialog>
M styles.css → styles.css

@@ -60,6 +60,11 @@ .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; } +.github-button { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 50%; color: var(--ink); background: transparent; transition: transform .15s, background .15s; } +.github-button:hover { transform: translateY(-1px); background: var(--card); } +.github-button svg { width: 17px; height: 17px; fill: currentColor; } +.vim-shortcuts-button { height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--muted); cursor: pointer; font-size: .68rem; white-space: nowrap; transition: transform .15s, background .15s; } +.vim-shortcuts-button:hover { transform: translateY(-1px); background: var(--card); color: var(--ink); } .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); } .usage-button #month-tokens { color: var(--ink); font-weight: 800; }

@@ -79,9 +84,23 @@ .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 { width: 100%; margin: 18px 0 9px; 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-search { display: flex; align-items: center; gap: 7px; margin-bottom: 9px; padding: 7px 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--muted); } +.history-search:focus-within { border-color: var(--sage); box-shadow: 0 0 0 3px rgba(79,109,96,.08); } +.history-search input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .72rem; } +.history-search input::-webkit-search-cancel-button { cursor: pointer; } +.history-search kbd { flex: 0 0 auto; font-size: .58rem; } +.search-results { flex: 1; min-height: 0; overflow-y: auto; padding: 1px 0 18px; scrollbar-width: thin; } +.search-results[hidden] { display: none; } +.search-result { width: 100%; display: block; padding: 10px 9px; border: 0; border-radius: 10px; background: transparent; text-align: left; cursor: pointer; } +.search-result:hover, .search-result:focus-visible { background: var(--card); outline: 0; } +.search-result-top { display: flex; align-items: baseline; justify-content: space-between; gap: 7px; } +.search-result-top strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .72rem; } +.search-result-top small { flex: 0 0 auto; color: var(--sage-dark); font-size: .55rem; text-transform: uppercase; letter-spacing: .06em; } +.search-excerpt { display: -webkit-box; margin-top: 4px; overflow: hidden; color: var(--muted); font-size: .63rem; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.search-empty { margin: 18px 8px; color: var(--muted); text-align: center; font-size: .72rem; line-height: 1.45; } .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; }

@@ -178,20 +197,30 @@ .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; } +.map-toolbar { display: flex; align-items: center; gap: 4px; margin: -5px 0 11px; padding-bottom: 10px; border-bottom: 1px solid var(--line); } +.map-toolbar button { min-width: 25px; height: 26px; padding: 0 7px; border: 1px solid var(--line); border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; font-size: .62rem; } +.map-toolbar button:hover { background: var(--card); color: var(--ink); } +.map-toolbar-spacer { flex: 1; } +.map-toolbar output { min-width: 34px; color: var(--muted); text-align: center; font-size: .58rem; font-variant-numeric: tabular-nums; } +.tree { --tree-indent: 20px; --tree-dot-size: 17px; --tree-node-pad-y: 8px; flex: 1; min-height: 0; overflow: auto; padding: 5px 4px 30px; font-size: .76rem; scrollbar-width: thin; } +.tree ul { list-style: none; margin: 0; padding: 0 0 0 var(--tree-indent); 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%; 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-wrap { display: flex; align-items: center; min-width: 0; } +.toggle-subtree, .toggle-placeholder { flex: 0 0 18px; width: 18px; height: 25px; display: grid; place-items: center; } +.toggle-subtree { padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; font-size: .9em; } +.toggle-subtree:hover { background: var(--card); color: var(--ink); } +.tree-node { width: 100%; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 11px; background: transparent; padding: var(--tree-node-pad-y) 9px; text-align: left; cursor: pointer; display: grid; grid-template-columns: calc(var(--tree-dot-size) + 2px) minmax(0,1fr); gap: 7px; align-items: start; font-size: inherit; } .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.on-path:not(.active) { background: rgba(139,163,151,.09); } +.node-dot { width: var(--tree-dot-size); height: var(--tree-dot-size); border-radius: 50%; display: grid; place-items: center; margin-top: 1px; background: #cad6cf; color: var(--sage-dark); font-size: .7em; 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; } +.node-title { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 1em; font-weight: 680; } +.node-meta { color: #858c88; font-size: .82em; } .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; }

@@ -248,6 +277,10 @@ .usage-note { margin: 14px 0 5px; padding: 9px 11px; border-radius: 9px; background: #f1f2ed; color: var(--muted); font-size: .7rem; line-height: 1.45; }

.usage-disclaimer { color: var(--muted); font-size: .64rem; line-height: 1.45; } .usage-dashboard { text-decoration: none; text-align: center; } .usage-dialog .dialog-actions { gap: 8px; align-items: center; } +.shortcuts-dialog { width: min(480px, calc(100vw - 28px)); } +.shortcut-list { display: grid; grid-template-columns: 145px 1fr; gap: 10px 16px; margin: 20px 0 8px; color: var(--muted); font-size: .76rem; align-items: center; } +.shortcut-list > span:nth-child(odd) { color: var(--ink); } +.shortcuts-dialog .dialog-actions { justify-content: flex-end; } .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); }

@@ -285,6 +318,7 @@ .usage-stat:last-child { grid-column: 1 / -1; }

.usage-dialog form { padding: 19px; } .usage-dialog .dialog-actions { display: grid; grid-template-columns: 1fr 1fr; } .usage-dialog .dialog-actions .button-dark { grid-column: 1 / -1; } + .shortcut-list { grid-template-columns: 118px 1fr; gap: 9px 11px; } } @media (max-width: 390px) { .brand > span:last-child, .usage-caption, .settings-label { display: none; }