Skip to content

Commit 417f5ad

Browse files
committed
feat: dark mode in dashboard
- @custom-variant dark for class-based toggling (.dark ancestor) - .dark overrides --color-background, --color-grid, --color-muted, --color-faint so semantic tokens flip automatically - Moon/Sun toggle in sidebar header; preference persisted to localStorage - Explicit dark: variants on all hardcoded bg-white, bg-gray-*, text-black elements across sidebar, thread list, chat viewer, action bar, bubbles - Status badges (amber/red/green) get dark:bg-*/border-* variants - MarkdownContent adds dark:prose-invert for readable md in dark mode
1 parent 2069f3f commit 417f5ad

3 files changed

Lines changed: 59 additions & 27 deletions

File tree

src/components/MarkdownContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ interface MarkdownContentProps {
5353

5454
export default function MarkdownContent({ children }: MarkdownContentProps) {
5555
return (
56-
<div className="prose prose-sm max-w-none overflow-hidden [overflow-wrap:break-word]">
56+
<div className="prose prose-sm max-w-none overflow-hidden [overflow-wrap:break-word] dark:prose-invert">
5757
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
5858
{children}
5959
</ReactMarkdown>

src/options/App.tsx

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* ────────────────────────────────────────────── */
99

1010
import React, { useEffect, useRef, useState } from "react";
11-
import { Copy, Check, Share2, Trash2, Search, Inbox, Download, Upload, AlertCircle } from "lucide-react";
11+
import { Copy, Check, Share2, Trash2, Search, Inbox, Download, Upload, AlertCircle, Moon, Sun } from "lucide-react";
1212
import { HacklmLogo } from "../components/HacklmIcon";
1313
import MarkdownContent from "../components/MarkdownContent";
1414
import { stripMarkdown } from "../utils/stripMarkdown";
@@ -67,6 +67,15 @@ export default function App() {
6767
const fileInputRef = useRef<HTMLInputElement>(null);
6868
const [copyMode, setCopyMode] = useState<"md" | "txt">("md");
6969
const [copiedId, setCopiedId] = useState<string | null>(null);
70+
const [dark, setDark] = useState<boolean>(() => {
71+
return localStorage.getItem("theme") === "dark";
72+
});
73+
74+
const toggleDark = () => {
75+
const next = !dark;
76+
setDark(next);
77+
localStorage.setItem("theme", next ? "dark" : "light");
78+
};
7079

7180
const copyMessage = async (id: string, content: string) => {
7281
const text = copyMode === "md" ? content : stripMarkdown(content);
@@ -181,19 +190,27 @@ export default function App() {
181190
};
182191

183192
return (
184-
<div className="flex h-screen bg-background">
193+
<div className={`flex h-screen bg-background${dark ? " dark" : ""}`}>
185194
{/* ── Sidebar ──────────────────────────────────────── */}
186-
<aside className="w-64 shrink-0 bg-white border-r border-grid p-4 flex flex-col gap-4">
187-
<div className="pb-4 border-b border-grid">
195+
<aside className="w-64 shrink-0 bg-white dark:bg-[#1a1a1a] border-r border-grid p-4 flex flex-col gap-4">
196+
<div className="pb-4 border-b border-grid flex items-center justify-between">
188197
<HacklmLogo size={28} />
198+
<button
199+
onClick={toggleDark}
200+
title={dark ? "Switch to light mode" : "Switch to dark mode"}
201+
className="p-1.5 rounded-sm text-faint hover:text-muted hover:bg-gray-100
202+
dark:hover:bg-[#262626] transition"
203+
>
204+
{dark ? <Sun size={14} strokeWidth={2} /> : <Moon size={14} strokeWidth={2} />}
205+
</button>
189206
</div>
190207

191208
<div className="flex flex-col gap-1 text-sm font-mono mt-2">
192209
<button
193210
onClick={() => setFilter("all")}
194211
className={`text-left px-3 py-2 rounded-sm transition ${filter === "all"
195-
? "bg-gray-100 text-black border border-grid"
196-
: "text-muted hover:bg-gray-50 border border-transparent"
212+
? "bg-gray-100 dark:bg-[#262626] text-black dark:text-[#e8e8e8] border border-grid"
213+
: "text-muted hover:bg-gray-50 dark:hover:bg-[#1f1f1f] border border-transparent"
197214
}`}
198215
>
199216
All ({index.length})
@@ -205,8 +222,8 @@ export default function App() {
205222
key={p}
206223
onClick={() => setFilter(p)}
207224
className={`text-left px-3 py-2 rounded-sm transition flex items-center gap-2 ${filter === p
208-
? "bg-gray-100 text-black border border-grid"
209-
: "text-muted hover:bg-gray-50 border border-transparent"
225+
? "bg-gray-100 dark:bg-[#262626] text-black dark:text-[#e8e8e8] border border-grid"
226+
: "text-muted hover:bg-gray-50 dark:hover:bg-[#1f1f1f] border border-transparent"
210227
}`}
211228
>
212229
<span
@@ -226,7 +243,7 @@ export default function App() {
226243
{(() => {
227244
if (!lastExported) {
228245
return (
229-
<div className="flex items-start gap-2 text-xs font-mono text-amber-700 bg-amber-50 border border-amber-200 rounded-sm px-3 py-2">
246+
<div className="flex items-start gap-2 text-xs font-mono text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-sm px-3 py-2">
230247
<AlertCircle size={12} className="mt-0.5 shrink-0" />
231248
<span>No backup yet. Export your data before uninstalling.</span>
232249
</div>
@@ -237,7 +254,7 @@ export default function App() {
237254
);
238255
if (daysSince > 7) {
239256
return (
240-
<div className="flex items-start gap-2 text-xs font-mono text-amber-700 bg-amber-50 border border-amber-200 rounded-sm px-3 py-2">
257+
<div className="flex items-start gap-2 text-xs font-mono text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-sm px-3 py-2">
241258
<AlertCircle size={12} className="mt-0.5 shrink-0" />
242259
<span>Last backup {daysSince}d ago. Consider exporting.</span>
243260
</div>
@@ -277,8 +294,9 @@ export default function App() {
277294
fileInputRef.current?.click();
278295
}}
279296
disabled={importing}
280-
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white border border-grid
281-
hover:bg-gray-50 text-black text-xs font-mono rounded-sm transition disabled:opacity-50"
297+
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-white dark:bg-[#1a1a1a]
298+
border border-grid hover:bg-gray-50 dark:hover:bg-[#1f1f1f]
299+
text-black dark:text-[#e8e8e8] text-xs font-mono rounded-sm transition disabled:opacity-50"
282300
>
283301
<Upload size={12} strokeWidth={2} />
284302
{importing ? "Importing…" : "Import File"}
@@ -288,8 +306,8 @@ export default function App() {
288306
<div
289307
className={`text-[11px] font-mono px-2 py-1.5 rounded-sm border ${
290308
importStatus.error
291-
? "text-red-700 bg-red-50 border-red-200"
292-
: "text-green-700 bg-green-50 border-green-200"
309+
? "text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800"
310+
: "text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/40 border-green-200 dark:border-green-800"
293311
}`}
294312
>
295313
{importStatus.error
@@ -304,7 +322,7 @@ export default function App() {
304322

305323
{/* ── Thread list ──────────────────────────────────── */}
306324
<section className="w-80 shrink-0 border-r border-grid bg-background flex flex-col">
307-
<div className="p-3 border-b border-grid bg-white">
325+
<div className="p-3 border-b border-grid bg-white dark:bg-[#1a1a1a]">
308326
<div className="relative">
309327
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
310328
<input
@@ -335,8 +353,8 @@ export default function App() {
335353
onClick={() => openThread(t.id)}
336354
className={`w-full text-left px-4 py-3 border-b border-grid transition
337355
${active?.id === t.id
338-
? "bg-gray-100"
339-
: "bg-white hover:bg-gray-50"
356+
? "bg-gray-100 dark:bg-[#262626]"
357+
: "bg-white dark:bg-[#1a1a1a] hover:bg-gray-50 dark:hover:bg-[#1f1f1f]"
340358
}`}
341359
>
342360
<div className="font-medium text-sm truncate">{t.title}</div>
@@ -353,7 +371,7 @@ export default function App() {
353371
</section>
354372

355373
{/* ── Chat viewer ──────────────────────────────────── */}
356-
<main className="flex-1 flex flex-col min-w-0 bg-white">
374+
<main className="flex-1 flex flex-col min-w-0 bg-white dark:bg-[#111111]">
357375
{!active ? (
358376
<div className="flex-1 flex items-center justify-center text-muted">
359377
<div className="text-center">
@@ -386,8 +404,8 @@ export default function App() {
386404
</button>
387405
<button
388406
onClick={() => deleteThread(active.id)}
389-
className="px-4 py-2 bg-white border border-grid hover:bg-gray-50 text-black text-xs font-mono
390-
rounded-sm transition tracking-tight flex items-center gap-1.5"
407+
className="px-4 py-2 bg-white dark:bg-[#1a1a1a] border border-grid hover:bg-gray-50 dark:hover:bg-[#1f1f1f]
408+
text-black dark:text-[#e8e8e8] text-xs font-mono rounded-sm transition tracking-tight flex items-center gap-1.5"
391409
>
392410
<Trash2 size={12} strokeWidth={2} /> Delete
393411
</button>
@@ -402,7 +420,7 @@ export default function App() {
402420
className={`px-2 py-0.5 text-[10px] font-mono rounded-sm border transition
403421
${copyMode === "md"
404422
? "border-accent text-accent bg-accent/5"
405-
: "border-grid text-faint hover:border-muted hover:text-muted"}`}
423+
: "border-grid text-faint hover:border-muted hover:text-muted dark:hover:text-[#888]"}`}
406424
>
407425
MD
408426
</button>
@@ -411,7 +429,7 @@ export default function App() {
411429
className={`px-2 py-0.5 text-[10px] font-mono rounded-sm border transition
412430
${copyMode === "txt"
413431
? "border-accent text-accent bg-accent/5"
414-
: "border-grid text-faint hover:border-muted hover:text-muted"}`}
432+
: "border-grid text-faint hover:border-muted hover:text-muted dark:hover:text-[#888]"}`}
415433
>
416434
TXT
417435
</button>
@@ -433,7 +451,8 @@ export default function App() {
433451
onClick={() => copyMessage(m.id, m.content)}
434452
title={`Copy as ${copyMode === "md" ? "markdown" : "plain text"}`}
435453
className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-sm
436-
bg-white border border-grid text-muted hover:text-black hover:border-muted transition"
454+
bg-white dark:bg-[#1a1a1a] border border-grid text-muted
455+
hover:text-black dark:hover:text-[#e8e8e8] hover:border-muted transition"
437456
>
438457
{copiedId === m.id
439458
? <><Check size={10} strokeWidth={2.5} /> Copied</>
@@ -443,7 +462,8 @@ export default function App() {
443462
onClick={() => shareMessage(m.content)}
444463
title="Share message"
445464
className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-sm
446-
bg-white border border-grid text-muted hover:text-black hover:border-muted transition"
465+
bg-white dark:bg-[#1a1a1a] border border-grid text-muted
466+
hover:text-black dark:hover:text-[#e8e8e8] hover:border-muted transition"
447467
>
448468
<Share2 size={10} strokeWidth={2} /> Share
449469
</button>
@@ -452,8 +472,8 @@ export default function App() {
452472
<div
453473
className={`px-5 py-4 rounded-sm text-sm leading-relaxed border border-grid overflow-hidden
454474
${m.role === "user"
455-
? "bg-gray-50 text-black"
456-
: "bg-white text-black"}`}
475+
? "bg-gray-50 dark:bg-[#1f1f1f] text-black dark:text-[#e8e8e8]"
476+
: "bg-white dark:bg-[#1a1a1a] text-black dark:text-[#e8e8e8]"}`}
457477
>
458478
<MarkdownContent>{m.content}</MarkdownContent>
459479
</div>

src/styles/global.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
@import "tailwindcss";
33
@plugin "@tailwindcss/typography";
44

5+
/* Class-based dark mode: applies when an ancestor has class="dark" */
6+
@custom-variant dark (&:where(.dark, .dark *));
7+
58
@theme {
69
--color-background: #fbfbfb;
710
--color-accent: #fb631b;
@@ -20,4 +23,13 @@
2023
body {
2124
@apply bg-background text-black antialiased font-sans;
2225
}
26+
27+
/* Dark token overrides — semantic tokens flip automatically */
28+
.dark {
29+
--color-background: #111111;
30+
--color-grid: #2a2a2a;
31+
--color-muted: #888888;
32+
--color-faint: #555555;
33+
color-scheme: dark;
34+
}
2335
}

0 commit comments

Comments
 (0)