diff --git a/ui/src/index.css b/ui/src/index.css
index 5f24f93..2279f51 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -729,3 +729,37 @@
}
.mdxg-outline a.depth-2 { padding-left: 1.25rem; }
.mdxg-outline a.depth-3 { padding-left: 2rem; font-size: 0.7rem; }
+
+/* ── Dark mode overrides for the MD reader + outline ───────────────────
+ `.mdxg-reader pre` (code blocks) are github-dark already and stay as-is.
+ Body text comes from `prose prose-slate` whose slate scale is var-remapped
+ (auto light in dark). These override only the hardcoded light-mode chrome
+ (#18181b headings, light table/blockquote fills, pink inline code, the
+ outline's light hover/active) that would otherwise be dark-on-dark or a
+ light island in dark mode. */
+[data-theme="dark"] .mdxg-reader h1,
+[data-theme="dark"] .mdxg-reader h2,
+[data-theme="dark"] .mdxg-reader h3,
+[data-theme="dark"] .mdxg-reader h4,
+[data-theme="dark"] .mdxg-reader h5,
+[data-theme="dark"] .mdxg-reader h6,
+[data-theme="dark"] .mdxg-reader strong,
+[data-theme="dark"] .mdxg-reader b { color: var(--ink); }
+[data-theme="dark"] .mdxg-reader h1 { border-bottom-color: var(--slate-700); }
+[data-theme="dark"] .mdxg-reader h2 { border-left-color: var(--slate-700); }
+[data-theme="dark"] .mdxg-reader h3::before { background: var(--slate-700); }
+[data-theme="dark"] .mdxg-reader h1 .mdxg-anchor,
+[data-theme="dark"] .mdxg-reader h2 .mdxg-anchor,
+[data-theme="dark"] .mdxg-reader h3 .mdxg-anchor { color: var(--slate-500); }
+[data-theme="dark"] .mdxg-reader thead th { background: var(--surface-2); border-bottom-color: var(--hairline); }
+[data-theme="dark"] .mdxg-reader tbody td { border-bottom-color: var(--hairline); }
+[data-theme="dark"] .mdxg-reader tbody tr:nth-child(even) { background: var(--surface); }
+[data-theme="dark"] .mdxg-reader blockquote { background: var(--surface); color: var(--muted); }
+[data-theme="dark"] .mdxg-reader :not(pre) > code {
+ background: var(--surface-2);
+ color: #f3a4c0;
+ border-color: var(--hairline);
+}
+[data-theme="dark"] .mdxg-outline a { color: var(--muted); }
+[data-theme="dark"] .mdxg-outline a:hover { background: var(--surface-2); color: var(--ink); }
+[data-theme="dark"] .mdxg-outline a.active { background: var(--surface-2); color: var(--ink); }
diff --git a/ui/src/lib/markdown-text.tsx b/ui/src/lib/markdown-text.tsx
index 49edf5f..88a56a9 100644
--- a/ui/src/lib/markdown-text.tsx
+++ b/ui/src/lib/markdown-text.tsx
@@ -57,7 +57,7 @@ const md = new Marked({ gfm: true, breaks: true, renderer });
// remove any doubt we ALSO use Tailwind's `!` important prefix.
// Result: paragraphs ~4px apart, lines tight, headings reasonable.
const COMPACT_PROSE = [
- 'prose prose-sm prose-slate max-w-none break-words min-w-0',
+ 'prose prose-sm prose-slate dark:prose-invert max-w-none break-words min-w-0',
// overflow-wrap: anywhere ensures unbreakable strings (long URLs, hashes,
// file paths) wrap mid-token. `break-words` alone only honors word
// boundaries which leaves them poking out of the container.
diff --git a/ui/src/lib/theme.test.ts b/ui/src/lib/theme.test.ts
index 0cf3105..e0f6514 100644
--- a/ui/src/lib/theme.test.ts
+++ b/ui/src/lib/theme.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
-import { resolveTheme, isThemePref, readStoredTheme, writeStoredTheme, THEME_STORAGE_KEY } from './theme';
+import { resolveTheme, isThemePref, readStoredTheme, writeStoredTheme, systemPrefersDark, THEME_STORAGE_KEY } from './theme';
describe('resolveTheme', () => {
it('explicit dark/light win regardless of system', () => {
@@ -65,3 +65,13 @@ describe('writeStoredTheme', () => {
expect(() => writeStoredTheme('dark')).not.toThrow();
});
});
+
+describe('systemPrefersDark', () => {
+ afterEach(() => vi.unstubAllGlobals());
+ it('reflects the OS matchMedia result', () => {
+ vi.stubGlobal('window', { matchMedia: () => ({ matches: true }) });
+ expect(systemPrefersDark()).toBe(true);
+ vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) });
+ expect(systemPrefersDark()).toBe(false);
+ });
+});
diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts
index 66fd551..3a195e9 100644
--- a/ui/src/lib/theme.ts
+++ b/ui/src/lib/theme.ts
@@ -38,6 +38,20 @@ export function applyTheme(root: HTMLElement, resolved: ResolvedTheme): void {
const DARK_MQ = '(prefers-color-scheme: dark)';
+/** True if the OS currently prefers a dark color scheme. */
+export function systemPrefersDark(): boolean {
+ return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches;
+}
+
+/**
+ * Persist a new preference and apply it to the document immediately. Used by
+ * the theme toggle UI. The pure resolution lives in resolveTheme.
+ */
+export function setThemePref(pref: ThemePref): void {
+ writeStoredTheme(pref);
+ applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark()));
+}
+
/**
* Wire runtime theme. The INITIAL attribute is set by the inline script in
* index.html (pre-paint, no FOUC); this keeps it live and re-applies when the
diff --git a/ui/src/pages/HelpPage.tsx b/ui/src/pages/HelpPage.tsx
index 82c7a63..d891569 100644
--- a/ui/src/pages/HelpPage.tsx
+++ b/ui/src/pages/HelpPage.tsx
@@ -124,7 +124,7 @@ export function HelpPage({ isAdmin, onAskAi, selectedId, onSelect }: HelpPagePro
diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js
index 2634484..0ac8b18 100644
--- a/ui/tailwind.config.js
+++ b/ui/tailwind.config.js
@@ -12,6 +12,10 @@ import typography from '@tailwindcss/typography';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
+ // Drive the `dark:` variant off our runtime [data-theme="dark"] attribute
+ // (set by index.html's pre-paint script + lib/theme.ts), not the OS media
+ // query — so `dark:prose-invert` follows the in-app theme toggle.
+ darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {