diff --git a/docs/appearance.md b/docs/appearance.md
new file mode 100644
index 0000000..096308a
--- /dev/null
+++ b/docs/appearance.md
@@ -0,0 +1,21 @@
+# Appearance
+
+Choose **Light**, **Dark**, or **System** from the **Theme** selector at the bottom of the sidebar, or from **Library settings → Appearance**. The selector is also available when the sidebar is collapsed. System is the default and follows changes to your device's appearance.
+
+Dark mode uses charcoal surfaces, subtle gray borders, muted text, and blue accents consistent with light mode. Primary buttons use the same blue (`#2563eb`) as light mode, while links and focus accents use a lighter blue (`#60a5fa`) for contrast against charcoal. The theme applies to navigation, library and project views, tables, forms, dialogs, Copilot, rich-text notes, canvases, graphs, charts, and LaTeX previews. Status colors and user-selected canvas/task colors retain their meaning.
+
+Your preference is saved for this browser and synchronized between open tabs. It applies before the application loads to avoid a light flash. If browser storage is blocked, switching themes still works for the current session. Switching does not reload the page or change your notes.
+
+Images, PDFs, and external embedded pages keep their original appearance. Printable note exports retain their print styling; canvas PNG exports use the current theme. JSON Canvas files store document data, not the application theme.
+
+## Development
+
+`frontend/themePalette.cjs` generates the light and dark CSS variables used by Tailwind. Foreground, background, and border colors have separate roles, so utility variants such as `hover:bg-slate-50` and opacity modifiers work in either theme. Light mode retains the existing utility colors. The dark sidebar has its own surface scope for translucent white hover states.
+
+The stylesheet explicitly references `tailwind.config.cjs`. Both the config and palette use CommonJS so Tailwind 3 can invalidate its configuration cache during development, including on Node versions with native `require(ESM)` support. Native ES module caching can otherwise leave a running server serving the old light utilities after a config edit.
+
+Use Tailwind utilities or the `--ui-*` variables in `frontend/src/index.css` for new UI surfaces. Custom editors, SVG visualizations, and imperative autocomplete menus use the same tokens. Keep data colors separate from interface colors. Recharts tooltips and third-party editors need explicit themed styles.
+
+`ThemeProvider` manages the preference and system changes. `frontend/public/theme.js` is the small pre-render script; keep its preference key and resolution rules consistent with the provider.
+
+Validation: run `npm run test:run`, `npm run test:e2e -- theme.spec.js`, and `npm run build` from `frontend`. The full unit suite assumes `TZ=UTC`. Theme browser tests cover persistence, system changes, cross-tab updates, collapsed navigation, application surfaces, dialogs, notes, graphs, and canvases, and produce screenshots for review.
diff --git a/frontend/proposals.html b/frontend/_archive/proposals.html
similarity index 100%
rename from frontend/proposals.html
rename to frontend/_archive/proposals.html
diff --git a/frontend/e2e/authors-directory.spec.js b/frontend/e2e/authors-directory.spec.js
index c9d189f..bbe5700 100644
--- a/frontend/e2e/authors-directory.spec.js
+++ b/frontend/e2e/authors-directory.spec.js
@@ -61,7 +61,7 @@ test('add author suggests frequent names and still accepts custom names after a
await page.goto('/authors')
await page.getByRole('button', { name: 'Add author' }).click()
const input = page.getByRole('combobox', { name: 'Name *' })
- await expect(page.getByRole('option')).toHaveText(['Jane Smith8 papers', 'Alex Doe2 papers'])
+ await expect(page.getByRole('listbox').getByRole('option')).toHaveText(['Jane Smith8 papers', 'Alex Doe2 papers'])
await page.screenshot({ path: testInfo.outputPath('author-suggestions.png') })
await input.fill('alex')
await input.press('ArrowDown')
diff --git a/frontend/e2e/theme.spec.js b/frontend/e2e/theme.spec.js
new file mode 100644
index 0000000..13142b9
--- /dev/null
+++ b/frontend/e2e/theme.spec.js
@@ -0,0 +1,118 @@
+import { expect, test } from '@playwright/test'
+
+async function fixtures(page) {
+ const errors = []
+ page.on('pageerror', error => errors.push(error.message))
+ const paper = { id: 'paper', title: 'Attention and memory', authors: ['Ada Researcher'], status: 'inbox', year: 2026, venue: 'NeurIPS', collections: [], createdAt: '2026-10-20T12:00:00Z' }
+ const project = { id: 'project', name: 'Memory research', description: 'A study of attention and memory.', status: 'active' }
+ const notes = [
+ { id: 'note', name: 'Research notes', type: 'file', parentId: null, content: '
Research notes Evidence and ideas.
const result = 42 ' },
+ { id: 'canvas', name: 'Ideas.canvas', type: 'canvas', parentId: null, content: JSON.stringify({ nodes: [
+ { id: 'text', type: 'text', x: 0, y: 0, width: 300, height: 200, text: '# A connected idea\n\nSupporting evidence.' },
+ { id: 'file', type: 'file', x: 400, y: 0, width: 300, height: 200, file: 'Research notes', researchos: { noteId: 'note' } },
+ ], edges: [{ id: 'edge', fromNode: 'text', toNode: 'file', fromSide: 'right', toSide: 'left', label: 'supports' }] }) },
+ ]
+ await page.route('**/api/**', route => {
+ const path = new URL(route.request().url()).pathname
+ let json = []
+ if (path === '/api/libraries') json = [{ id: 'lib', name: 'Research' }]
+ if (path === '/api/papers') json = [paper]
+ if (path === '/api/papers/paper') json = paper
+ if (path === '/api/libraries/lib/notes') json = notes
+ if (path === '/api/settings/models') json = { current: {}, defaults: {}, descriptions: {}, available_chat_models: [], available_embedding_models: [] }
+ if (path === '/api/projects') json = [project]
+ if (path === '/api/projects/project') json = project
+ if (path === '/api/projects/project/notes') json = notes
+ if (path === '/api/projects/project/task-columns') json = [{ id: 'todo', projectId: 'project', name: 'To do', color: '#93c5fd', position: 0 }]
+ if (path === '/api/projects/project/tasks') json = [{ id: 'task', projectId: 'project', columnId: 'todo', title: 'Review evidence', position: 0, priority: 'medium', tags: [], customFields: {} }]
+ if (path === '/api/authors') json = [{ id: 'ada', name: 'Ada Researcher', paperCount: 1 }]
+ if (path === '/api/labs') json = { items: [], total: 0, limit: 50, offset: 0 }
+ return route.fulfill({ json })
+ })
+ return errors
+}
+
+test('theme choice persists, follows system changes, and works in the collapsed sidebar', async ({ page, context }) => {
+ await fixtures(page)
+ await page.emulateMedia({ colorScheme: 'dark' })
+ await page.goto('/library')
+ const selector = page.getByLabel('Color theme')
+ await expect(selector).toHaveValue('system')
+ await expect(page.locator('html')).toHaveClass('dark')
+ await selector.selectOption('light')
+ await page.reload()
+ await expect(page.locator('html')).not.toHaveClass('dark')
+ await expect(selector).toHaveValue('light')
+ await page.getByTitle('Collapse sidebar').click()
+ await selector.selectOption('dark')
+ await expect(page.locator('html')).toHaveClass('dark')
+ // A second tab shares the saved preference and receives live updates.
+ const other = await context.newPage()
+ await fixtures(other)
+ await other.goto('/authors')
+ await expect(other.locator('html')).toHaveClass('dark')
+ await selector.selectOption('light')
+ await expect(other.locator('html')).not.toHaveClass('dark')
+ await other.close()
+ await selector.selectOption('system')
+ await page.emulateMedia({ colorScheme: 'light' })
+ await expect(page.locator('html')).not.toHaveClass('dark')
+ await page.emulateMedia({ colorScheme: 'dark' })
+ await expect(page.locator('html')).toHaveClass('dark')
+})
+
+test('dark surfaces cover the application, dialogs, and charts', async ({ page }, testInfo) => {
+ test.setTimeout(60000)
+ const errors = await fixtures(page)
+ await page.addInitScript(() => localStorage.setItem('researchos.theme', 'dark'))
+ for (const route of ['/dashboard', '/library', '/authors', '/labs', '/agents', '/projects', '/projects/project', '/projects/project/literature', '/projects/project/experiments', '/projects/project/tasks', '/projects/project/notes', '/proposals', '/library/settings', '/library/map', '/library/paper/paper']) {
+ await page.goto(route)
+ await expect(page.getByLabel('Color theme').first(), route).toBeVisible()
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(25, 25, 25)')
+ await expect(page.locator('main'), route).not.toBeEmpty()
+ await expect(page.locator('.theme-sidebar')).toHaveCSS('background-color', 'rgb(24, 24, 24)')
+ // Check actual computed values, including panels rendered through portals.
+ for (const panel of await page.locator('.bg-white:visible').all()) {
+ await expect(panel).toHaveCSS('background-color', 'rgb(30, 30, 30)')
+ }
+ if (route === '/dashboard') {
+ await expect(page.getByRole('heading', { name: /Added Over Time/ })).toBeVisible()
+ await page.screenshot({ path: testInfo.outputPath('dashboard-dark.png') })
+ }
+ if (route === '/projects/project/tasks') {
+ await expect(page.getByText('Review evidence')).toBeVisible()
+ await page.screenshot({ path: testInfo.outputPath('tasks-dark.png') })
+ }
+ }
+ await page.goto('/authors')
+ await page.getByRole('button', { name: 'Add author' }).click()
+ await expect(page.getByRole('combobox', { name: 'Name *' })).toHaveCSS('background-color', 'rgb(30, 30, 30)')
+ await page.screenshot({ path: testInfo.outputPath('dialog-dark.png') })
+ expect(errors).toEqual([])
+})
+
+test('notes, graph, and canvas stay legible when switching themes without losing content', async ({ page }, testInfo) => {
+ const errors = await fixtures(page)
+ await page.addInitScript(() => localStorage.setItem('researchos.theme', 'dark'))
+ await page.goto('/library/notes')
+ await page.getByRole('button', { name: /Research notes/ }).first().click()
+ await expect(page.locator('.tiptap-editor h1')).toHaveCSS('color', 'rgb(237, 237, 237)')
+ await expect(page.locator('.tiptap-editor pre')).toHaveCSS('background-color', 'rgb(25, 25, 25)')
+ await page.screenshot({ path: testInfo.outputPath('notes-dark.png') })
+ await page.getByRole('button', { name: /Ideas.canvas/ }).first().click()
+ await expect(page.locator('.canvas-card').first()).toHaveCSS('background-color', 'rgb(30, 30, 30)')
+ await expect(page.locator('.react-flow__controls-button').first()).toHaveCSS('background-color', 'rgb(30, 30, 30)')
+ await expect(page.locator('.react-flow__minimap')).toHaveCSS('background-color', 'rgb(30, 30, 30)')
+ await page.screenshot({ path: testInfo.outputPath('canvas-dark.png') })
+ await page.getByRole('button', { name: 'File / canvas', exact: true }).click()
+ await expect(page.getByRole('dialog')).toHaveCSS('background-color', 'rgb(30, 30, 30)')
+ await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click()
+ await page.getByLabel('Color theme').selectOption('light')
+ await expect(page.locator('.canvas-card').first()).toHaveCSS('background-color', 'rgb(255, 255, 255)')
+ await expect(page.locator('.canvas-card-content').getByText('Supporting evidence.')).toBeVisible()
+ await page.getByLabel('Color theme').selectOption('dark')
+ await page.getByRole('button', { name: /Graph/ }).first().click()
+ await expect(page.locator('main svg text').filter({ hasText: 'Research notes' }).first()).toHaveCSS('fill', 'rgb(212, 212, 212)')
+ await page.screenshot({ path: testInfo.outputPath('graph-dark.png') })
+ expect(errors).toEqual([])
+})
diff --git a/frontend/index.html b/frontend/index.html
index a4b337a..fa549b9 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,6 +2,7 @@
+
diff --git a/frontend/public/theme.js b/frontend/public/theme.js
new file mode 100644
index 0000000..2e7665b
--- /dev/null
+++ b/frontend/public/theme.js
@@ -0,0 +1,8 @@
+// Runs before the app/styles load so a saved dark preference never flashes white.
+;(() => {
+ let mode = 'system'
+ try { mode = localStorage.getItem('researchos.theme') || mode } catch { /* Storage may be disabled. */ }
+ const dark = mode === 'dark' || (mode !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
+ document.documentElement.classList.toggle('dark', dark)
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
+})()
diff --git a/frontend/src/assets/profile-icons/README.md b/frontend/src/assets/profile-icons/README.md
index f054d9c..a50e903 100644
--- a/frontend/src/assets/profile-icons/README.md
+++ b/frontend/src/assets/profile-icons/README.md
@@ -7,5 +7,6 @@ Simple Icons is distributed under CC0; see LICENSE.simple-icons.md. Brand marks
SVG fills use the brand colours: ORCID green, Google Scholar blue, LinkedIn blue, and GitHub/X black.
OpenReview uses the Material Symbols rate_review icon in red.
-Icons are faded at rest and reach full opacity on individual link hover or keyboard focus.
+In light mode, icons are faded at rest and reach full opacity on individual link hover or keyboard focus.
+Dark mode reuses the SVG silhouettes as CSS masks at full opacity: GitHub/X are light gray, LinkedIn/Google Scholar are light blue, and ORCID retains its green. The website globe is light blue and OpenReview uses the theme's light red. Link backgrounds and focus outlines provide hover and keyboard feedback.
Assets are bundled locally; displaying profiles does not contact these services.
diff --git a/frontend/src/components/CitationExtension.js b/frontend/src/components/CitationExtension.js
index 4a984e1..03958b1 100644
--- a/frontend/src/components/CitationExtension.js
+++ b/frontend/src/components/CitationExtension.js
@@ -139,8 +139,8 @@ export function createCitationExtension({
`top:${y}px`,
`left:${x}px`,
'z-index:9999',
- 'background:#fff',
- 'border:1px solid #e2e8f0',
+ 'background:var(--ui-surface)',
+ 'border:1px solid rgb(var(--line-slate-200))',
'border-radius:8px',
'box-shadow:0 8px 24px rgba(0,0,0,.12)',
'min-width:220px',
@@ -150,13 +150,13 @@ export function createCitationExtension({
// ── Header: label + citation key ────────────────────────────────────
const header = document.createElement('div')
- header.style.cssText = 'padding:6px 12px 5px;border-bottom:1px solid #f1f5f9;margin-bottom:4px;'
+ header.style.cssText = 'padding:6px 12px 5px;border-bottom:1px solid rgb(var(--bg-slate-100));margin-bottom:4px;'
const labelText = displayLabel || '(citation)'
const keyText = citationKey || ''
header.innerHTML = `
- Citation
- ${labelText}
- ${keyText ? `${keyText} ` : ''}
+ Citation
+ ${labelText}
+ ${keyText ? `${keyText} ` : ''}
`
ctxMenu.appendChild(header)
@@ -164,7 +164,7 @@ export function createCitationExtension({
const btn = document.createElement('div')
btn.style.cssText = `padding:6px 12px;cursor:pointer;font-size:12px;color:${color};display:flex;align-items:center;gap:8px;`
btn.innerHTML = `${icon} ${label}`
- btn.addEventListener('mouseenter', () => { btn.style.background = '#f8fafc' })
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'rgb(var(--bg-slate-50))' })
btn.addEventListener('mouseleave', () => { btn.style.background = '' })
btn.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); onClick(e) })
ctxMenu.appendChild(btn)
@@ -172,13 +172,13 @@ export function createCitationExtension({
}
// ── Open paper ───────────────────────────────────────────────────────
- addMenuItem('📄', 'Open paper', '#374151', () => {
+ addMenuItem('📄', 'Open paper', 'rgb(var(--fg-gray-700))', () => {
removeCtxMenu()
onOpenItem?.(paperId, websiteId)
})
// ── Copy citation key ────────────────────────────────────────────────
- addMenuItem('🔑', 'Copy citation key', '#374151', async () => {
+ addMenuItem('🔑', 'Copy citation key', 'rgb(var(--fg-gray-700))', async () => {
if (citationKey) {
await navigator.clipboard.writeText(citationKey).catch(() => {})
}
@@ -186,7 +186,7 @@ export function createCitationExtension({
})
// ── Copy BibTeX entry ────────────────────────────────────────────────
- addMenuItem('📋', 'Copy BibTeX entry', '#374151', async () => {
+ addMenuItem('📋', 'Copy BibTeX entry', 'rgb(var(--fg-gray-700))', async () => {
try {
const bibtex = await getBibtexEntry?.(paperId, websiteId)
if (bibtex) {
@@ -200,11 +200,11 @@ export function createCitationExtension({
// ── Separator ────────────────────────────────────────────────────────
const sep = document.createElement('div')
- sep.style.cssText = 'height:1px;background:#f1f5f9;margin:4px 0;'
+ sep.style.cssText = 'height:1px;background:rgb(var(--bg-slate-100));margin:4px 0;'
ctxMenu.appendChild(sep)
// ── Remove citation ──────────────────────────────────────────────────
- addMenuItem('🗑️', 'Remove citation', '#ef4444', () => {
+ addMenuItem('🗑️', 'Remove citation', 'rgb(var(--fg-red-500))', () => {
const { state, dispatch } = view
dispatch(state.tr.removeMark(range.from, range.to, mark.type))
removeCtxMenu()
@@ -372,7 +372,7 @@ export function createCitationExtension({
if (!popup) return
const items = getSelectableItems()
items.forEach((el, i) => {
- el.style.background = i === selectedIndex ? '#f1f5f9' : ''
+ el.style.background = i === selectedIndex ? 'rgb(var(--bg-slate-100))' : ''
})
}
@@ -383,8 +383,8 @@ export function createCitationExtension({
popup.style.cssText = [
'position:fixed',
'z-index:9999',
- 'background:#fff',
- 'border:1px solid #e2e8f0',
+ 'background:var(--ui-surface)',
+ 'border:1px solid rgb(var(--line-slate-200))',
'border-radius:8px',
'box-shadow:0 8px 24px rgba(0,0,0,.12)',
'min-width:320px',
@@ -408,7 +408,7 @@ export function createCitationExtension({
if (!props.items.length) {
const empty = document.createElement('div')
- empty.style.cssText = 'padding:8px 12px;font-size:12px;color:#94a3b8;font-style:italic;'
+ empty.style.cssText = 'padding:8px 12px;font-size:12px;color:rgb(var(--fg-slate-400));font-style:italic;'
empty.textContent = 'No papers found'
popup.appendChild(empty)
return
@@ -422,11 +422,11 @@ export function createCitationExtension({
div.style.cssText = [
'padding:4px 12px 3px',
'font-size:10px',
- 'color:#94a3b8',
+ 'color:rgb(var(--fg-slate-400))',
'font-weight:500',
'text-transform:uppercase',
'letter-spacing:.05em',
- 'border-top:1px solid #f1f5f9',
+ 'border-top:1px solid rgb(var(--bg-slate-100))',
'margin-top:2px',
].join(';')
div.textContent = 'Search full library...'
@@ -443,7 +443,7 @@ export function createCitationExtension({
'display:flex',
'align-items:center',
'gap:7px',
- idx === selectedIndex ? 'background:#f1f5f9' : '',
+ idx === selectedIndex ? 'background:rgb(var(--bg-slate-100))' : '',
].join(';')
const icon = item.type === 'website' ? '🌐' : '📄'
@@ -460,11 +460,11 @@ export function createCitationExtension({
el.innerHTML = [
`${icon} `,
``,
- ` ${titleDisplay} `,
- subtitle ? ` ${subtitle} ` : '',
+ ` ${titleDisplay} `,
+ subtitle ? ` ${subtitle} ` : '',
` `,
item.isLinked
- ? `Linked `
+ ? `Linked `
: '',
].join('')
diff --git a/frontend/src/components/CitationNetworkViz.jsx b/frontend/src/components/CitationNetworkViz.jsx
index af4f308..f0f2dc9 100644
--- a/frontend/src/components/CitationNetworkViz.jsx
+++ b/frontend/src/components/CitationNetworkViz.jsx
@@ -180,7 +180,7 @@ export default function CitationNetworkViz({
.attr('class', 'node-circle')
.attr('r', d => getNodeSize(d, sizeBy, degreeMap))
.attr('fill', d => getNodeColor(d, colorBy, scales))
- .attr('stroke', '#fff')
+ .attr('stroke', 'var(--ui-surface)')
.attr('stroke-width', 1.5)
// Label below node
@@ -193,7 +193,7 @@ export default function CitationNetworkViz({
.attr('y', d => getNodeSize(d, sizeBy, degreeMap) + 12)
.attr('font-size', '9px')
.attr('font-family', 'ui-sans-serif, system-ui, sans-serif')
- .attr('fill', '#64748b')
+ .attr('fill', 'rgb(var(--fg-slate-500))')
.attr('text-anchor', 'middle')
.attr('pointer-events', 'none')
diff --git a/frontend/src/components/CopilotPanel.jsx b/frontend/src/components/CopilotPanel.jsx
index 6a86264..7c7fa96 100644
--- a/frontend/src/components/CopilotPanel.jsx
+++ b/frontend/src/components/CopilotPanel.jsx
@@ -30,7 +30,7 @@ function renderWikiLinksInHtml(html) {
if (!html) return html
return html.replace(/\[\[([^\]]+)\]\]/g, (_, name) => {
const safe = name.replace(/"/g, '"')
- return `${name} `
+ return `${name} `
})
}
diff --git a/frontend/src/components/HeatmapViz.jsx b/frontend/src/components/HeatmapViz.jsx
index 450fd2d..e61a5f7 100644
--- a/frontend/src/components/HeatmapViz.jsx
+++ b/frontend/src/components/HeatmapViz.jsx
@@ -167,7 +167,7 @@ export default function HeatmapViz({
.attr('text-anchor', 'end')
.attr('font-size', '10px')
.attr('font-family', 'ui-sans-serif, system-ui, sans-serif')
- .attr('fill', '#64748b')
+ .attr('fill', 'rgb(var(--fg-slate-500))')
.text(d => d.length > 20 ? d.slice(0, 20) + '\u2026' : d)
// Row labels
@@ -181,7 +181,7 @@ export default function HeatmapViz({
.attr('text-anchor', 'end')
.attr('font-size', '10px')
.attr('font-family', 'ui-sans-serif, system-ui, sans-serif')
- .attr('fill', '#64748b')
+ .attr('fill', 'rgb(var(--fg-slate-500))')
.text(d => d.length > 25 ? d.slice(0, 25) + '\u2026' : d)
// Cells
@@ -202,8 +202,8 @@ export default function HeatmapViz({
.attr('height', cellH - 1)
.attr('rx', 2)
.attr('ry', 2)
- .attr('fill', count === 0 ? '#f8fafc' : colorScale(count))
- .attr('stroke', count === 0 ? '#e2e8f0' : 'none')
+ .attr('fill', count === 0 ? 'rgb(var(--bg-slate-50))' : colorScale(count))
+ .attr('stroke', count === 0 ? 'rgb(var(--line-slate-200))' : 'none')
.attr('stroke-dasharray', count === 0 ? '3,2' : 'none')
.attr('cursor', count > 0 ? 'pointer' : 'default')
diff --git a/frontend/src/components/LaTeXPreviewPanel.jsx b/frontend/src/components/LaTeXPreviewPanel.jsx
index d66c4da..619bae9 100644
--- a/frontend/src/components/LaTeXPreviewPanel.jsx
+++ b/frontend/src/components/LaTeXPreviewPanel.jsx
@@ -6,12 +6,14 @@
*/
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
-import { github } from 'react-syntax-highlighter/dist/esm/styles/hljs'
+import { github, atomOneDark } from 'react-syntax-highlighter/dist/esm/styles/hljs'
+import { useTheme } from '../context/ThemeContext'
import latex from 'react-syntax-highlighter/dist/esm/languages/hljs/latex'
SyntaxHighlighter.registerLanguage('latex', latex)
export default function LaTeXPreviewPanel({ texContent }) {
+ const { resolvedTheme } = useTheme()
if (!texContent || !texContent.trim()) {
return (
nodeRadius(d.degree) + 5).attr('fill', 'transparent')
nodeEls.append('circle').attr('r', d => nodeRadius(d.degree))
.attr('fill', d => mergedColors[d.source] || mergedColors.library)
- .attr('stroke', '#fff').attr('stroke-width', 2.5)
+ .attr('stroke', 'var(--ui-surface)').attr('stroke-width', 2.5)
nodeEls.append('text').text(d => d.name)
.attr('x', 0).attr('y', d => nodeRadius(d.degree) + 13)
.attr('font-size', '11px')
.attr('font-family', 'ui-sans-serif, system-ui, sans-serif')
- .attr('fill', '#334155')
+ .attr('fill', 'rgb(var(--fg-slate-700))')
.attr('text-anchor', 'middle')
.attr('pointer-events', 'none')
@@ -508,7 +508,7 @@ export default function NoteGraphView({
{/* ── Options panel — top-right overlay ─────────────────────────── */}
diff --git a/frontend/src/components/NotesCopilotPanel.jsx b/frontend/src/components/NotesCopilotPanel.jsx
index d3ea8e3..601bbb6 100644
--- a/frontend/src/components/NotesCopilotPanel.jsx
+++ b/frontend/src/components/NotesCopilotPanel.jsx
@@ -50,7 +50,7 @@ function renderWikiLinksInHtml(html) {
if (!html) return html
return html.replace(/\[\[([^\]]+)\]\]/g, (_, name) => {
const safe = name.replace(/"/g, '"')
- return `${name} `
+ return `${name} `
})
}
@@ -94,25 +94,25 @@ function Icon({ name, className = '' }) {
}
/* ─── Inline metric chart (rendered into data-chart divs) ───────────────────── */
-function MetricComparisonChart({ type = 'bar', data = [], xKey = 'name', yKey = 'value', color = '#6366f1' }) {
+function MetricComparisonChart({ type = 'bar', data = [], xKey = 'name', yKey = 'value', color = 'rgb(var(--fg-indigo-500))' }) {
if (!data || data.length === 0) return null
return (
{type === 'line' ? (
-
-
-
-
+
+
+
+
) : (
-
-
-
-
+
+
+
+
)}
diff --git a/frontend/src/components/ProfileIcon.css b/frontend/src/components/ProfileIcon.css
new file mode 100644
index 0000000..de08530
--- /dev/null
+++ b/frontend/src/components/ProfileIcon.css
@@ -0,0 +1,19 @@
+/* Reuse the bundled SVG silhouettes, with legible colors on dark surfaces. */
+.dark .profile-icon-brand {
+ background-color: var(--profile-icon-color, rgb(var(--fg-slate-800)));
+ -webkit-mask: var(--profile-icon-image) center / contain no-repeat;
+ mask: var(--profile-icon-image) center / contain no-repeat;
+}
+
+.dark .profile-icon--linkedin,
+.dark .profile-icon--googlescholar {
+ --profile-icon-color: rgb(var(--fg-blue-400));
+}
+
+.dark .profile-icon--orcid {
+ --profile-icon-color: #a6ce39;
+}
+
+.dark .profile-icon--website {
+ color: rgb(var(--fg-blue-400));
+}
diff --git a/frontend/src/components/ProfileIcon.jsx b/frontend/src/components/ProfileIcon.jsx
index b866dca..f2f1de6 100644
--- a/frontend/src/components/ProfileIcon.jsx
+++ b/frontend/src/components/ProfileIcon.jsx
@@ -3,6 +3,7 @@ import github from '../assets/profile-icons/github.svg'
import googlescholar from '../assets/profile-icons/googlescholar.svg'
import linkedin from '../assets/profile-icons/linkedin.svg'
import x from '../assets/profile-icons/x.svg'
+import './ProfileIcon.css'
const BRAND_ICONS = { orcid, github, googlescholar, linkedin, x }
@@ -11,7 +12,7 @@ function Icon({ name, className = '' }) {
}
export default function ProfileIcon({ name, className = '' }) {
- const iconClassName = 'opacity-50 transition-opacity duration-150 group-hover/profile:opacity-100 group-focus-visible/profile:opacity-100 motion-reduce:transition-none ' + className
+ const iconClassName = `profile-icon profile-icon--${name} opacity-50 dark:opacity-100 transition-opacity duration-150 group-hover/profile:opacity-100 group-focus-visible/profile:opacity-100 motion-reduce:transition-none ` + className
if (name === 'openreview') {
return
}
@@ -20,5 +21,7 @@ export default function ProfileIcon({ name, className = '' }) {
}
- return
+ return
+
+
}
diff --git a/frontend/src/components/ThemeSelector.jsx b/frontend/src/components/ThemeSelector.jsx
new file mode 100644
index 0000000..238350d
--- /dev/null
+++ b/frontend/src/components/ThemeSelector.jsx
@@ -0,0 +1,24 @@
+import { useTheme } from '../context/ThemeContext'
+
+export default function ThemeSelector({ collapsed = false, sidebar = false }) {
+ const { mode, setMode } = useTheme()
+ return (
+
+
+ {mode === 'system' ? 'contrast' : mode === 'dark' ? 'dark_mode' : 'light_mode'}
+
+ {!collapsed && Theme }
+ setMode(event.target.value)}
+ className={`focus:outline-none focus:ring-0 ${collapsed ? 'absolute inset-0 opacity-0 cursor-pointer' : 'ml-auto rounded-md border px-2 py-1.5 max-w-[130px]'} ${sidebar ? 'bg-sidebar border-white/10 text-slate-200' : 'bg-white border-slate-300'}`}
+ >
+ System
+ Light
+ Dark
+
+
+ )
+}
diff --git a/frontend/src/components/TimelineViz.jsx b/frontend/src/components/TimelineViz.jsx
index ff52f23..308f587 100644
--- a/frontend/src/components/TimelineViz.jsx
+++ b/frontend/src/components/TimelineViz.jsx
@@ -124,7 +124,7 @@ export default function TimelineViz({ papers = [], colorBy = 'year', projectId }
) : (
-
+
diff --git a/frontend/src/components/WikiLinkExtension.js b/frontend/src/components/WikiLinkExtension.js
index b47612d..b6fcf29 100644
--- a/frontend/src/components/WikiLinkExtension.js
+++ b/frontend/src/components/WikiLinkExtension.js
@@ -122,8 +122,8 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
`top:${y}px`,
`left:${x}px`,
'z-index:9999',
- 'background:#fff',
- 'border:1px solid #e2e8f0',
+ 'background:var(--ui-surface)',
+ 'border:1px solid rgb(var(--line-slate-200))',
'border-radius:8px',
'box-shadow:0 8px 24px rgba(0,0,0,.12)',
'min-width:200px',
@@ -133,15 +133,15 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
// Header: show the target note name
const header = document.createElement('div')
- header.style.cssText = 'padding:6px 12px 5px;border-bottom:1px solid #f1f5f9;margin-bottom:4px;'
- header.innerHTML = `Wiki link 📄 ${noteName} `
+ header.style.cssText = 'padding:6px 12px 5px;border-bottom:1px solid rgb(var(--bg-slate-100));margin-bottom:4px;'
+ header.innerHTML = `Wiki link 📄 ${noteName} `
ctxMenu.appendChild(header)
// ── Set label ─────────────────────────────────────────────────────
const setLabelBtn = document.createElement('div')
- setLabelBtn.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:12px;color:#374151;display:flex;align-items:center;gap:8px;'
+ setLabelBtn.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:12px;color:rgb(var(--fg-gray-700));display:flex;align-items:center;gap:8px;'
setLabelBtn.innerHTML = '✏️ Set label'
- setLabelBtn.addEventListener('mouseenter', () => setLabelBtn.style.background = '#f8fafc')
+ setLabelBtn.addEventListener('mouseenter', () => setLabelBtn.style.background = 'rgb(var(--bg-slate-50))')
setLabelBtn.addEventListener('mouseleave', () => setLabelBtn.style.background = '')
setLabelBtn.addEventListener('mousedown', e => {
e.preventDefault()
@@ -158,15 +158,15 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
const currentText = view.state.doc.textBetween(range.from, range.to)
input.value = currentText
input.placeholder = 'Display label…'
- input.style.cssText = 'flex:1;min-width:0;padding:4px 8px;font-size:12px;border:1px solid #cbd5e1;border-radius:6px;outline:none;'
- input.addEventListener('focus', () => input.style.borderColor = '#6366f1')
- input.addEventListener('blur', () => input.style.borderColor = '#cbd5e1')
+ input.style.cssText = 'flex:1;min-width:0;padding:4px 8px;font-size:12px;border:1px solid rgb(var(--line-slate-300));border-radius:6px;outline:none;'
+ input.addEventListener('focus', () => input.style.borderColor = 'rgb(var(--fg-indigo-500))')
+ input.addEventListener('blur', () => input.style.borderColor = 'rgb(var(--line-slate-300))')
const confirmBtn = document.createElement('button')
confirmBtn.textContent = '✓'
- confirmBtn.style.cssText = 'padding:4px 8px;font-size:12px;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;flex-shrink:0;'
- confirmBtn.addEventListener('mouseenter', () => confirmBtn.style.background = '#4f46e5')
- confirmBtn.addEventListener('mouseleave', () => confirmBtn.style.background = '#6366f1')
+ confirmBtn.style.cssText = 'padding:4px 8px;font-size:12px;background:rgb(var(--bg-indigo-500));color:#fff;border:none;border-radius:6px;cursor:pointer;flex-shrink:0;'
+ confirmBtn.addEventListener('mouseenter', () => confirmBtn.style.background = 'rgb(var(--bg-indigo-600))')
+ confirmBtn.addEventListener('mouseleave', () => confirmBtn.style.background = 'rgb(var(--bg-indigo-500))')
function applyLabel() {
const newLabel = input.value.trim()
@@ -200,9 +200,9 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
// ── Remove link ───────────────────────────────────────────────────
const removeLinkBtn = document.createElement('div')
- removeLinkBtn.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:12px;color:#ef4444;display:flex;align-items:center;gap:8px;'
+ removeLinkBtn.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:12px;color:rgb(var(--fg-red-500));display:flex;align-items:center;gap:8px;'
removeLinkBtn.innerHTML = '🔗 Remove link'
- removeLinkBtn.addEventListener('mouseenter', () => removeLinkBtn.style.background = '#fef2f2')
+ removeLinkBtn.addEventListener('mouseenter', () => removeLinkBtn.style.background = 'rgb(var(--bg-red-50))')
removeLinkBtn.addEventListener('mouseleave', () => removeLinkBtn.style.background = '')
removeLinkBtn.addEventListener('mousedown', e => {
e.preventDefault()
@@ -321,10 +321,10 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
let selectedIndex = 0
const SOURCE_BADGE = {
- library: { bg: '#f1f5f9', color: '#64748b', label: 'Library' },
- paper: { bg: '#dbeafe', color: '#2563eb', label: 'Paper' },
- website: { bg: '#ccfbf1', color: '#0d9488', label: 'Website' },
- github: { bg: '#ede9fe', color: '#7c3aed', label: 'GitHub' },
+ library: { bg: 'rgb(var(--bg-slate-100))', color: 'rgb(var(--fg-slate-500))', label: 'Library' },
+ paper: { bg: 'rgb(var(--bg-blue-100))', color: 'rgb(var(--fg-blue-600))', label: 'Paper' },
+ website: { bg: 'rgb(var(--bg-teal-100))', color: 'rgb(var(--fg-teal-600))', label: 'Website' },
+ github: { bg: 'rgb(var(--bg-violet-100))', color: 'rgb(var(--fg-violet-600))', label: 'GitHub' },
}
function removePopup() {
@@ -338,8 +338,8 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
popup.style.cssText = [
'position:fixed',
'z-index:9999',
- 'background:#fff',
- 'border:1px solid #e2e8f0',
+ 'background:var(--ui-surface)',
+ 'border:1px solid rgb(var(--line-slate-200))',
'border-radius:8px',
'box-shadow:0 8px 24px rgba(0,0,0,.12)',
'min-width:300px',
@@ -366,7 +366,7 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
if (!props.items.length) {
const empty = document.createElement('div')
empty.style.cssText =
- 'padding:8px 12px;font-size:12px;color:#94a3b8;font-style:italic;'
+ 'padding:8px 12px;font-size:12px;color:rgb(var(--fg-slate-400));font-style:italic;'
empty.textContent = 'No notes found'
popup.appendChild(empty)
return
@@ -380,21 +380,21 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
'display:flex',
'align-items:center',
'gap:7px',
- i === selectedIndex ? 'background:#f1f5f9' : '',
+ i === selectedIndex ? 'background:rgb(var(--bg-slate-100))' : '',
].join(';')
if (item.isNew) {
el.innerHTML = [
- `new `,
- `${item.name} `,
+ `new `,
+ `${item.name} `,
].join('')
} else {
const badge = SOURCE_BADGE[item.source] || SOURCE_BADGE.library
const sourceName = item.sourceName || badge.label
const truncated = sourceName.length > 28 ? sourceName.slice(0, 26) + '…' : sourceName
el.innerHTML = [
- `📄 `,
- `${item.name} `,
+ `📄 `,
+ `${item.name} `,
`${truncated} `,
].join('')
}
@@ -413,7 +413,7 @@ export function createWikiLinkExtension({ getAllNotes, onWikiLinkClick }) {
function highlight() {
if (!popup) return
Array.from(popup.children).forEach((el, i) => {
- el.style.background = i === selectedIndex ? '#f1f5f9' : ''
+ el.style.background = i === selectedIndex ? 'rgb(var(--bg-slate-100))' : ''
})
}
diff --git a/frontend/src/components/canvas/CanvasEditor.jsx b/frontend/src/components/canvas/CanvasEditor.jsx
index f9a14df..d8484f0 100644
--- a/frontend/src/components/canvas/CanvasEditor.jsx
+++ b/frontend/src/components/canvas/CanvasEditor.jsx
@@ -8,6 +8,7 @@ import { FilePreview, MarkdownCard } from './CanvasPreview'
import LinkedNoteDialog from './LinkedNoteDialog'
import { noteMarkdown } from '../../utils/noteMarkdown'
import './canvas.css'
+import { useTheme } from '../../context/ThemeContext'
const SIDES = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left }
const MAX_FILE_SIZE = 15 * 1024 * 1024
@@ -56,6 +57,7 @@ function FilePicker({ notes, onPick, onClose }) {
}
function CanvasEditorInner({ initial, note, notes, onChange, onOpen, onCreateNote, onSave, renderNoteEditor, onFileSaved }) {
+ const { resolvedTheme } = useTheme()
const [doc, setDoc] = useState(initial)
const current = useRef(doc)
const [selected, setSelected] = useState(new Set())
@@ -331,7 +333,7 @@ function CanvasEditorInner({ initial, note, notes, onChange, onOpen, onCreateNot
if (!doc.nodes.length) throw new Error('Add a card before exporting an image.')
const width = Math.ceil(viewportOnly ? root.current.clientWidth : box.width + 80), height = Math.ceil(viewportOnly ? root.current.clientHeight : box.height + 80)
if (width * height * scale * scale > 64_000_000) throw new Error('This canvas is too large for a PNG. Export the viewport or use a lower scale.')
- const url = await toPng(viewport, { backgroundColor: '#f8fafc', width, height, pixelRatio: scale,
+ const url = await toPng(viewport, { backgroundColor: getComputedStyle(root.current.querySelector('.canvas-flow')).backgroundColor, width, height, pixelRatio: scale,
style: viewportOnly ? {} : { width: `${width}px`, height: `${height}px`, transform: `translate(${-box.x + 40}px, ${-box.y + 40}px) scale(1)` },
filter: element => !element.classList?.contains('react-flow__handle') && !element.classList?.contains('react-flow__resize-control') && !(privacy && (element.classList?.contains('canvas-card-content') || element.classList?.contains('canvas-card-title') || element.classList?.contains('react-flow__edge-textwrapper'))),
})
@@ -414,7 +416,7 @@ function CanvasEditorInner({ initial, note, notes, onChange, onOpen, onCreateNot
} else if (safeUrl(text)) add('link', { url: text }, position)
else if (text) add('text', { text }, position)
}}>
- setSelectedEdges(prev => { const next = new Set(prev); changes.filter(c => c.type === 'select').forEach(c => c.selected ? next.add(c.id) : next.delete(c.id)); return next })}
onConnect={connect} onConnectEnd={connectEnd} onNodeDragStart={dragStart} onNodeDragStop={dragStop}
onReconnectStart={() => { reconnecting.current = { success: false } }} onReconnect={(old, connection) => { reconnecting.current.success = true; patchEdge(old.id, { fromNode: connection.source, toNode: connection.target, fromSide: connection.sourceHandle, toSide: connection.targetHandle }) }}
@@ -427,7 +429,7 @@ function CanvasEditorInner({ initial, note, notes, onChange, onOpen, onCreateNot
connectionMode={ConnectionMode.Loose} nodesDraggable={!readOnly} nodesConnectable={!readOnly} edgesReconnectable={!readOnly}
deleteKeyCode={null} selectionOnDrag selectionMode={SelectionMode.Partial} multiSelectionKeyCode="Shift" panOnDrag={[1, 2]} panActivationKeyCode="Space" panOnScroll zoomOnScroll={false} zoomOnPinch zoomOnDoubleClick={false}
snapToGrid={snap} snapGrid={[20, 20]} minZoom={0.05} maxZoom={4} fitView fitViewOptions={{ padding: 0.15 }} elevateNodesOnSelect={false} proOptions={{ hideAttribution: false }}>
-
+
{showMap && canvasColor(n.data.raw.color)} />}
diff --git a/frontend/src/components/canvas/CanvasPreview.jsx b/frontend/src/components/canvas/CanvasPreview.jsx
index 2377cfc..5cf4b37 100644
--- a/frontend/src/components/canvas/CanvasPreview.jsx
+++ b/frontend/src/components/canvas/CanvasPreview.jsx
@@ -72,7 +72,7 @@ export default function CanvasPreview({ content, notes = [], sourceKey, visited
const byId = new Map(doc.nodes.map(n => [n.id, n]))
return
{doc.nodes.map(n =>
-
+
{n.type === 'text' &&
}
diff --git a/frontend/src/components/canvas/canvas.css b/frontend/src/components/canvas/canvas.css
index 78846e9..ca119d5 100644
--- a/frontend/src/components/canvas/canvas.css
+++ b/frontend/src/components/canvas/canvas.css
@@ -1,37 +1,37 @@
-.canvas-document{display:flex;flex:1;flex-direction:column;min-height:0;min-width:0;height:100%;color:#334155;font-size:12px;background:#fff}
-.canvas-note-embed{border:1px solid #cbd5e1;border-radius:8px;padding:10px;margin:12px 0;background:#f8fafc}.canvas-note-embed>button{color:#2563eb;font-size:13px}
+.canvas-document{display:flex;flex:1;flex-direction:column;min-height:0;min-width:0;height:100%;color:rgb(var(--fg-slate-700));font-size:12px;background:var(--ui-surface)}
+.canvas-note-embed{border:1px solid rgb(var(--line-slate-300));border-radius:8px;padding:10px;margin:12px 0;background:rgb(var(--bg-slate-50))}.canvas-note-embed>button{color:rgb(var(--fg-blue-600));font-size:13px}
.canvas-document button,.canvas-editor button,.canvas-editor summary{cursor:pointer}
.canvas-document button:disabled,.canvas-editor button:disabled{opacity:.4;cursor:default}
-.canvas-document-header{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid #e2e8f0}
+.canvas-document-header{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid rgb(var(--line-slate-200))}
.canvas-document-header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
-.canvas-document-header span{font-size:11px;color:#64748b}
-.canvas-document-header button,.canvas-toolbar>button,.canvas-toolbar summary,.canvas-status button{padding:4px 8px;border:1px solid #e2e8f0;border-radius:5px;background:#fff;white-space:nowrap}
+.canvas-document-header span{font-size:11px;color:rgb(var(--fg-slate-500))}
+.canvas-document-header button,.canvas-toolbar>button,.canvas-toolbar summary,.canvas-status button{padding:4px 8px;border:1px solid rgb(var(--line-slate-200));border-radius:5px;background:var(--ui-surface);white-space:nowrap}
.canvas-editor{display:flex;flex:1;flex-direction:column;min-height:320px;min-width:0;position:relative;outline:none}
-.canvas-editor:focus-visible{outline:2px solid #93c5fd;outline-offset:-2px}
-.canvas-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:5px;padding:7px 10px;border-bottom:1px solid #e2e8f0;background:#fff;z-index:10}
-.canvas-toolbar button:hover:not(:disabled),.canvas-inspector button:hover,.canvas-status button:hover{background:#eff6ff}
-.canvas-toolbar-divider{height:18px;border-left:1px solid #e2e8f0;margin:0 3px}
+.canvas-editor:focus-visible{outline:2px solid rgb(var(--line-blue-300));outline-offset:-2px}
+.canvas-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:5px;padding:7px 10px;border-bottom:1px solid rgb(var(--line-slate-200));background:var(--ui-surface);z-index:10}
+.canvas-toolbar button:hover:not(:disabled),.canvas-inspector button:hover,.canvas-status button:hover{background:rgb(var(--bg-blue-50))}
+.canvas-toolbar-divider{height:18px;border-left:1px solid rgb(var(--line-slate-200));margin:0 3px}
.canvas-toolbar details{position:relative}
-.canvas-menu,.canvas-context-menu{display:flex;flex-direction:column;gap:4px;min-width:185px;padding:8px;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 8px 24px #0f172a20;background:#fff}
+.canvas-menu,.canvas-context-menu{display:flex;flex-direction:column;gap:4px;min-width:185px;padding:8px;border:1px solid rgb(var(--line-slate-300));border-radius:8px;box-shadow:0 8px 24px #0f172a20;background:var(--ui-surface)}
.canvas-menu{position:absolute;top:100%;right:0;z-index:30}
.canvas-menu button,.canvas-context-menu button{padding:6px;text-align:left;border-radius:4px}
.canvas-menu label{display:flex;align-items:center;gap:6px;padding:4px;white-space:nowrap}
.canvas-context-menu{position:absolute;z-index:40}
.canvas-workspace{display:flex;flex:1;min-height:0;overflow:hidden}
-.canvas-flow{position:relative;flex:1;min-width:0;min-height:280px;background:#f8fafc}
-.canvas-card{height:100%;width:100%;border:2px solid;border-radius:9px;background:#fff;box-shadow:0 3px 10px #0f172a0a;display:flex;flex-direction:column;overflow:visible}
+.canvas-flow{position:relative;flex:1;min-width:0;min-height:280px;background:rgb(var(--bg-slate-50))}
+.canvas-card{height:100%;width:100%;border:2px solid;border-radius:9px;background:var(--ui-surface);box-shadow:0 3px 10px #0f172a0a;display:flex;flex-direction:column;overflow:visible}
.canvas-card.is-selected{box-shadow:0 0 0 2px #3b82f640,0 4px 15px #0f172a15}
.canvas-card-group{background-color:#e2e8f040;border-style:dashed}
-.canvas-card-title{display:flex;align-items:center;gap:5px;padding:8px 10px;min-height:34px;border-bottom:1px solid #e2e8f0;font-size:12px;cursor:grab;background:color-mix(in srgb,var(--card-color) 8%,white);border-radius:7px 7px 0 0}
+.canvas-card-title{display:flex;align-items:center;gap:5px;padding:8px 10px;min-height:34px;border-bottom:1px solid rgb(var(--line-slate-200));font-size:12px;cursor:grab;background:color-mix(in srgb,var(--card-color) 8%,var(--ui-surface));border-radius:7px 7px 0 0}
.canvas-card-title span{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.canvas-card-title button,.canvas-card-title a{padding:0 4px;color:#2563eb;font-size:16px}
+.canvas-card-title button,.canvas-card-title a{padding:0 4px;color:rgb(var(--fg-blue-600));font-size:16px}
.canvas-card-group .canvas-card-title{font-weight:600}
.canvas-card-content{flex:1;min-height:0;overflow:hidden;padding:12px;font-size:14px;line-height:1.55}
.canvas-card.is-selected .canvas-card-content{overflow:auto}
.canvas-card-content>div:has(>textarea){height:100%}
-.canvas-card-content textarea{height:100%;min-height:100px;width:100%;border:0;outline:0;resize:none;background:#fff;white-space:pre-wrap;font:14px/1.55 ui-monospace,monospace}
-.canvas-card-content iframe{width:100%;height:calc(100% - 30px);border:0;background:white}
-.canvas-card-content small{font-size:10px;color:#64748b;display:block}
+.canvas-card-content textarea{height:100%;min-height:100px;width:100%;border:0;outline:0;resize:none;background:var(--ui-surface);white-space:pre-wrap;font:14px/1.55 ui-monospace,monospace}
+.canvas-card-content iframe{width:100%;height:calc(100% - 30px);border:0;background:var(--ui-surface)}
+.canvas-card-content small{font-size:10px;color:rgb(var(--fg-slate-500));display:block}
.canvas-inactive-frame{pointer-events:none}
.canvas-card-content img,.canvas-card-content video{width:100%;height:100%;object-fit:contain}
.canvas-card-content audio{max-width:100%}
@@ -40,29 +40,46 @@
.canvas-markdown{overflow-wrap:anywhere;line-height:1.6}
.canvas-markdown p{margin:0 0 8px}.canvas-markdown h1{font-size:1.6em;font-weight:700}.canvas-markdown h2{font-size:1.35em;font-weight:650}.canvas-markdown h3{font-size:1.15em;font-weight:600}
.canvas-markdown ul,.canvas-markdown ol{padding-left:22px;margin:8px 0}.canvas-markdown ul{list-style:disc}.canvas-markdown ol{list-style:decimal}
-.canvas-markdown pre{white-space:pre-wrap;background:#f1f5f9;border-radius:4px;padding:8px;font-size:.9em}.canvas-markdown code{background:#f1f5f9;padding:1px 3px}.canvas-markdown a{color:#2563eb;text-decoration:underline}
-.canvas-markdown blockquote{border-left:3px solid #cbd5e1;padding-left:10px;color:#64748b}.canvas-markdown table{border-collapse:collapse}.canvas-markdown td,.canvas-markdown th{border:1px solid #cbd5e1;padding:3px 7px}
+.canvas-markdown pre{white-space:pre-wrap;background:rgb(var(--bg-slate-100));border-radius:4px;padding:8px;font-size:.9em}.canvas-markdown code{background:rgb(var(--bg-slate-100));padding:1px 3px}.canvas-markdown a{color:rgb(var(--fg-blue-600));text-decoration:underline}
+.canvas-markdown blockquote{border-left:3px solid rgb(var(--line-slate-300));padding-left:10px;color:rgb(var(--fg-slate-500))}.canvas-markdown table{border-collapse:collapse}.canvas-markdown td,.canvas-markdown th{border:1px solid rgb(var(--line-slate-300));padding:3px 7px}
.canvas-preview{height:100%;width:100%;min-height:100px;pointer-events:none}
-.canvas-preview-content{height:100%;overflow:hidden;font:14px/1.4 system-ui;color:#334155}
-.canvas-placeholder{color:#64748b;font-size:12px;padding:10px}
-.canvas-inspector{width:190px;flex-shrink:0;padding:12px;overflow-y:auto;display:flex;flex-direction:column;gap:9px;background:white;border-left:1px solid #e2e8f0}
-.canvas-inspector button,.canvas-inspector input,.canvas-inspector select,.canvas-status input,.canvas-status select{border:1px solid #e2e8f0;border-radius:5px;padding:5px;font-size:11px;max-width:100%;background:#fff}
-.canvas-inspector label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:#64748b}
+.canvas-preview-content{height:100%;overflow:hidden;font:14px/1.4 system-ui;color:rgb(var(--fg-slate-700))}
+.canvas-placeholder{color:rgb(var(--fg-slate-500));font-size:12px;padding:10px}
+.canvas-inspector{width:190px;flex-shrink:0;padding:12px;overflow-y:auto;display:flex;flex-direction:column;gap:9px;background:var(--ui-surface);border-left:1px solid rgb(var(--line-slate-200))}
+.canvas-inspector button,.canvas-inspector input,.canvas-inspector select,.canvas-status input,.canvas-status select{border:1px solid rgb(var(--line-slate-200));border-radius:5px;padding:5px;font-size:11px;max-width:100%;background:var(--ui-surface)}
+.canvas-inspector label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:rgb(var(--fg-slate-500))}
.canvas-inspector>div>label{margin-bottom:6px}
.canvas-colors{display:flex;gap:4px;flex-wrap:wrap}.canvas-colors button{width:19px;height:19px;border-radius:50%;border:0}.canvas-colors input{height:22px;width:27px;padding:0}
-.canvas-status{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:6px 10px;border-top:1px solid #e2e8f0;font-size:10px;color:#64748b;background:white}
+.canvas-status{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:6px 10px;border-top:1px solid rgb(var(--line-slate-200));font-size:10px;color:rgb(var(--fg-slate-500));background:var(--ui-surface)}
.canvas-status span{flex:1}.canvas-status input{width:110px}
-.canvas-error{padding:10px 12px;color:#991b1b;background:#fff1f2;border-bottom:1px solid #fecdd3;display:flex;align-items:center;gap:8px;font-size:12px}
-.canvas-error button{padding:4px;border:1px solid #fda4af;border-radius:4px}
-.canvas-help{padding:10px 14px;font-size:11px;line-height:1.7;background:#eff6ff;border-bottom:1px solid #bfdbfe;color:#1e40af}
-.canvas-empty{position:absolute;top:40%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;max-width:350px;pointer-events:none;color:#64748b}
-.canvas-empty strong{font-size:18px;color:#334155}.canvas-empty p{margin:8px 0 16px}.canvas-empty button{pointer-events:auto;background:#2563eb;color:#fff;padding:8px 12px;border-radius:7px}
+.canvas-error{padding:10px 12px;color:rgb(var(--fg-red-800));background:rgb(var(--bg-rose-50));border-bottom:1px solid rgb(var(--line-rose-200));display:flex;align-items:center;gap:8px;font-size:12px}
+.canvas-error button{padding:4px;border:1px solid rgb(var(--line-rose-300));border-radius:4px}
+.canvas-help{padding:10px 14px;font-size:11px;line-height:1.7;background:rgb(var(--bg-blue-50));border-bottom:1px solid rgb(var(--line-blue-200));color:rgb(var(--fg-blue-800))}
+.canvas-empty{position:absolute;top:40%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;max-width:350px;pointer-events:none;color:rgb(var(--fg-slate-500))}
+.canvas-empty strong{font-size:18px;color:rgb(var(--fg-slate-700))}.canvas-empty p{margin:8px 0 16px}.canvas-empty button{pointer-events:auto;background:rgb(var(--bg-blue-600));color:#fff;padding:8px 12px;border-radius:7px}
.canvas-modal-backdrop{position:fixed;inset:0;background:#0f172a60;z-index:100;display:flex;align-items:center;justify-content:center;padding:20px}
-.canvas-modal{background:#fff;border-radius:12px;padding:18px;width:480px;max-width:100%;max-height:75vh;display:flex;flex-direction:column;gap:14px;box-shadow:0 20px 60px #0003}
+.canvas-modal{background:var(--ui-surface);border-radius:12px;padding:18px;width:480px;max-width:100%;max-height:75vh;display:flex;flex-direction:column;gap:14px;box-shadow:0 20px 60px #0003}
.canvas-modal header{display:flex;justify-content:space-between;font-size:14px}.canvas-modal header button{font-size:20px}
-.canvas-modal>input{padding:8px;border:1px solid #cbd5e1;border-radius:6px}
+.canvas-modal>input{padding:8px;border:1px solid rgb(var(--line-slate-300));border-radius:6px}
.canvas-file-list{overflow:auto;display:flex;flex-direction:column;gap:4px}
.canvas-file-list button{display:flex;flex-direction:column;align-items:flex-start;padding:8px;text-align:left;border-radius:6px}
-.canvas-file-list button:hover{background:#eff6ff}.canvas-file-list small{color:#64748b;font-size:10px;margin-top:3px}
-.canvas-note-dialog{width:800px;height:75vh}.canvas-note-dialog-editor{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.canvas-note-dialog footer{display:flex;justify-content:flex-end;gap:10px}.canvas-note-dialog footer button{padding:6px 12px;border:1px solid #cbd5e1;border-radius:6px}
+.canvas-file-list button:hover{background:rgb(var(--bg-blue-50))}.canvas-file-list small{color:rgb(var(--fg-slate-500));font-size:10px;margin-top:3px}
+.canvas-note-dialog{width:800px;height:75vh}.canvas-note-dialog-editor{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.canvas-note-dialog footer{display:flex;justify-content:flex-end;gap:10px}.canvas-note-dialog footer button{padding:6px 12px;border:1px solid rgb(var(--line-slate-300));border-radius:6px}
@media(max-width:750px){.canvas-inspector{width:150px;padding:8px}.canvas-toolbar{gap:3px;padding:5px}.canvas-toolbar>button{font-size:10px;padding:4px}.canvas-flow .react-flow__minimap{display:none}.canvas-status input{display:none}}
+
+.canvas-flow .react-flow {
+ --xy-background-color: var(--ui-bg);
+ --xy-controls-button-background-color: var(--ui-surface);
+ --xy-controls-button-background-color-hover: var(--ui-hover);
+ --xy-controls-button-color: var(--ui-text);
+ --xy-controls-button-border-color: var(--ui-border);
+ --xy-minimap-background-color: var(--ui-surface);
+ --xy-minimap-mask-background-color: rgb(var(--bg-slate-200) / .65);
+ --xy-edge-label-background-color: var(--ui-surface);
+ --xy-edge-label-color: var(--ui-text);
+ --xy-attribution-background-color: var(--ui-surface);
+}
+.canvas-flow .react-flow__attribution a { color: var(--ui-muted); }
+.canvas-card .react-flow__handle { border-color: var(--ui-surface); }
+.dark .canvas-card-group { background-color: rgb(var(--bg-slate-200) / .3); }
+.dark .canvas-modal { color: var(--ui-text); }
diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx
index 6d331be..2e4c485 100644
--- a/frontend/src/components/layout/Sidebar.jsx
+++ b/frontend/src/components/layout/Sidebar.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
+import ThemeSelector from '../ThemeSelector'
import { NavLink, useNavigate, useSearchParams, useLocation } from 'react-router-dom'
import { proposalsApi, papersApi, websitesApi, projectsApi } from '../../services/api'
import { useLibrary } from '../../context/LibraryContext'
@@ -827,7 +828,7 @@ export default function Sidebar({ collapsed, onToggle }) {
return (
@@ -857,7 +858,7 @@ export default function Sidebar({ collapsed, onToggle }) {
{/* Library switcher */}
-
+
{/* Main navigation */}
@@ -905,6 +906,7 @@ export default function Sidebar({ collapsed, onToggle }) {
{/* User profile */}
+
{collapsed ? (
['light', 'dark', 'system'].includes(mode) ? mode : 'system'
+const readMode = () => {
+ try { return validMode(localStorage.getItem(KEY)) } catch { return 'system' }
+}
+const ThemeContext = createContext({ mode: 'system', resolvedTheme: 'light', setMode: () => {} })
+
+export function ThemeProvider({ children }) {
+ const [mode, updateMode] = useState(readMode)
+ const [systemDark, setSystemDark] = useState(() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false)
+ const resolvedTheme = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
+
+ useLayoutEffect(() => {
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark')
+ document.documentElement.style.colorScheme = resolvedTheme
+ }, [resolvedTheme])
+
+ useEffect(() => {
+ const media = window.matchMedia?.('(prefers-color-scheme: dark)')
+ const onSystem = event => setSystemDark(event.matches)
+ const onStorage = event => {
+ if (event.key === KEY || event.key === null) updateMode(readMode())
+ }
+ media?.addEventListener('change', onSystem)
+ if (media) setSystemDark(media.matches)
+ window.addEventListener('storage', onStorage)
+ return () => {
+ media?.removeEventListener('change', onSystem)
+ window.removeEventListener('storage', onStorage)
+ }
+ }, [])
+
+ function setMode(nextMode) {
+ const next = validMode(nextMode)
+ updateMode(next)
+ try { localStorage.setItem(KEY, next) } catch { /* The session preference still works. */ }
+ }
+
+ return {children}
+}
+
+export const useTheme = () => useContext(ThemeContext)
diff --git a/frontend/src/context/ThemeContext.test.jsx b/frontend/src/context/ThemeContext.test.jsx
new file mode 100644
index 0000000..be6b426
--- /dev/null
+++ b/frontend/src/context/ThemeContext.test.jsx
@@ -0,0 +1,66 @@
+import { act, fireEvent, render, screen, cleanup } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ThemeProvider, useTheme } from './ThemeContext'
+import ThemeSelector from '../components/ThemeSelector'
+
+function Preview() {
+ const { resolvedTheme } = useTheme()
+ return <>{resolvedTheme} >
+}
+
+describe('theme preference', () => {
+ let media, listeners
+ beforeEach(() => {
+ localStorage.clear()
+ listeners = new Set()
+ media = { matches: false, addEventListener: (_, fn) => listeners.add(fn), removeEventListener: (_, fn) => listeners.delete(fn) }
+ vi.stubGlobal('matchMedia', () => media)
+ })
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ localStorage.clear()
+ document.documentElement.classList.remove('dark')
+ document.documentElement.style.colorScheme = ''
+ })
+ const mount = () => render( )
+ const system = matches => act(() => { media.matches = matches; listeners.forEach(fn => fn({ matches })) })
+
+ it('follows the system until an explicit choice is made, persists it, and can resume following', () => {
+ mount()
+ system(true)
+ expect(document.documentElement).toHaveClass('dark')
+ fireEvent.change(screen.getByLabelText('Color theme'), { target: { value: 'light' } })
+ expect(localStorage.getItem('researchos.theme')).toBe('light')
+ system(false); system(true)
+ expect(document.documentElement).not.toHaveClass('dark')
+ fireEvent.change(screen.getByLabelText('Color theme'), { target: { value: 'system' } })
+ expect(document.documentElement.style.colorScheme).toBe('dark')
+ })
+
+ it('restores saved choices and reacts to preference changes in another tab', () => {
+ localStorage.setItem('researchos.theme', 'dark')
+ mount()
+ expect(screen.getByRole('status')).toHaveTextContent('dark')
+ act(() => {
+ localStorage.setItem('researchos.theme', 'light')
+ window.dispatchEvent(new StorageEvent('storage', { key: 'researchos.theme' }))
+ })
+ expect(document.documentElement).not.toHaveClass('dark')
+ expect(screen.getByLabelText('Color theme')).toHaveValue('light')
+ })
+
+ it('falls back to system for invalid preferences and works when storage is blocked', () => {
+ localStorage.setItem('researchos.theme', 'invalid')
+ const view = mount()
+ expect(screen.getByLabelText('Color theme')).toHaveValue('system')
+ view.unmount()
+ expect(listeners.size).toBe(0)
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked') })
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked') })
+ mount()
+ fireEvent.change(screen.getByLabelText('Color theme'), { target: { value: 'dark' } })
+ expect(document.documentElement).toHaveClass('dark')
+ })
+})
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f2aa50f..c8760a6 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,10 +1,54 @@
+@config "../tailwind.config.cjs";
+
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
+ :root {
+ --ui-surface: rgb(var(--surface));
+ --ui-bg: rgb(var(--bg-slate-50));
+ --ui-hover: rgb(var(--bg-slate-100));
+ --ui-border: rgb(var(--line-slate-200));
+ --ui-text: rgb(var(--fg-slate-700));
+ --ui-muted: rgb(var(--fg-slate-500));
+ --ui-accent: rgb(var(--fg-blue-600));
+ --map-bg: #060e1c;
+ --map-grid: #0c2a3e;
+ --map-edge: #010409;
+ --map-panel: 4 10 20;
+ --map-accent: 34 211 238;
+ --map-accent-light: #67e8f9;
+ --map-muted: #94a3b8;
+ --map-faint: #64748b;
+ --map-grid-label: #1e4a6a;
+ }
+ html.dark {
+ --map-bg: #1e1e1e;
+ --map-grid: #383838;
+ --map-edge: #181818;
+ --map-panel: 24 24 24;
+ --map-accent: 96 165 250;
+ --map-accent-light: #93c5fd;
+ --map-muted: #b0b0b0;
+ --map-faint: #a3a3a3;
+ --map-grid-label: #686868;
+ }
body {
font-family: 'Inter', system-ui, sans-serif;
+ background: var(--ui-bg);
+ color: var(--ui-text);
+ }
+ input, textarea, select {
+ color: inherit;
+ background-color: var(--ui-surface);
+ accent-color: var(--ui-accent);
+ }
+ input::placeholder, textarea::placeholder {
+ color: rgb(var(--fg-slate-400));
+ }
+ ::selection {
+ background: rgb(var(--bg-blue-300) / .5);
}
}
@@ -26,19 +70,19 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
- background: #cbd5e1;
+ background: rgb(var(--bg-slate-300));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
- background: #94a3b8;
+ background: rgb(var(--bg-slate-400));
}
/* Dark scrollbar for sidebar */
.dark-scroll::-webkit-scrollbar-thumb {
- background: #475569;
+ background: rgb(var(--bg-slate-600));
}
.dark-scroll::-webkit-scrollbar-thumb:hover {
- background: #64748b;
+ background: rgb(var(--bg-slate-500));
}
/* Log viewer */
@@ -67,7 +111,7 @@
outline: none;
font-size: 0.875rem;
line-height: 1.75;
- color: #334155;
+ color: rgb(var(--fg-slate-700));
min-height: 100%;
}
@@ -78,7 +122,7 @@
.tiptap-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
- color: #94a3b8;
+ color: rgb(var(--fg-slate-400));
pointer-events: none;
height: 0;
}
@@ -86,7 +130,7 @@
.tiptap-editor h1 {
font-size: 1.5rem;
font-weight: 700;
- color: #0f172a;
+ color: rgb(var(--fg-slate-900));
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.3;
@@ -95,7 +139,7 @@
.tiptap-editor h2 {
font-size: 1.25rem;
font-weight: 600;
- color: #1e293b;
+ color: rgb(var(--fg-slate-800));
margin-top: 1.25rem;
margin-bottom: 0.375rem;
line-height: 1.35;
@@ -104,7 +148,7 @@
.tiptap-editor h3 {
font-size: 1rem;
font-weight: 600;
- color: #334155;
+ color: rgb(var(--fg-slate-700));
margin-top: 1rem;
margin-bottom: 0.25rem;
line-height: 1.4;
@@ -152,7 +196,7 @@
}
.tiptap-editor ul[data-type="taskList"] li > label input[type="checkbox"] {
- accent-color: #3b82f6;
+ accent-color: rgb(var(--fg-blue-500));
width: 14px;
height: 14px;
cursor: pointer;
@@ -164,20 +208,20 @@
.tiptap-editor ul[data-type="taskList"] li[data-checked="true"] > div p {
text-decoration: line-through;
- color: #94a3b8;
+ color: rgb(var(--fg-slate-400));
}
.tiptap-editor blockquote {
- border-left: 3px solid #e2e8f0;
+ border-left: 3px solid rgb(var(--line-slate-200));
padding-left: 0.75rem;
- color: #64748b;
+ color: rgb(var(--fg-slate-500));
font-style: italic;
margin: 0.75rem 0;
}
.tiptap-editor code {
- background: #f1f5f9;
- color: #334155;
+ background: rgb(var(--bg-slate-100));
+ color: rgb(var(--fg-slate-700));
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.8em;
@@ -185,9 +229,9 @@
}
.tiptap-editor pre {
- background: #f8fafc;
- color: #334155;
- border: 1px solid #e2e8f0;
+ background: rgb(var(--bg-slate-50));
+ color: rgb(var(--fg-slate-700));
+ border: 1px solid rgb(var(--line-slate-200));
border-radius: 0.5rem;
padding: 0.75rem 1rem;
overflow-x: auto;
@@ -205,18 +249,18 @@
.tiptap-editor hr {
border: none;
- border-top: 1px solid #e2e8f0;
+ border-top: 1px solid rgb(var(--line-slate-200));
margin: 1.25rem 0;
}
.tiptap-editor a {
- color: #3b82f6;
+ color: rgb(var(--fg-blue-500));
text-decoration: underline;
cursor: pointer;
}
.tiptap-editor mark {
- background-color: #fef08a;
+ background-color: rgb(var(--bg-yellow-200));
border-radius: 0.125rem;
padding: 0.0625rem 0;
}
@@ -229,8 +273,8 @@
/* Slash command menu */
.slash-menu {
- background: white;
- border: 1px solid #e2e8f0;
+ background: var(--ui-surface);
+ border: 1px solid rgb(var(--line-slate-200));
border-radius: 0.5rem;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
padding: 0.25rem;
@@ -247,14 +291,14 @@
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
- color: #334155;
+ color: rgb(var(--fg-slate-700));
transition: background-color 0.1s;
text-align: left;
}
.slash-menu button:hover,
.slash-menu button.is-selected {
- background: #f1f5f9;
+ background: rgb(var(--bg-slate-100));
}
.slash-menu button .slash-icon {
@@ -263,8 +307,8 @@
display: flex;
align-items: center;
justify-content: center;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
+ background: rgb(var(--bg-slate-50));
+ border: 1px solid rgb(var(--line-slate-200));
border-radius: 0.25rem;
flex-shrink: 0;
}
@@ -274,14 +318,14 @@
outline: none;
font-size: 0.8125rem;
line-height: 1.5;
- color: #334155;
+ color: rgb(var(--fg-slate-700));
min-height: 1.5em;
}
.copilot-input p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
- color: #94a3b8;
+ color: rgb(var(--fg-slate-400));
pointer-events: none;
height: 0;
}
@@ -320,8 +364,8 @@
font-size: 0.75rem;
padding: 0.125rem 0.3rem;
border-radius: 0.1875rem;
- background: #e2e8f0;
- color: #334155;
+ background: rgb(var(--bg-slate-200));
+ color: rgb(var(--fg-slate-700));
}
/* User message code */
@@ -331,11 +375,11 @@
}
.copilot-message-content pre {
- background: #f1f5f9;
- color: #334155;
+ background: rgb(var(--bg-slate-100));
+ color: rgb(var(--fg-slate-700));
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
- border: 1px solid #e2e8f0;
+ border: 1px solid rgb(var(--line-slate-200));
overflow-x: auto;
margin: 0.375rem 0;
}
@@ -381,13 +425,13 @@
/* Tiptap math node styling */
.tiptap-editor .Tiptap-mathematics-editor {
- background: #f1f5f9;
- border: 1px solid #e2e8f0;
+ background: rgb(var(--bg-slate-100));
+ border: 1px solid rgb(var(--line-slate-200));
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 0.8em;
- color: #6366f1;
+ color: rgb(var(--fg-indigo-500));
}
.tiptap-editor .Tiptap-mathematics-render {
@@ -412,9 +456,9 @@
/* ── Wiki link mark styles ────────────────────────────────────────────────── */
.tiptap-editor .wiki-link {
- color: #6366f1;
- background: #eef2ff;
- border-bottom: 1px solid #a5b4fc;
+ color: rgb(var(--fg-indigo-500));
+ background: rgb(var(--bg-indigo-50));
+ border-bottom: 1px solid rgb(var(--line-indigo-300));
border-radius: 3px;
padding: 0 3px;
cursor: pointer;
@@ -422,28 +466,28 @@
}
.tiptap-editor .wiki-link:hover {
- background: #e0e7ff;
- color: #4f46e5;
- border-bottom-color: #6366f1;
+ background: rgb(var(--bg-indigo-100));
+ color: rgb(var(--fg-indigo-600));
+ border-bottom-color: rgb(var(--line-indigo-500));
}
/* ── Citation chip mark styles ────────────────────────────────────────────── */
.citation-chip {
display: inline;
- background: #eff6ff;
- color: #1d4ed8;
+ background: rgb(var(--bg-blue-50));
+ color: rgb(var(--fg-blue-700));
padding: 1px 6px;
border-radius: 4px;
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
- border: 1px solid #bfdbfe;
+ border: 1px solid rgb(var(--line-blue-200));
transition: background 0.15s, border-color 0.15s;
}
.citation-chip:hover {
- background: #dbeafe;
- border-color: #93c5fd;
+ background: rgb(var(--bg-blue-100));
+ border-color: rgb(var(--line-blue-300));
}
/* ── Wiki link autocomplete popup (rendered imperatively outside React tree) */
@@ -452,7 +496,7 @@
}
.wiki-link-popup div:hover {
- background: #f8fafc;
+ background: rgb(var(--bg-slate-50));
}
/* ── Table styles ─────────────────────────────────────────────────────────── */
@@ -463,12 +507,12 @@
font-size: 0.85rem;
overflow: hidden;
border-radius: 0.375rem;
- border: 1px solid #e2e8f0;
+ border: 1px solid rgb(var(--line-slate-200));
}
.tiptap-editor th,
.tiptap-editor td {
- border: 1px solid #e2e8f0;
+ border: 1px solid rgb(var(--line-slate-200));
padding: 6px 12px;
vertical-align: top;
position: relative;
@@ -476,18 +520,18 @@
}
.tiptap-editor th {
- background: #f8fafc;
+ background: rgb(var(--bg-slate-50));
font-weight: 600;
- color: #1e293b;
+ color: rgb(var(--fg-slate-800));
text-align: left;
}
.tiptap-editor td {
- color: #334155;
+ color: rgb(var(--fg-slate-700));
}
.tiptap-editor tr:hover td {
- background: #f8fafc;
+ background: rgb(var(--bg-slate-50));
}
/* Selected cell highlight */
@@ -507,7 +551,7 @@
top: 0;
bottom: 0;
width: 4px;
- background: #6366f1;
+ background: rgb(var(--bg-indigo-500));
cursor: col-resize;
opacity: 0;
transition: opacity 0.15s;
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index b9a1a6d..4e94ef6 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -2,9 +2,10 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
+import { ThemeProvider } from './context/ThemeContext.jsx'
createRoot(document.getElementById('root')).render(
-
+
,
)
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 5f495d2..227ff55 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -393,11 +393,11 @@ export default function Dashboard() {
-
-
-
+
+
+
{
const nameMap = {
papersTotal: 'Papers', reposTotal: 'Repos', websitesTotal: 'Websites',
@@ -406,7 +406,7 @@ export default function Dashboard() {
const label = nameMap[name] ?? (chartMode === 'cumulative' ? `Total ${cfg.label}` : `${cfg.label} Added`)
return [value, label]
}}
- labelStyle={{ fontWeight: 600, color: '#1e293b', marginBottom: 4 }}
+ labelStyle={{ fontWeight: 600, color: 'rgb(var(--fg-slate-800))', marginBottom: 4 }}
/>
{chartItemType === 'all' && chartSplit === 'separate' ? (
<>
diff --git a/frontend/src/pages/GitHubRepo.jsx b/frontend/src/pages/GitHubRepo.jsx
index 3617df7..2cff8b9 100644
--- a/frontend/src/pages/GitHubRepo.jsx
+++ b/frontend/src/pages/GitHubRepo.jsx
@@ -424,7 +424,7 @@ export default function GitHubRepo() {
className="flex flex-col overflow-hidden"
style={isNotes
? { width: leftWidth ?? '55%', minWidth: 240, flexShrink: 0 }
- : { flex: 1, borderRight: '1px solid #e2e8f0' }}
+ : { flex: 1, borderRight: '1px solid var(--ui-border)' }}
>
{/* Toolbar */}
diff --git a/frontend/src/pages/Labs.jsx b/frontend/src/pages/Labs.jsx
index 4063c00..321dbd4 100644
--- a/frontend/src/pages/Labs.jsx
+++ b/frontend/src/pages/Labs.jsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import WindowModal from '../components/WindowModal'
import AuthorChip from '../components/AuthorChip'
+import ProfileIcon from '../components/ProfileIcon'
import { labsApi } from '../services/api'
const INPUT = 'w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/30'
@@ -302,7 +303,7 @@ function LabDetail({ id, onDeleted, backTo }) {
{lab.data.name}
{lab.data.description &&
{lab.data.description}
}
- {lab.data.websites?.length ? {lab.data.websites.map(url => {url} )} : No websites added. Use Edit lab to add links.
}
+ {lab.data.websites?.length ? {lab.data.websites.map(url => {url} )} : No websites added. Use Edit lab to add links.
}
setEditing(true)}> { setError(null); setDeleting(true) }}>
@@ -405,7 +406,7 @@ function LabsDirectory() {
{lab.paperCount ?? 0} {lab.paperCount === 1 ? 'paper' : 'papers'}
{!!lab.websites?.length &&
-
+
}
diff --git a/frontend/src/pages/LibraryMap.jsx b/frontend/src/pages/LibraryMap.jsx
index 7c7ba9b..f0260e1 100644
--- a/frontend/src/pages/LibraryMap.jsx
+++ b/frontend/src/pages/LibraryMap.jsx
@@ -135,13 +135,13 @@ export default function LibraryMap() {
const pat = defs.append('pattern')
.attr('id', 'dot-map').attr('width', 28).attr('height', 28)
.attr('patternUnits', 'userSpaceOnUse')
- pat.append('circle').attr('cx', 1).attr('cy', 1).attr('r', 0.9).attr('fill', '#0c2a3e')
+ pat.append('circle').attr('cx', 1).attr('cy', 1).attr('r', 0.9).attr('fill', 'var(--map-grid)')
// Radial vignette
const vig = defs.append('radialGradient').attr('id', 'vignette')
.attr('cx', '50%').attr('cy', '50%').attr('r', '52%')
- vig.append('stop').attr('offset', '0%').attr('stop-color', '#060e1c').attr('stop-opacity', 0)
- vig.append('stop').attr('offset', '100%').attr('stop-color', '#010409').attr('stop-opacity', 1)
+ vig.append('stop').attr('offset', '0%').attr('stop-color', 'var(--map-bg)').attr('stop-opacity', 0)
+ vig.append('stop').attr('offset', '100%').attr('stop-color', 'var(--map-edge)').attr('stop-opacity', 1)
// Neon glow filter (multi-pass bloom)
const flt = defs.append('filter').attr('id', 'glow')
@@ -166,13 +166,13 @@ export default function LibraryMap() {
// Scan line gradient
const sg = defs.append('linearGradient').attr('id', 'scan-grad')
sg.append('stop').attr('offset', '0%').attr('stop-color', 'transparent')
- sg.append('stop').attr('offset', '25%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.5)
- sg.append('stop').attr('offset', '50%').attr('stop-color', '#67e8f9').attr('stop-opacity', 0.8)
- sg.append('stop').attr('offset', '75%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.5)
+ sg.append('stop').attr('offset', '25%').attr('stop-color', 'rgb(var(--map-accent))').attr('stop-opacity', 0.5)
+ sg.append('stop').attr('offset', '50%').attr('stop-color', 'var(--map-accent-light)').attr('stop-opacity', 0.8)
+ sg.append('stop').attr('offset', '75%').attr('stop-color', 'rgb(var(--map-accent))').attr('stop-opacity', 0.5)
sg.append('stop').attr('offset', '100%').attr('stop-color', 'transparent')
// ── Background layers ──────────────────────────────────────────────────────
- svg.append('rect').attr('width', width).attr('height', height).attr('fill', '#060e1c')
+ svg.append('rect').attr('width', width).attr('height', height).attr('fill', 'var(--map-bg)')
svg.append('rect').attr('width', width).attr('height', height).attr('fill', 'url(#dot-map)')
svg.append('rect').attr('width', width).attr('height', height).attr('fill', 'url(#vignette)')
@@ -184,13 +184,13 @@ export default function LibraryMap() {
`M ${P+B},${height-P} L ${P},${height-P} L ${P},${height-P-B}`,
`M ${width-P-B},${height-P} L ${width-P},${height-P} L ${width-P},${height-P-B}`,
].forEach(d => svg.append('path').attr('d', d)
- .attr('fill', 'none').attr('stroke', '#22d3ee')
+ .attr('fill', 'none').attr('stroke', 'rgb(var(--map-accent))')
.attr('stroke-width', 1.5).attr('opacity', 0.35))
// ── Zoom level readout ─────────────────────────────────────────────────────
const zoomLabel = svg.append('text')
.attr('x', width - P - 4).attr('y', height - P - 4)
- .attr('text-anchor', 'end') .attr('fill', '#1e4a6a')
+ .attr('text-anchor', 'end') .attr('fill', 'var(--map-grid-label)')
.attr('font-size', '10').attr('font-family', 'monospace')
.attr('letter-spacing', '0.06em').text('1.0×')
@@ -304,12 +304,12 @@ export default function LibraryMap() {
const tip = document.createElement('div')
Object.assign(tip.style, {
position: 'fixed', pointerEvents: 'none',
- background: 'rgba(4,10,20,0.97)',
- border: '1px solid rgba(34,211,238,0.3)',
+ background: 'rgb(var(--map-panel) / 0.97)',
+ border: '1px solid rgb(var(--map-accent) / 0.3)',
borderRadius: '6px', padding: '9px 13px',
fontSize: '12px', color: '#e2e8f0', maxWidth: '240px',
opacity: '0', zIndex: '9999', transition: 'opacity 0.1s',
- lineHeight: '1.5', boxShadow: '0 0 24px rgba(34,211,238,0.12), 0 8px 32px rgba(0,0,0,0.7)',
+ lineHeight: '1.5', boxShadow: '0 0 24px rgb(var(--map-accent) / 0.12), 0 8px 32px rgba(0,0,0,0.7)',
})
document.body.appendChild(tip)
@@ -342,7 +342,7 @@ export default function LibraryMap() {
const colNames = (d.collections || []).map(id => cm[id]?.name).filter(Boolean).join(', ') || 'Uncollected'
tip.innerHTML =
`${d.title}
` +
- `${TYPE_LABELS[d.itemType] || d.itemType}
` +
+ `${TYPE_LABELS[d.itemType] || d.itemType}
` +
`${colNames}
`
tip.style.opacity = '1'
tip.style.left = `${event.clientX + 15}px`
@@ -415,9 +415,9 @@ export default function LibraryMap() {
gBrush.call(brush)
gBrush.select('.selection')
- .attr('fill', 'rgba(34,211,238,0.07)')
- .attr('stroke', '#22d3ee').attr('stroke-width', 1)
- gBrush.selectAll('.handle').attr('fill', '#22d3ee').attr('fill-opacity', 0.4)
+ .attr('fill', 'rgb(var(--map-accent) / 0.07)')
+ .attr('stroke', 'rgb(var(--map-accent))').attr('stroke-width', 1)
+ gBrush.selectAll('.handle').attr('fill', 'rgb(var(--map-accent))').attr('fill-opacity', 0.4)
}
return () => {
@@ -463,9 +463,9 @@ export default function LibraryMap() {
]
// ── Render ─────────────────────────────────────────────────────────────────
- const CYAN = '#22d3ee'
+ const CYAN = 'rgb(var(--map-accent))'
return (
-
+
{/* CSS keyframes (always in DOM) */}