Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/appearance.md
Original file line number Diff line number Diff line change
@@ -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.
File renamed without changes.
2 changes: 1 addition & 1 deletion frontend/e2e/authors-directory.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
118 changes: 118 additions & 0 deletions frontend/e2e/theme.spec.js
Original file line number Diff line number Diff line change
@@ -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: '<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>' },
{ 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([])
})
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="/theme.js"></script>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
Expand Down
8 changes: 8 additions & 0 deletions frontend/public/theme.js
Original file line number Diff line number Diff line change
@@ -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'
})()
3 changes: 2 additions & 1 deletion frontend/src/assets/profile-icons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading