Skip to content

Commit cfe088a

Browse files
feat: implement dark mode with Tailwind dark variant and system preference detection (#338)
- Add theme bootstrap script to layout.tsx for SSR flash prevention - Update body/header with light/dark theme variants - Update InvoiceCard, Skeleton, WalletConnect with dark: variants - Update DashboardClient filter buttons and modals with dark: variants - Update landing page feature cards and text colors - Add base light/dark CSS vars in globals.css Closes #289 Closes #290 Closes #291 Closes #292 Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 7b5782c commit cfe088a

8 files changed

Lines changed: 130 additions & 97 deletions

File tree

src/app/dashboard/loading.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ export default function DashboardLoading() {
88
return (
99
<main className="max-w-3xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
1010
<div className="flex items-center justify-between mb-10 flex-wrap gap-3">
11-
<div className="h-9 w-40 bg-gray-700 rounded animate-pulse" />
12-
<div className="h-11 w-32 bg-gray-700 rounded-lg animate-pulse" />
11+
<div className="h-9 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
12+
<div className="h-11 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />
1313
</div>
1414

1515
<div className="mb-6">
16-
<div className="h-12 w-full bg-gray-800 rounded-lg animate-pulse" />
16+
<div className="h-12 w-full bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse" />
1717
</div>
1818

1919
<div className="flex flex-col gap-4">

src/app/globals.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ body {
4242
max-width: 100%;
4343
}
4444

45+
/* Light mode base */
46+
body {
47+
background-color: #ffffff;
48+
color: #111827;
49+
}
50+
51+
/* Dark mode base */
52+
.dark body {
53+
background-color: #030712;
54+
color: #f3f4f6;
55+
}
56+
4557
* {
4658
transition: color 200ms, background-color 200ms, border-color 200ms;
4759
}

src/app/layout.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ import { SessionLockProvider } from "@/contexts/SessionLockContext";
1616
import CommandPalette from "@/components/CommandPalette";
1717
import { ToastProvider } from "@/contexts/ToastContext";
1818

19+
const themeBootstrap = `
20+
(function () {
21+
try {
22+
var stored = window.localStorage.getItem("theme");
23+
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
24+
var isDark = stored === "dark" || (!stored && prefersDark) || (stored === "system" && prefersDark);
25+
if (isDark) {
26+
document.documentElement.classList.add("dark");
27+
} else {
28+
document.documentElement.classList.remove("dark");
29+
}
30+
} catch (e) {}
31+
})();
32+
`;
33+
1934
const accessibilityBootstrap = `
2035
(function () {
2136
try {
@@ -66,16 +81,53 @@ export default function RootLayout({
6681
}) {
6782
return (
6883
<html lang="en" suppressHydrationWarning>
84+
<Script
85+
id="theme-bootstrap"
86+
strategy="beforeInteractive"
87+
dangerouslySetInnerHTML={{ __html: themeBootstrap }}
88+
/>
6989
<Script
7090
id="accessibility-bootstrap"
7191
strategy="beforeInteractive"
7292
dangerouslySetInnerHTML={{ __html: accessibilityBootstrap }}
7393
/>
74-
<body className="min-h-screen bg-gray-950 text-gray-100 antialiased overflow-x-hidden">
94+
<body className="min-h-screen bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100 antialiased overflow-x-hidden">
7595
<ThemeProvider>
7696
<AccessibilityProvider>
7797
<I18nProvider>
78-
<Navbar />
98+
<header className="sticky top-0 z-40 flex items-center justify-between gap-2 px-4 sm:px-6 py-3 bg-white/80 dark:bg-gray-950/80 backdrop-blur border-b border-gray-200 dark:border-gray-800 min-w-0">
99+
<a href="/" className="font-bold text-base sm:text-lg tracking-tight shrink-0 min-h-11 inline-flex items-center">
100+
StellarSplit
101+
</a>
102+
<a
103+
href="/groups"
104+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
105+
>
106+
Groups
107+
</a>
108+
<a
109+
href="/address-book"
110+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center whitespace-nowrap"
111+
>
112+
<span className="sm:hidden">Contacts</span>
113+
<span className="hidden sm:inline">Address Book</span>
114+
</a>
115+
<a
116+
href="/leaderboard"
117+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
118+
>
119+
Leaderboard
120+
</a>
121+
<a
122+
href="/settings/accessibility"
123+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
124+
>
125+
Accessibility
126+
</a>
127+
<ThemeToggle />
128+
<SimulationModeToggle />
129+
<NotificationCenter />
130+
</header>
79131
<SessionLockProvider>
80132
<ToastProvider>
81133
<SimulationBanner />

src/app/page.tsx

Lines changed: 25 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -26,50 +26,20 @@ export default function HomePage() {
2626
const { t } = useI18n();
2727

2828
return (
29-
<main className="relative overflow-x-hidden">
30-
31-
{/* ── Hero ──────────────────────────────────────────────────── */}
32-
<section className="relative flex flex-col items-center justify-center min-h-[calc(100vh-3.5rem)] px-4 sm:px-6 py-20 text-center">
33-
34-
{/* Ambient radial glow */}
35-
<div
36-
aria-hidden="true"
37-
className="pointer-events-none absolute inset-0 bg-gradient-hero"
38-
/>
39-
{/* Soft grid overlay */}
40-
<div
41-
aria-hidden="true"
42-
className="pointer-events-none absolute inset-0 opacity-[0.04]"
43-
style={{
44-
backgroundImage:
45-
"linear-gradient(rgba(255,255,255,0.4) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.4) 1px, transparent 1px)",
46-
backgroundSize: "48px 48px",
47-
}}
48-
/>
49-
50-
<div className="relative z-10 flex flex-col items-center max-w-3xl w-full">
51-
52-
{/* Eyebrow badge */}
53-
<span className="mb-6 inline-flex items-center gap-2 rounded-full border border-brand-500/30 bg-brand-600/10 px-4 py-1.5 text-xs font-semibold text-brand-300 uppercase tracking-widest">
54-
<span className="h-1.5 w-1.5 rounded-full bg-brand-400 animate-pulse" aria-hidden="true" />
55-
Built on Stellar · Powered by Soroban
56-
</span>
57-
58-
{/* Headline */}
59-
<h1 className="text-h1 sm:text-display font-bold tracking-tight text-white mb-5 leading-tight">
60-
{t("home.headline")
61-
.split("Instantly.")
62-
.map((part, i) =>
63-
i === 0
64-
? <span key="pre">{part}</span>
65-
: [
66-
<span key="instant" className="bg-gradient-brand bg-clip-text text-transparent">
67-
Instantly.
68-
</span>,
69-
<span key="post">{part}</span>,
70-
]
71-
)}
72-
</h1>
29+
<main className="flex flex-col items-center justify-center min-h-screen w-full max-w-full px-4 sm:px-6 py-20 text-center overflow-x-hidden">
30+
{/* Hero */}
31+
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight mb-4">
32+
{t("home.headline")
33+
.split("Instantly.")
34+
.map((part, i) =>
35+
i === 0
36+
? part
37+
: [<span key="instant" className="text-indigo-400">Instantly.</span>, part]
38+
)}
39+
</h1>
40+
<p className="max-w-xl text-lg text-gray-600 dark:text-gray-400 mb-10">
41+
{t("home.description")}
42+
</p>
7343

7444
{/* Sub-headline */}
7545
<p className="text-body-lg text-slate-400 max-w-xl mb-10">
@@ -101,25 +71,21 @@ export default function HomePage() {
10171
className="relative px-4 sm:px-6 pb-24"
10272
>
10373
<h2 id="features-heading" className="sr-only">Features</h2>
104-
105-
<div className="mx-auto grid max-w-4xl grid-cols-1 sm:grid-cols-3 gap-5">
106-
{FEATURES.map(({ icon, titleKey, bodyKey }) => (
107-
<div
108-
key={titleKey}
109-
className="group rounded-2xl border border-white/[0.07] bg-surface-800/60 p-6 hover:border-brand-500/30 hover:shadow-glow-sm transition-all"
110-
>
111-
<div
112-
className="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand-600/15 text-2xl"
113-
aria-hidden="true"
114-
>
115-
{icon}
116-
</div>
117-
<h3 className="text-h3 text-white mb-2">{t(titleKey)}</h3>
118-
<p className="text-small text-slate-400">{t(bodyKey)}</p>
74+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8 max-w-3xl w-full text-left">
75+
{features.map((f) => (
76+
<div key={f.title} className="bg-gray-100 dark:bg-gray-900 rounded-xl p-6">
77+
<div className="text-3xl mb-3" aria-hidden="true">{f.icon}</div>
78+
<h3 className="font-semibold text-lg mb-1">{f.title}</h3>
79+
<p className="text-gray-600 dark:text-gray-400 text-sm">{f.body}</p>
11980
</div>
12081
))}
12182
</div>
12283
</section>
84+
85+
{/* Use cases */}
86+
<p className="mt-16 text-gray-500 dark:text-gray-500 text-sm">
87+
{t("home.useCases")}
88+
</p>
12389
</main>
12490
);
12591
}

src/components/DashboardClient.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export default function DashboardClient() {
215215
{!multiSelect && !reminderSelect && pendingInvoices.length > 0 && (
216216
<button
217217
onClick={() => setMultiSelect(true)}
218-
className="min-h-11 px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
218+
className="min-h-11 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
219219
aria-label="Enter multi-select mode to pay multiple invoices"
220220
>
221221
Pay Multiple
@@ -224,7 +224,7 @@ export default function DashboardClient() {
224224
{!multiSelect && !reminderSelect && invoices.length > 0 && (
225225
<button
226226
onClick={() => { setBulkReminderResults(null); setReminderSelect(true); }}
227-
className="min-h-11 px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
227+
className="min-h-11 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
228228
aria-label="Enter multi-select mode to schedule reminders"
229229
>
230230
Schedule Reminders
@@ -234,7 +234,7 @@ export default function DashboardClient() {
234234
<>
235235
<button
236236
onClick={exitMultiSelect}
237-
className="min-h-11 px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
237+
className="min-h-11 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
238238
>
239239
Cancel
240240
</button>
@@ -252,7 +252,7 @@ export default function DashboardClient() {
252252
<>
253253
<button
254254
onClick={exitReminderSelect}
255-
className="min-h-11 px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors"
255+
className="min-h-11 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
256256
>
257257
Cancel
258258
</button>
@@ -301,7 +301,7 @@ export default function DashboardClient() {
301301
className={`rounded-full px-3 py-1.5 text-sm font-semibold transition-colors ${
302302
activePreset === "all"
303303
? "bg-indigo-600 text-white"
304-
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
304+
: "bg-gray-200 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
305305
}`}
306306
aria-pressed={activePreset === "all"}
307307
>
@@ -319,7 +319,7 @@ export default function DashboardClient() {
319319
className={`rounded-full px-3 py-1.5 text-sm font-semibold transition-colors ${
320320
isActive
321321
? "bg-indigo-600 text-white"
322-
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
322+
: "bg-gray-200 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
323323
}`}
324324
aria-pressed={isActive}
325325
>
@@ -334,7 +334,7 @@ export default function DashboardClient() {
334334
<button
335335
type="button"
336336
onClick={clearFilters}
337-
className="rounded-full border border-gray-700 px-3 py-1.5 text-sm font-semibold text-gray-300 transition-colors hover:bg-gray-800"
337+
className="rounded-full border border-gray-300 dark:border-gray-700 px-3 py-1.5 text-sm font-semibold text-gray-600 dark:text-gray-300 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
338338
>
339339
Clear filters
340340
</button>
@@ -363,15 +363,15 @@ export default function DashboardClient() {
363363
</p>
364364
)}
365365
{bulkReminderResults && (
366-
<div className="mb-4 rounded-lg bg-gray-800 border border-gray-700 p-4">
367-
<p className="text-sm font-medium text-gray-300 mb-2">Reminder scheduling results:</p>
366+
<div className="mb-4 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4">
367+
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reminder scheduling results:</p>
368368
<ul className="flex flex-col gap-1">
369369
{bulkReminderResults.map((r) => (
370370
<li key={r.invoiceId} className="text-xs flex items-center gap-2">
371371
<span className={r.success ? "text-green-400" : "text-red-400"}>
372372
{r.success ? "✓" : "✗"}
373373
</span>
374-
<span className="text-gray-300">Invoice #{r.invoiceId}</span>
374+
<span className="text-gray-700 dark:text-gray-300">Invoice #{r.invoiceId}</span>
375375
{!r.success && r.error && (
376376
<span className="text-red-400">{r.error}</span>
377377
)}
@@ -405,7 +405,7 @@ export default function DashboardClient() {
405405
</Link>
406406
</div>
407407
) : visibleInvoices.length === 0 ? (
408-
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-6 text-center">
408+
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/60 p-6 text-center">
409409
<p className="text-gray-400">
410410
{activePreset === "all"
411411
? searchValue.trim()
@@ -526,26 +526,26 @@ export default function DashboardClient() {
526526
aria-label="Schedule bulk reminders"
527527
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
528528
>
529-
<div className="bg-gray-900 rounded-2xl border border-gray-700 p-6 w-full max-w-sm">
529+
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-sm">
530530
<h2 className="text-lg font-bold mb-4">Schedule Reminders</h2>
531-
<p className="text-sm text-gray-400 mb-4">
531+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
532532
Applies to {reminderSelected.size} selected invoice{reminderSelected.size !== 1 ? "s" : ""}.
533533
</p>
534-
<label htmlFor="bulk-reminder-dt" className="block text-sm font-medium text-gray-300 mb-1">
534+
<label htmlFor="bulk-reminder-dt" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
535535
Reminder date &amp; time
536536
</label>
537537
<input
538538
id="bulk-reminder-dt"
539539
type="datetime-local"
540540
value={reminderDateTime}
541541
onChange={(e) => setReminderDateTime(e.target.value)}
542-
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-indigo-500"
542+
className="w-full min-h-11 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg px-4 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-indigo-500"
543543
/>
544544
<div className="flex gap-3 justify-end">
545545
<button
546546
type="button"
547547
onClick={() => { setShowReminderPicker(false); setReminderDateTime(""); }}
548-
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-medium transition-colors"
548+
className="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
549549
>
550550
Cancel
551551
</button>

src/components/InvoiceCard.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,12 @@ export default function InvoiceCard({ invoice, displayNumber }: Props) {
1818
const deadlineLabel = new Date(invoice.deadline * 1000).toLocaleDateString();
1919

2020
return (
21-
<div className="bg-gray-900 rounded-xl p-4 sm:p-5 hover:bg-gray-800 transition-colors cursor-pointer min-w-0 h-full flex flex-col">
22-
<div className="flex items-start justify-between gap-2 mb-3">
23-
<div className="min-w-0">
24-
<span className="text-sm font-semibold text-gray-300 truncate block">
25-
{title ?? `Invoice #${invoice.id}`}
26-
</span>
21+
<div className="bg-gray-100 dark:bg-gray-900 rounded-xl p-4 sm:p-5 hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors cursor-pointer min-w-0">
22+
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
23+
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
24+
Invoice #{invoice.id}
2725
{displayNumber && (
28-
<span className="text-xs font-mono text-indigo-400">
29-
{displayNumber}
30-
</span>
26+
<span className="ml-2 text-xs font-mono text-indigo-600 dark:text-indigo-400">({displayNumber})</span>
3127
)}
3228
</span>
3329
<StatusBadge status={invoice.status as any} size="sm" />
@@ -49,6 +45,13 @@ export default function InvoiceCard({ invoice, displayNumber }: Props) {
4945
{/* Progress — compact (no label) */}
5046
<FundingProgress funded={invoice.funded} total={total} token={invoice.token || "USDC"} compact />
5147

48+
<div className="flex justify-between text-xs text-gray-500 mt-1">
49+
<span>{formatAmount(invoice.funded)} USDC funded</span>
50+
<span>Total: {formatAmount(total)} USDC</span>
51+
</div>
52+
53+
{invoice.deadline > 0 && (
54+
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-200 dark:border-gray-800">
5255
{invoice.deadline > 0 && (
5356
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-800">
5457
<span className="text-xs text-gray-500">Deadline</span>

src/components/Skeleton.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/** Shared animated shimmer base */
2-
const shimmer = "animate-pulse bg-gray-700 rounded";
2+
const shimmer = "animate-pulse bg-gray-200 dark:bg-gray-700 rounded";
33

44
/** Matches InvoiceCard dimensions */
55
export function SkeletonCard() {
66
return (
7-
<div className="bg-gray-900 rounded-xl p-5">
7+
<div className="bg-gray-100 dark:bg-gray-900 rounded-xl p-5">
88
<div className="flex items-center justify-between mb-3">
99
<div className={`${shimmer} h-4 w-24`} />
1010
<div className={`${shimmer} h-4 w-16`} />
@@ -25,7 +25,7 @@ export function SkeletonCard() {
2525
/** Matches a table/list row */
2626
export function SkeletonRow() {
2727
return (
28-
<div className="flex justify-between bg-gray-900 rounded-lg px-4 py-2">
28+
<div className="flex justify-between bg-gray-100 dark:bg-gray-900 rounded-lg px-4 py-2">
2929
<div className={`${shimmer} h-4 w-48`} />
3030
<div className={`${shimmer} h-4 w-20`} />
3131
</div>

0 commit comments

Comments
 (0)