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
ResultValue
Accuracy95%
' }, + { 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 ( + + ) +} 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 (
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) */}