Skip to content
Open
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: 20 additions & 1 deletion packages/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,31 @@
rel="stylesheet"
/>
<style>
body {
/* Prevent flash: apply theme before React hydrates */
html:not(.dark) body {
background-color: #f9f9fb;
}
html.dark body {
background-color: #0f1117;
}
</style>
</head>
<body>
<script>
// Apply saved theme before React renders to prevent flash
(function () {
try {
var theme = localStorage.getItem('archon-theme');
if (theme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
} catch (_) {
document.documentElement.classList.add('dark');
}
})();
</script>
Comment on lines +25 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pre-hydration fallback should respect system theme, not always dark.

Lines 32-37 force dark when there is no saved value (or storage throws), so first paint can be wrong for users with light OS preference.

Suggested fix
       (function () {
+        var isDark;
         try {
           var theme = localStorage.getItem('archon-theme');
-          if (theme === 'light') {
-            document.documentElement.classList.remove('dark');
-          } else {
-            document.documentElement.classList.add('dark');
-          }
+          if (theme === 'light') isDark = false;
+          else if (theme === 'dark') isDark = true;
+          else isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
         } catch (_) {
-          document.documentElement.classList.add('dark');
+          isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
         }
+        document.documentElement.classList.toggle('dark', !!isDark);
       })();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/index.html` around lines 25 - 39, The pre-hydration script
currently forces dark mode when localStorage access fails or no saved value
exists; change the IIFE so after reading 'archon-theme' it only applies dark
when the value is 'dark', removes dark when 'light', and if the value is missing
or storage throws fall back to the system preference via
window.matchMedia('(prefers-color-scheme: dark)').use the existing
document.documentElement.classList.add('dark') and .classList.remove('dark')
calls and the same 'archon-theme' key so the logic in the anonymous function
matches saved preference first, saved 'light'/'dark' explicitly, otherwise
consult matchMedia.

<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
14 changes: 12 additions & 2 deletions packages/web/src/components/layout/TopNav.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { NavLink, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react';
import { LayoutDashboard, MessageSquare, Workflow, Settings, Sun, Moon } from 'lucide-react';
import { listDashboardRuns, getUpdateCheck } from '@/lib/api';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';

const tabs = [
{ to: '/chat', end: false, icon: MessageSquare, label: 'Chat' },
Expand All @@ -12,6 +13,7 @@ const tabs = [
] as const;

export function TopNav(): React.ReactElement {
const { theme, toggleTheme } = useTheme();
// We only need `counts.running` — a server-side aggregate independent of
// the `runs` array. `limit: 1` minimises the `runs` payload that the API
// returns alongside the counts (we discard it).
Expand Down Expand Up @@ -66,7 +68,15 @@ export function TopNav(): React.ReactElement {
)}
</NavLink>
))}
<span className="ml-auto text-xs text-text-secondary">
<span className="ml-auto flex items-center gap-3 text-xs text-text-secondary">
<button
onClick={toggleTheme}
className="rounded-md p-1.5 text-text-secondary hover:text-text-primary hover:bg-surface-hover transition-colors"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
v{import.meta.env.VITE_APP_VERSION as string}
{updateCheck?.updateAvailable && updateCheck.releaseUrl && (
<a
Expand Down
40 changes: 40 additions & 0 deletions packages/web/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react';

type Theme = 'light' | 'dark';

const STORAGE_KEY = 'archon-theme';

function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark';
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
} catch {
// localStorage unavailable (Safari private mode, Firefox privacy settings)
}
return 'dark';
}
Comment thread
fezzzza marked this conversation as resolved.

export function useTheme(): { theme: Theme; toggleTheme: () => void } {
const [theme, setTheme] = useState<Theme>(getInitialTheme);

useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// Storage unavailable or quota exceeded — theme state persists in memory
}
}, [theme]);

const toggleTheme = useCallback(() => {
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
}, []);

return { theme, toggleTheme };
}
64 changes: 61 additions & 3 deletions packages/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,66 @@

@custom-variant dark (&:is(.dark *));

/* Dark-only theme - uses our custom palette as root defaults */
:root {
/* Light theme (default when no .dark class) */
:root:not(.dark) {
/* App-specific colors */
--surface: oklch(0.97 0.005 260);
--surface-elevated: oklch(1 0 0);
--border-bright: oklch(0.78 0.005 260);
--text-primary: oklch(0.15 0.01 260);
--text-secondary: oklch(0.42 0.01 260);
--text-tertiary: oklch(0.6 0.01 260);
--accent-hover: oklch(0.52 0.18 250);
--accent-muted: oklch(0.88 0.06 250);
--surface-inset: oklch(0.93 0.005 260);
--surface-hover: oklch(0.94 0.005 260);
--accent-bright: oklch(0.45 0.18 250);
--success: oklch(0.45 0.17 155);
--warning: oklch(0.55 0.18 75);
--error: oklch(0.5 0.2 25);

/* Node type colors */
--node-command: oklch(0.45 0.18 250);
--node-prompt: oklch(0.42 0.19 290);
--node-bash: oklch(0.55 0.18 75);

/* shadcn variables mapped to light theme */
--radius: 0.625rem;
--background: oklch(0.985 0.002 260);
--foreground: oklch(0.15 0.01 260);
--card: oklch(0.97 0.005 260);
--card-foreground: oklch(0.15 0.01 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0.01 260);
--primary: oklch(0.45 0.18 250);
--primary-foreground: oklch(0.98 0.005 260);
--secondary: oklch(0.94 0.005 260);
--secondary-foreground: oklch(0.15 0.01 260);
--muted: oklch(0.94 0.005 260);
--muted-foreground: oklch(0.42 0.01 260);
--accent: oklch(0.92 0.03 250);
--accent-foreground: oklch(0.15 0.01 260);
--destructive: oklch(0.5 0.2 25);
--border: oklch(0.88 0.005 260);
--input: oklch(0.88 0.005 260);
--ring: oklch(0.45 0.18 250);
--chart-1: oklch(0.45 0.18 250);
--chart-2: oklch(0.45 0.17 155);
--chart-3: oklch(0.55 0.18 75);
--chart-4: oklch(0.5 0.2 25);
--chart-5: oklch(0.42 0.18 250);
--sidebar: oklch(0.97 0.005 260);
--sidebar-foreground: oklch(0.15 0.01 260);
--sidebar-primary: oklch(0.45 0.18 250);
--sidebar-primary-foreground: oklch(0.98 0.005 260);
--sidebar-accent: oklch(0.92 0.03 250);
--sidebar-accent-foreground: oklch(0.15 0.01 260);
--sidebar-border: oklch(0.88 0.005 260);
--sidebar-ring: oklch(0.45 0.18 250);
}

/* Dark theme */
:root.dark {
/* App-specific colors */
--surface: oklch(0.18 0.008 260);
--surface-elevated: oklch(0.22 0.01 260);
Expand Down Expand Up @@ -146,7 +204,7 @@ body {
font-family: var(--font-sans);
}

/* Scrollbar styling for dark theme */
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
Expand Down