Skip to content

Commit a97f7a8

Browse files
feat: Implement a Dark Mode Inspired by Obsidian (#13)
* feat(theme): add shared light and dark palettes with reload-safe Tailwind config * feat(theme): add persistent theme selection and system appearance support * feat(theme): theme note menus, Copilot, and document previews * feat(canvas): apply theme to cards, controls, previews, and PNG exports * feat(theme): adapt charts and research graphs to dark mode * fix(routing): archive legacy proposals mockup that shadows the app * test(theme): cover application appearance and document theme behavior * style(theme): align dark mode accents with light mode blue * fix(theme): improve author and lab icon contrast in dark mode * style(theme): remove focus ring from appearance selector
2 parents 51501dc + 1c4c543 commit a97f7a8

38 files changed

Lines changed: 726 additions & 251 deletions

‎docs/appearance.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Appearance
2+
3+
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.
4+
5+
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.
6+
7+
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.
8+
9+
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.
10+
11+
## Development
12+
13+
`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.
14+
15+
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.
16+
17+
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.
18+
19+
`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.
20+
21+
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.

‎frontend/e2e/authors-directory.spec.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ test('add author suggests frequent names and still accepts custom names after a
6161
await page.goto('/authors')
6262
await page.getByRole('button', { name: 'Add author' }).click()
6363
const input = page.getByRole('combobox', { name: 'Name *' })
64-
await expect(page.getByRole('option')).toHaveText(['Jane Smith8 papers', 'Alex Doe2 papers'])
64+
await expect(page.getByRole('listbox').getByRole('option')).toHaveText(['Jane Smith8 papers', 'Alex Doe2 papers'])
6565
await page.screenshot({ path: testInfo.outputPath('author-suggestions.png') })
6666
await input.fill('alex')
6767
await input.press('ArrowDown')

‎frontend/e2e/theme.spec.js‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
async function fixtures(page) {
4+
const errors = []
5+
page.on('pageerror', error => errors.push(error.message))
6+
const paper = { id: 'paper', title: 'Attention and memory', authors: ['Ada Researcher'], status: 'inbox', year: 2026, venue: 'NeurIPS', collections: [], createdAt: '2026-10-20T12:00:00Z' }
7+
const project = { id: 'project', name: 'Memory research', description: 'A study of attention and memory.', status: 'active' }
8+
const notes = [
9+
{ id: 'note', name: 'Research notes', type: 'file', parentId: null, content: '<h1>Research notes</h1><p>Evidence and ideas.</p><pre><code>const result = 42</code></pre><table><tbody><tr><th>Result</th><th>Value</th></tr><tr><td>Accuracy</td><td>95%</td></tr></tbody></table>' },
10+
{ id: 'canvas', name: 'Ideas.canvas', type: 'canvas', parentId: null, content: JSON.stringify({ nodes: [
11+
{ id: 'text', type: 'text', x: 0, y: 0, width: 300, height: 200, text: '# A connected idea\n\nSupporting evidence.' },
12+
{ id: 'file', type: 'file', x: 400, y: 0, width: 300, height: 200, file: 'Research notes', researchos: { noteId: 'note' } },
13+
], edges: [{ id: 'edge', fromNode: 'text', toNode: 'file', fromSide: 'right', toSide: 'left', label: 'supports' }] }) },
14+
]
15+
await page.route('**/api/**', route => {
16+
const path = new URL(route.request().url()).pathname
17+
let json = []
18+
if (path === '/api/libraries') json = [{ id: 'lib', name: 'Research' }]
19+
if (path === '/api/papers') json = [paper]
20+
if (path === '/api/papers/paper') json = paper
21+
if (path === '/api/libraries/lib/notes') json = notes
22+
if (path === '/api/settings/models') json = { current: {}, defaults: {}, descriptions: {}, available_chat_models: [], available_embedding_models: [] }
23+
if (path === '/api/projects') json = [project]
24+
if (path === '/api/projects/project') json = project
25+
if (path === '/api/projects/project/notes') json = notes
26+
if (path === '/api/projects/project/task-columns') json = [{ id: 'todo', projectId: 'project', name: 'To do', color: '#93c5fd', position: 0 }]
27+
if (path === '/api/projects/project/tasks') json = [{ id: 'task', projectId: 'project', columnId: 'todo', title: 'Review evidence', position: 0, priority: 'medium', tags: [], customFields: {} }]
28+
if (path === '/api/authors') json = [{ id: 'ada', name: 'Ada Researcher', paperCount: 1 }]
29+
if (path === '/api/labs') json = { items: [], total: 0, limit: 50, offset: 0 }
30+
return route.fulfill({ json })
31+
})
32+
return errors
33+
}
34+
35+
test('theme choice persists, follows system changes, and works in the collapsed sidebar', async ({ page, context }) => {
36+
await fixtures(page)
37+
await page.emulateMedia({ colorScheme: 'dark' })
38+
await page.goto('/library')
39+
const selector = page.getByLabel('Color theme')
40+
await expect(selector).toHaveValue('system')
41+
await expect(page.locator('html')).toHaveClass('dark')
42+
await selector.selectOption('light')
43+
await page.reload()
44+
await expect(page.locator('html')).not.toHaveClass('dark')
45+
await expect(selector).toHaveValue('light')
46+
await page.getByTitle('Collapse sidebar').click()
47+
await selector.selectOption('dark')
48+
await expect(page.locator('html')).toHaveClass('dark')
49+
// A second tab shares the saved preference and receives live updates.
50+
const other = await context.newPage()
51+
await fixtures(other)
52+
await other.goto('/authors')
53+
await expect(other.locator('html')).toHaveClass('dark')
54+
await selector.selectOption('light')
55+
await expect(other.locator('html')).not.toHaveClass('dark')
56+
await other.close()
57+
await selector.selectOption('system')
58+
await page.emulateMedia({ colorScheme: 'light' })
59+
await expect(page.locator('html')).not.toHaveClass('dark')
60+
await page.emulateMedia({ colorScheme: 'dark' })
61+
await expect(page.locator('html')).toHaveClass('dark')
62+
})
63+
64+
test('dark surfaces cover the application, dialogs, and charts', async ({ page }, testInfo) => {
65+
test.setTimeout(60000)
66+
const errors = await fixtures(page)
67+
await page.addInitScript(() => localStorage.setItem('researchos.theme', 'dark'))
68+
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']) {
69+
await page.goto(route)
70+
await expect(page.getByLabel('Color theme').first(), route).toBeVisible()
71+
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(25, 25, 25)')
72+
await expect(page.locator('main'), route).not.toBeEmpty()
73+
await expect(page.locator('.theme-sidebar')).toHaveCSS('background-color', 'rgb(24, 24, 24)')
74+
// Check actual computed values, including panels rendered through portals.
75+
for (const panel of await page.locator('.bg-white:visible').all()) {
76+
await expect(panel).toHaveCSS('background-color', 'rgb(30, 30, 30)')
77+
}
78+
if (route === '/dashboard') {
79+
await expect(page.getByRole('heading', { name: /Added Over Time/ })).toBeVisible()
80+
await page.screenshot({ path: testInfo.outputPath('dashboard-dark.png') })
81+
}
82+
if (route === '/projects/project/tasks') {
83+
await expect(page.getByText('Review evidence')).toBeVisible()
84+
await page.screenshot({ path: testInfo.outputPath('tasks-dark.png') })
85+
}
86+
}
87+
await page.goto('/authors')
88+
await page.getByRole('button', { name: 'Add author' }).click()
89+
await expect(page.getByRole('combobox', { name: 'Name *' })).toHaveCSS('background-color', 'rgb(30, 30, 30)')
90+
await page.screenshot({ path: testInfo.outputPath('dialog-dark.png') })
91+
expect(errors).toEqual([])
92+
})
93+
94+
test('notes, graph, and canvas stay legible when switching themes without losing content', async ({ page }, testInfo) => {
95+
const errors = await fixtures(page)
96+
await page.addInitScript(() => localStorage.setItem('researchos.theme', 'dark'))
97+
await page.goto('/library/notes')
98+
await page.getByRole('button', { name: /Research notes/ }).first().click()
99+
await expect(page.locator('.tiptap-editor h1')).toHaveCSS('color', 'rgb(237, 237, 237)')
100+
await expect(page.locator('.tiptap-editor pre')).toHaveCSS('background-color', 'rgb(25, 25, 25)')
101+
await page.screenshot({ path: testInfo.outputPath('notes-dark.png') })
102+
await page.getByRole('button', { name: /Ideas.canvas/ }).first().click()
103+
await expect(page.locator('.canvas-card').first()).toHaveCSS('background-color', 'rgb(30, 30, 30)')
104+
await expect(page.locator('.react-flow__controls-button').first()).toHaveCSS('background-color', 'rgb(30, 30, 30)')
105+
await expect(page.locator('.react-flow__minimap')).toHaveCSS('background-color', 'rgb(30, 30, 30)')
106+
await page.screenshot({ path: testInfo.outputPath('canvas-dark.png') })
107+
await page.getByRole('button', { name: 'File / canvas', exact: true }).click()
108+
await expect(page.getByRole('dialog')).toHaveCSS('background-color', 'rgb(30, 30, 30)')
109+
await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click()
110+
await page.getByLabel('Color theme').selectOption('light')
111+
await expect(page.locator('.canvas-card').first()).toHaveCSS('background-color', 'rgb(255, 255, 255)')
112+
await expect(page.locator('.canvas-card-content').getByText('Supporting evidence.')).toBeVisible()
113+
await page.getByLabel('Color theme').selectOption('dark')
114+
await page.getByRole('button', { name: /Graph/ }).first().click()
115+
await expect(page.locator('main svg text').filter({ hasText: 'Research notes' }).first()).toHaveCSS('fill', 'rgb(212, 212, 212)')
116+
await page.screenshot({ path: testInfo.outputPath('graph-dark.png') })
117+
expect(errors).toEqual([])
118+
})

‎frontend/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />
5+
<script src="/theme.js"></script>
56
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
67
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
78
<link rel="preconnect" href="https://fonts.googleapis.com" />

‎frontend/public/theme.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Runs before the app/styles load so a saved dark preference never flashes white.
2+
;(() => {
3+
let mode = 'system'
4+
try { mode = localStorage.getItem('researchos.theme') || mode } catch { /* Storage may be disabled. */ }
5+
const dark = mode === 'dark' || (mode !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
6+
document.documentElement.classList.toggle('dark', dark)
7+
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
8+
})()

‎frontend/src/assets/profile-icons/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ Simple Icons is distributed under CC0; see LICENSE.simple-icons.md. Brand marks
77

88
SVG fills use the brand colours: ORCID green, Google Scholar blue, LinkedIn blue, and GitHub/X black.
99
OpenReview uses the Material Symbols rate_review icon in red.
10-
Icons are faded at rest and reach full opacity on individual link hover or keyboard focus.
10+
In light mode, icons are faded at rest and reach full opacity on individual link hover or keyboard focus.
11+
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.
1112
Assets are bundled locally; displaying profiles does not contact these services.

0 commit comments

Comments
 (0)