From e1b0e3cb06c22431d44834ba78022914abc34fd8 Mon Sep 17 00:00:00 2001 From: Prannaya Gupta Date: Fri, 30 Oct 2026 02:04:06 -0400 Subject: [PATCH 01/10] feat(theme): add shared light and dark palettes with reload-safe Tailwind config --- frontend/src/index.css | 156 ++++++++++++++++++++----------- frontend/src/themeConfig.test.js | 41 ++++++++ frontend/tailwind.config.cjs | 31 ++++++ frontend/tailwind.config.js | 21 ----- frontend/themePalette.cjs | 44 +++++++++ 5 files changed, 216 insertions(+), 77 deletions(-) create mode 100644 frontend/src/themeConfig.test.js create mode 100644 frontend/tailwind.config.cjs delete mode 100644 frontend/tailwind.config.js create mode 100644 frontend/themePalette.cjs diff --git a/frontend/src/index.css b/frontend/src/index.css index f2aa50f..a4ca6b5 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: 167 139 250; + --map-accent-light: #c4b5fd; + --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/themeConfig.test.js b/frontend/src/themeConfig.test.js new file mode 100644 index 0000000..4d65f36 --- /dev/null +++ b/frontend/src/themeConfig.test.js @@ -0,0 +1,41 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, writeFileSync, utimesSync, rmSync } from 'node:fs' +import { dirname, join, basename } from 'node:path' +import { fileURLToPath } from 'node:url' +import postcss from 'postcss' +import tailwindcss from 'tailwindcss' +import { expect, it } from 'vitest' + +it('reloads theme configuration and its palette in a running Tailwind process', async () => { + const frontend = fileURLToPath(new URL('../', import.meta.url)) + const directory = mkdtempSync(join(frontend, '.theme-config-test-')) + const configPath = join(directory, 'tailwind.config.cjs') + const palettePath = join(directory, 'themePalette.cjs') + const config = readFileSync(join(frontend, 'tailwind.config.cjs'), 'utf8') + const palette = readFileSync(join(frontend, 'themePalette.cjs'), 'utf8') + const content = "module.exports.content = [{ raw: '
' }];" + try { + writeFileSync(configPath, config + '\n' + content) + writeFileSync(palettePath, palette) + const processor = postcss([tailwindcss(configPath)]) + const compile = () => processor.process('@tailwind base; @tailwind utilities;', { from: join(directory, 'input.css') }) + const initial = (await compile()).css + expect(initial).toContain('--surface: 30 30 30') + expect(initial).toContain('background-color: rgb(var(--surface)') + + // Exercise the same process after edits, rather than starting a fresh build. + writeFileSync(palettePath, palette.replace("'--surface': '30 30 30'", "'--surface': '31 31 31'")) + const nextTime = new Date(Date.now() + 2000) + utimesSync(palettePath, nextTime, nextTime) + expect((await compile()).css).toContain('--surface: 31 31 31') + + writeFileSync(configPath, config + '\n' + content + '\nmodule.exports.theme.extend.backgroundColor.white = "#121212";') + utimesSync(configPath, nextTime, nextTime) + expect((await compile()).css).toMatch(/\.bg-white\s*\{[^}]*rgb\(18 18 18/) + } finally { + // Only remove the isolated directory created by this test. + if (dirname(directory) === dirname(join(frontend, 'placeholder')) && basename(directory).startsWith('.theme-config-test-')) { + rmSync(directory, { recursive: true, force: true }) + } + } +}) diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs new file mode 100644 index 0000000..82272a0 --- /dev/null +++ b/frontend/tailwind.config.cjs @@ -0,0 +1,31 @@ +const { palette, themePlugin } = require('./themePalette.cjs') + +/** @type {import('tailwindcss').Config} */ +// Tailwind 3 invalidates require.cache on changes. CommonJS keeps that working +// on Node versions that otherwise cache these files as native ES modules. +module.exports = { + darkMode: 'class', + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + colors: { + ...palette('fg'), + sidebar: 'rgb(var(--sidebar) / )', + }, + backgroundColor: { ...palette('bg'), white: 'rgb(var(--surface) / )' }, + gradientColorStops: { ...palette('bg'), white: 'rgb(var(--surface) / )' }, + borderColor: { ...palette('line'), DEFAULT: 'rgb(var(--line-gray-200) / )' }, + divideColor: palette('line'), + animation: { + 'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + }, + }, + plugins: [themePlugin], +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index bddd397..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,21 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: { - fontFamily: { - sans: ['Inter', 'system-ui', 'sans-serif'], - }, - colors: { - sidebar: '#1e293b', - }, - animation: { - 'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', - }, - }, - }, - plugins: [], -} diff --git a/frontend/themePalette.cjs b/frontend/themePalette.cjs new file mode 100644 index 0000000..6fb7be0 --- /dev/null +++ b/frontend/themePalette.cjs @@ -0,0 +1,44 @@ +const colors = require('tailwindcss/colors.js') + +// Keep utility variants and opacity modifiers working in both themes. Background, +// foreground and border roles are separate: a dark button is not a text color. +const families = ['slate', 'gray', 'zinc', 'neutral', 'stone', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky'] +const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] +const backgrounds = ['#191919', '#262626', '#303030', '#3b3b3b', '#484848', '#545454', '#454545', '#363636', '#262626', '#1e1e1e', '#141414'] +const foregrounds = ['#fafafa', '#ededed', '#dedede', '#cecece', '#a3a3a3', '#ababab', '#bdbdbd', '#d4d4d4', '#e3e3e3', '#ededed', '#fafafa'] +const borders = ['#292929', '#303030', '#383838', '#484848', '#626262', '#777777', '#888888', '#999999', '#aaaaaa', '#bbbbbb', '#cccccc'] +const rgb = hex => hex.replace('#', '').match(/../g).map(n => parseInt(n, 16)) +const mix = (a, b, weight) => rgb(a).map((n, i) => Math.round(n * (1 - weight) + rgb(b)[i] * weight)).join(' ') +const value = (role, family, shade) => `rgb(var(--${role}-${family}-${shade}) / )` +const palette = role => Object.fromEntries(families.map(family => [family, Object.fromEntries(shades.map(shade => [shade, value(role, family, shade)]))])) + +const lightVariables = { '--surface': '255 255 255', '--sidebar': '30 41 59' } +const darkVariables = { '--surface': '30 30 30', '--sidebar': '24 24 24' } +for (const [index, family] of families.entries()) { + for (const [i, shade] of shades.entries()) { + const original = colors[family][shade] + const accent = family === 'blue' ? colors.violet : colors[family] + for (const role of ['fg', 'bg', 'line']) { + const key = `--${role}-${family}-${shade}` + lightVariables[key] = rgb(original).join(' ') + if (index < 5) { + darkVariables[key] = rgb(({ fg: foregrounds, bg: backgrounds, line: borders })[role][i]).join(' ') + } else if (role === 'bg') { + darkVariables[key] = shade <= 300 ? mix('#1e1e1e', accent[400], [0.10, 0.16, 0.23, 0.30][i]) : rgb(accent[shade]).join(' ') + } else if (role === 'line') { + darkVariables[key] = mix('#1e1e1e', accent[400], shade <= 300 ? 0.35 : 0.65) + } else { + darkVariables[key] = rgb(accent[shade >= 500 ? 300 : shade]).join(' ') + } + } + } +} + +const themePlugin = ({ addBase }) => addBase({ + ':root': lightVariables, + 'html.dark': darkVariables, + // The sidebar is dark in either theme; preserve its translucent white hovers. + '.theme-sidebar': { '--surface': '255 255 255', colorScheme: 'dark' }, +}) + +module.exports = { palette, themePlugin } From 751657e6ea1e6123e29d9aaacca65a7ed4b540b1 Mon Sep 17 00:00:00 2001 From: Prannaya Gupta Date: Fri, 30 Oct 2026 02:04:27 -0400 Subject: [PATCH 02/10] feat(theme): add persistent theme selection and system appearance support --- frontend/e2e/authors-directory.spec.js | 2 +- frontend/index.html | 1 + frontend/public/theme.js | 8 +++ frontend/src/components/ThemeSelector.jsx | 24 ++++++++ frontend/src/components/layout/Sidebar.jsx | 6 +- frontend/src/context/ThemeContext.jsx | 44 +++++++++++++++ frontend/src/context/ThemeContext.test.jsx | 66 ++++++++++++++++++++++ frontend/src/main.jsx | 3 +- frontend/src/pages/LibrarySettings.jsx | 4 ++ 9 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 frontend/public/theme.js create mode 100644 frontend/src/components/ThemeSelector.jsx create mode 100644 frontend/src/context/ThemeContext.jsx create mode 100644 frontend/src/context/ThemeContext.test.jsx 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/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/components/ThemeSelector.jsx b/frontend/src/components/ThemeSelector.jsx new file mode 100644 index 0000000..928ac27 --- /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/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 (