diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json
index 3cd8966..9de5ded 100644
--- a/packages/ui/package-lock.json
+++ b/packages/ui/package-lock.json
@@ -20,6 +20,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.3",
+ "eslint-config-prettier": "^9.1.0",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -2816,6 +2817,19 @@
}
}
},
+ "node_modules/eslint-config-prettier": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
+ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index bb2e5d5..057df64 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -8,22 +8,22 @@
"start": "next start",
"lint": "eslint",
"smoke-test": "bash ../../scripts/smoke-test.sh"
-
},
"dependencies": {
+ "next": "15.5.3",
"react": "19.1.0",
- "react-dom": "19.1.0",
- "next": "15.5.3"
+ "react-dom": "19.1.0"
},
"devDependencies": {
- "typescript": "^5",
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@tailwindcss/postcss": "^4",
- "tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.5.3",
- "@eslint/eslintrc": "^3"
+ "eslint-config-prettier": "^9.1.0",
+ "tailwindcss": "^4",
+ "typescript": "^5"
}
}
diff --git a/packages/ui/src/app/globals.css b/packages/ui/src/app/globals.css
index cd6eef6..d60ad8b 100644
--- a/packages/ui/src/app/globals.css
+++ b/packages/ui/src/app/globals.css
@@ -1,20 +1,73 @@
@import "tailwindcss";
+/* ── Dark theme (default) ── */
:root {
- --background: #080c12;
- --foreground: #e8eaf0;
+ --bg-base: #080c12;
+ --bg-card: rgba(255,255,255,0.04);
+ --bg-card-hover: rgba(255,255,255,0.06);
+ --border-subtle: rgba(255,255,255,0.08);
+ --border-accent: rgba(56,189,248,0.3);
+ --text-primary: rgba(255,255,255,0.90);
+ --text-secondary: rgba(255,255,255,0.50);
+ --text-muted: rgba(255,255,255,0.25);
+ --text-mono: rgba(255,255,255,0.40);
+ --accent-sky: #38bdf8;
+ --accent-sky-dim: rgba(56,189,248,0.1);
+ --accent-purple: #a78bfa;
+ --accent-purple-dim: rgba(167,139,250,0.1);
+ --pill-success-bg: rgba(34,197,94,0.12);
+ --pill-success-text: #86efac;
+ --pill-fail-bg: rgba(239,68,68,0.12);
+ --pill-fail-text: #fca5a5;
+ --pill-warning-bg: rgba(245,158,11,0.12);
+ --pill-warning-text: #fcd34d;
+ --pill-default-bg: rgba(56,189,248,0.12);
+ --pill-default-text: #7dd3fc;
+ --grid-line: rgba(255,255,255,0.025);
+ --glow-sky: rgba(56,189,248,0.06);
+}
+
+/* ── Light theme ── */
+[data-theme="light"] {
+ --bg-base: #f0f4f8;
+ --bg-card: rgba(0,0,0,0.03);
+ --bg-card-hover: rgba(0,0,0,0.05);
+ --border-subtle: rgba(0,0,0,0.08);
+ --border-accent: rgba(2,132,199,0.3);
+ --text-primary: rgba(0,0,0,0.90);
+ --text-secondary: rgba(0,0,0,0.55);
+ --text-muted: rgba(0,0,0,0.30);
+ --text-mono: rgba(0,0,0,0.45);
+ --accent-sky: #0284c7;
+ --accent-sky-dim: rgba(2,132,199,0.08);
+ --accent-purple: #7c3aed;
+ --accent-purple-dim: rgba(124,58,237,0.08);
+ --pill-success-bg: rgba(22,163,74,0.10);
+ --pill-success-text: #15803d;
+ --pill-fail-bg: rgba(220,38,38,0.10);
+ --pill-fail-text: #b91c1c;
+ --pill-warning-bg: rgba(180,83,9,0.10);
+ --pill-warning-text: #92400e;
+ --pill-default-bg: rgba(2,132,199,0.10);
+ --pill-default-text: #0369a1;
+ --grid-line: rgba(0,0,0,0.04);
+ --glow-sky: rgba(2,132,199,0.04);
}
@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
+ --color-background: var(--bg-base);
+ --color-foreground: var(--text-primary);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
}
+html {
+ transition: background-color 0.2s ease, color 0.2s ease;
+}
+
body {
- background: var(--background);
- color: var(--foreground);
+ background: var(--bg-base);
+ color: var(--text-primary);
}
@keyframes animate-in {
@@ -25,10 +78,12 @@ body {
.animate-in {
animation: animate-in 0.25s ease-out forwards;
}
+
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
+
.animate-fadeSlideUp {
animation: fadeSlideUp 0.35s ease forwards;
-}
\ No newline at end of file
+}
diff --git a/packages/ui/src/app/layout.tsx b/packages/ui/src/app/layout.tsx
index d881047..fc75004 100644
--- a/packages/ui/src/app/layout.tsx
+++ b/packages/ui/src/app/layout.tsx
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
import "./globals.css";
import NetworkStatusBanner from "@/components/NetworkStatusBanner";
-// import NetworkStatusBanner from "@/components/NetworkStatusBanner";
const ibmPlexMono = IBM_Plex_Mono({
variable: "--font-mono",
@@ -27,12 +26,19 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
+
+ {/* Prevent flash of wrong theme on initial load */}
+
+
- {" "}
{children}
diff --git a/packages/ui/src/app/page.tsx b/packages/ui/src/app/page.tsx
index eed007b..21a00e1 100644
--- a/packages/ui/src/app/page.tsx
+++ b/packages/ui/src/app/page.tsx
@@ -10,15 +10,19 @@ import WhatWeDecodeSection from '@/components/landing/WhatWeDecodeSection';
export default function LandingPage() {
return (
{/* ── Background layers ── */}
@@ -26,7 +30,7 @@ export default function LandingPage() {
className="fixed inset-0 pointer-events-none opacity-30"
style={{
backgroundImage:
- 'linear-gradient(135deg, rgba(56,189,248,0.03) 25%, transparent 25%), linear-gradient(225deg, rgba(56,189,248,0.03) 25%, transparent 25%)',
+ 'linear-gradient(135deg, var(--glow-sky) 25%, transparent 25%), linear-gradient(225deg, var(--glow-sky) 25%, transparent 25%)',
backgroundSize: '96px 96px',
}}
/>
@@ -34,14 +38,7 @@ export default function LandingPage() {
className="fixed top-0 left-1/2 -translate-x-1/2 w-[900px] h-[500px] pointer-events-none"
style={{
background:
- 'radial-gradient(ellipse at 50% 0%, rgba(56,189,248,0.08) 0%, transparent 65%)',
- }}
- />
-
diff --git a/packages/ui/src/components/AccountResult.tsx b/packages/ui/src/components/AccountResult.tsx
index c9babec..cc6cd20 100644
--- a/packages/ui/src/components/AccountResult.tsx
+++ b/packages/ui/src/components/AccountResult.tsx
@@ -3,9 +3,6 @@ import { Card } from "@/components/Card";
import { Label } from "@/components/Label";
import { Pill } from "@/components/Pill";
import { formatBalance } from "@/lib/utils";
-// import SaveAddressButton from "@/components/addressbook/SaveAddressButton";
-// import QRShareButton from "@/components/QRShareButton";
-// import { useAppShell } from "@/components/AppShellContext";
interface AccountResultProps {
data: AccountExplanation;
@@ -15,12 +12,7 @@ interface AccountResultProps {
onRemoveSaved: () => void;
}
-export function AccountResult({ data, isSaved, savedLabel, onSave, onRemoveSaved }: AccountResultProps) {
-// const { personalise } = useAppShell();
- const shareUrl = typeof window !== "undefined"
- ? `${window.location.origin}/account/${data.address}`
- : "";
-
+export function AccountResult({ data }: AccountResultProps) {
return (
@@ -28,52 +20,45 @@ export function AccountResult({ data, isSaved, savedLabel, onSave, onRemoveSaved
Account
-
+
{data.address}
- {/*
- {data.org_name && (
-
- )}
-
-
-
*/}
{/* summary */}
Summary
- {data.summary}
+
+ {data.summary}
+
{/* stats grid */}
XLM Balance
-
+
{formatBalance(data.xlm_balance)}
- XLM
+ XLM
Other Assets
- {data.asset_count}
- trust lines
+
+ {data.asset_count}
+
+ trust lines
Signers
- {data.signer_count}
- keys
+
+ {data.signer_count}
+
+ keys
@@ -81,7 +66,9 @@ export function AccountResult({ data, isSaved, savedLabel, onSave, onRemoveSaved
{data.home_domain && (
Home Domain
- {data.home_domain}
+
+ {data.home_domain}
+
)}
diff --git a/packages/ui/src/components/AddressChip.tsx b/packages/ui/src/components/AddressChip.tsx
index 37304ef..ff7527a 100644
--- a/packages/ui/src/components/AddressChip.tsx
+++ b/packages/ui/src/components/AddressChip.tsx
@@ -7,7 +7,12 @@ interface AddressChipProps {
export function AddressChip({ addr }: AddressChipProps) {
return (
{shortAddr(addr)}
diff --git a/packages/ui/src/components/AppShell.tsx b/packages/ui/src/components/AppShell.tsx
index 5c0a370..715af30 100644
--- a/packages/ui/src/components/AppShell.tsx
+++ b/packages/ui/src/components/AppShell.tsx
@@ -2,14 +2,12 @@
import { AppShellContext, AppShellContextValue } from "./AppShellContext";
import StarLogo from "@/components/StarLogo";
-
+import { ThemeToggle } from "@/components/ThemeToggle";
interface Props {
children: React.ReactNode;
}
-// Minimal AppShell — provides context with stub values.
-// Replace this file progressively as UI #21, #22, #24 are implemented.
export default function AppShell({ children }: Props) {
const contextValue: AppShellContextValue = {
addEntry: () => {},
@@ -27,15 +25,19 @@ export default function AppShell({ children }: Props) {
return (
{/* Grid background */}
@@ -45,31 +47,43 @@ export default function AppShell({ children }: Props) {
className="fixed top-0 left-1/2 -translate-x-1/2 w-[700px] h-[300px] pointer-events-none"
style={{
background:
- "radial-gradient(ellipse at 50% 0%, rgba(56,189,248,0.06) 0%, transparent 70%)",
+ "radial-gradient(ellipse at 50% 0%, var(--glow-sky) 0%, transparent 70%)",
}}
/>
{/* App header */}
{/* Page content */}
diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx
index 3603ba5..3712a9e 100644
--- a/packages/ui/src/components/Card.tsx
+++ b/packages/ui/src/components/Card.tsx
@@ -6,9 +6,13 @@ interface CardProps {
export function Card({ children, className = "" }: CardProps) {
return (
{children}
);
-}
\ No newline at end of file
+}
diff --git a/packages/ui/src/components/Label.tsx b/packages/ui/src/components/Label.tsx
index 0f52c3f..8ec704c 100644
--- a/packages/ui/src/components/Label.tsx
+++ b/packages/ui/src/components/Label.tsx
@@ -4,7 +4,10 @@ interface LabelProps {
export function Label({ children }: LabelProps) {
return (
-
+
{children}
);
diff --git a/packages/ui/src/components/Pill.tsx b/packages/ui/src/components/Pill.tsx
index d52c3ae..7166b5b 100644
--- a/packages/ui/src/components/Pill.tsx
+++ b/packages/ui/src/components/Pill.tsx
@@ -5,19 +5,36 @@ interface PillProps {
variant?: PillVariant;
}
-const variantClasses: Record = {
- success: "bg-emerald-900/40 text-emerald-300 border-emerald-700/50",
- fail: "bg-red-900/40 text-red-300 border-red-700/50",
- warning: "bg-amber-900/40 text-amber-300 border-amber-700/50",
- default: "bg-sky-900/40 text-sky-300 border-sky-700/50",
+const variantStyles: Record = {
+ success: {
+ background: "var(--pill-success-bg)",
+ color: "var(--pill-success-text)",
+ borderColor: "var(--pill-success-bg)",
+ },
+ fail: {
+ background: "var(--pill-fail-bg)",
+ color: "var(--pill-fail-text)",
+ borderColor: "var(--pill-fail-bg)",
+ },
+ warning: {
+ background: "var(--pill-warning-bg)",
+ color: "var(--pill-warning-text)",
+ borderColor: "var(--pill-warning-bg)",
+ },
+ default: {
+ background: "var(--pill-default-bg)",
+ color: "var(--pill-default-text)",
+ borderColor: "var(--pill-default-bg)",
+ },
};
export function Pill({ label, variant = "default" }: PillProps) {
return (
{label}
);
-}
\ No newline at end of file
+}
diff --git a/packages/ui/src/components/SearchBar.tsx b/packages/ui/src/components/SearchBar.tsx
index df4dd95..ba37bb9 100644
--- a/packages/ui/src/components/SearchBar.tsx
+++ b/packages/ui/src/components/SearchBar.tsx
@@ -13,13 +13,7 @@ const PLACEHOLDERS: Record = {
account: "Paste a Stellar account address (G…)",
};
-export function SearchBar({
- tab,
- value,
- loading,
- onChange,
- onSubmit,
-}: SearchBarProps) {
+export function SearchBar({ tab, value, loading, onChange, onSubmit }: SearchBarProps) {
function handleKey(e: React.KeyboardEvent) {
if (e.key === "Enter") onSubmit();
}
@@ -33,16 +27,37 @@ export function SearchBar({
onKeyDown={handleKey}
placeholder={PLACEHOLDERS[tab]}
spellCheck={false}
- className="flex-1 bg-white/4 border border-white/10 rounded-lg px-4 py-3 text-xs font-mono text-white/80 placeholder:text-white/20 outline-none focus:border-sky-500/50 focus:bg-white/5 transition-all"
+ className="flex-1 rounded-lg px-4 py-3 text-xs font-mono outline-none transition-all"
+ style={{
+ background: "var(--bg-card)",
+ border: "1px solid var(--border-subtle)",
+ color: "var(--text-secondary)",
+ }}
+ onFocus={(e) => {
+ e.currentTarget.style.borderColor = "var(--border-accent)";
+ e.currentTarget.style.background = "var(--bg-card-hover)";
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.borderColor = "var(--border-subtle)";
+ e.currentTarget.style.background = "var(--bg-card)";
+ }}
/>
{loading ? (
-
+
loading
) : (
@@ -51,4 +66,4 @@ export function SearchBar({
);
-}
\ No newline at end of file
+}
diff --git a/packages/ui/src/components/TabSwitcher.tsx b/packages/ui/src/components/TabSwitcher.tsx
index 2a7702a..b82cd39 100644
--- a/packages/ui/src/components/TabSwitcher.tsx
+++ b/packages/ui/src/components/TabSwitcher.tsx
@@ -12,20 +12,34 @@ const TABS: { id: Tab; label: string }[] = [
export function TabSwitcher({ active, onChange }: TabSwitcherProps) {
return (
-
+
{TABS.map((t) => (
onChange(t.id)}
- className={`px-4 py-1.5 rounded-md text-xs font-mono transition-all duration-150 ${
+ className="px-4 py-1.5 rounded-md text-xs font-mono transition-all duration-150"
+ style={
active === t.id
- ? "bg-sky-500/20 text-sky-300 border border-sky-500/30"
- : "text-white/35 hover:text-white/60"
- }`}
+ ? {
+ background: "var(--accent-sky-dim)",
+ color: "var(--accent-sky)",
+ border: "1px solid var(--border-accent)",
+ }
+ : {
+ color: "var(--text-muted)",
+ border: "1px solid transparent",
+ }
+ }
>
{t.label}
))}
);
-}
\ No newline at end of file
+}
diff --git a/packages/ui/src/components/ThemeToggle.tsx b/packages/ui/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..a297c4e
--- /dev/null
+++ b/packages/ui/src/components/ThemeToggle.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useTheme } from "@/hooks/useTheme";
+
+export function ThemeToggle() {
+ const { theme, toggleTheme } = useTheme();
+
+ return (
+
+ {theme === "dark" ? "☀️" : "🌙"}
+
+ );
+}
diff --git a/packages/ui/src/components/TransactionResult.tsx b/packages/ui/src/components/TransactionResult.tsx
index af12d86..8af453c 100644
--- a/packages/ui/src/components/TransactionResult.tsx
+++ b/packages/ui/src/components/TransactionResult.tsx
@@ -2,21 +2,13 @@ import type { TransactionExplanation } from "@/types";
import { Card } from "@/components/Card";
import { Label } from "@/components/Label";
import { Pill } from "@/components/Pill";
-import { AddressChip } from "@/components/AddressChip";
import { formatLedgerTime } from "@/lib/utils";
-// import QRShareButton from "@/components/QRShareButton";
-// import { useAppShell } from "@/components/AppShellContext";
interface TransactionResultProps {
data: TransactionExplanation;
}
export function TransactionResult({ data }: TransactionResultProps) {
-// const { personalise } = useAppShell();
- const shareUrl = typeof window !== "undefined"
- ? `${window.location.origin}/tx/${data.transaction_hash}`
- : "";
-
return (
@@ -24,7 +16,10 @@ export function TransactionResult({ data }: TransactionResultProps) {
Transaction
-
+
{data.transaction_hash}
@@ -34,22 +29,17 @@ export function TransactionResult({ data }: TransactionResultProps) {
variant={data.successful ? "success" : "fail"}
/>
{data.skipped_operations > 0 && (
-
+
)}
- {/*
*/}
{/* summary */}
Summary
- {data.summary}
+
+ {data.summary}
+
{/* timeline */}
@@ -58,7 +48,7 @@ export function TransactionResult({ data }: TransactionResultProps) {
{data.ledger_closed_at && (
Confirmed at
-
+
{formatLedgerTime(data.ledger_closed_at)}
@@ -66,7 +56,7 @@ export function TransactionResult({ data }: TransactionResultProps) {
{data.ledger && (
Ledger
-
+
#{data.ledger.toLocaleString()}
@@ -78,7 +68,9 @@ export function TransactionResult({ data }: TransactionResultProps) {
{data.memo_explanation && (
Memo
- {data.memo_explanation}
+
+ {data.memo_explanation}
+
)}
@@ -86,7 +78,9 @@ export function TransactionResult({ data }: TransactionResultProps) {
{data.fee_explanation && (
Fee
- {data.fee_explanation}
+
+ {data.fee_explanation}
+
)}
@@ -96,21 +90,21 @@ export function TransactionResult({ data }: TransactionResultProps) {
Payments ({data.payment_explanations.length})
{data.payment_explanations.map((p, i) => (
- {/* {personalise(p.summary)}
*/}
-
+
Amount
-
+
{p.amount}{" "}
- {p.asset}
+ {p.asset}
diff --git a/packages/ui/src/components/landing/HeroSection.tsx b/packages/ui/src/components/landing/HeroSection.tsx
index 1e9d310..df7e04d 100644
--- a/packages/ui/src/components/landing/HeroSection.tsx
+++ b/packages/ui/src/components/landing/HeroSection.tsx
@@ -8,21 +8,34 @@ function HeroSection() {
{/* left — copy */}
{/* eyebrow */}
-
-
+
+
Stellar Mainnet · Live
Stellar transactions,
- in plain English.
+ in plain English.
-
+
Paste any transaction hash or account address. Get a clear, human-readable explanation
of exactly what happened — no blockchain expertise required.
@@ -31,7 +44,12 @@ function HeroSection() {
Try it now
@@ -59,12 +81,12 @@ function HeroSection() {
-
+
Open source · Built on Stellar Horizon · No login required
- {/* right — animated demo, hard constrained to its column */}
+ {/* right — animated demo */}
diff --git a/packages/ui/src/components/landing/Navbar.tsx b/packages/ui/src/components/landing/Navbar.tsx
index 3a798fc..ac8434a 100644
--- a/packages/ui/src/components/landing/Navbar.tsx
+++ b/packages/ui/src/components/landing/Navbar.tsx
@@ -1,18 +1,33 @@
-import Link from 'next/link'
-import React from 'react'
-import StarLogo from '@/components/StarLogo'
+import Link from 'next/link';
+import StarLogo from '@/components/StarLogo';
+import { ThemeToggle } from '@/components/ThemeToggle';
function Navbar() {
return (
-
+
-
+
Stellar Explain
@@ -23,23 +38,33 @@ function Navbar() {
href="https://github.com/StellarCommons/Stellar-Explain"
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs text-white/40 hover:text-white/70 border border-white/8 hover:border-white/15 transition-all duration-200"
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs transition-all duration-200"
+ style={{
+ color: "var(--text-muted)",
+ border: "1px solid var(--border-subtle)",
+ }}
>
GitHub
+
Open App →
-
- )
+
+ );
}
-export default Navbar
\ No newline at end of file
+export default Navbar;
diff --git a/packages/ui/src/components/landing/UseCasesSection.tsx b/packages/ui/src/components/landing/UseCasesSection.tsx
index 0ce2a9d..fe048b4 100644
--- a/packages/ui/src/components/landing/UseCasesSection.tsx
+++ b/packages/ui/src/components/landing/UseCasesSection.tsx
@@ -89,7 +89,7 @@ export default function UseCasesSection() {
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
- if (entry.isIntersecting && !started) {
+ if (entry?.isIntersecting && !started) {
setVisible(true);
setStarted(true);
}
@@ -110,6 +110,7 @@ export default function UseCasesSection() {
NODES.forEach((node) => {
const d = SEQUENCE_DELAYS[node.id];
+ if (!d) return;
lineTimers.push(
setTimeout(() => {
setLinesVisible((prev) => ({ ...prev, [node.id]: true }));
diff --git a/packages/ui/src/hooks/useTheme.ts b/packages/ui/src/hooks/useTheme.ts
new file mode 100644
index 0000000..e814e11
--- /dev/null
+++ b/packages/ui/src/hooks/useTheme.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+type Theme = "dark" | "light";
+const STORAGE_KEY = "stellar-explain-theme";
+
+export function useTheme(): {
+ theme: Theme;
+ toggleTheme: () => void;
+ setTheme: (theme: Theme) => void;
+} {
+ const [theme, setThemeState] = useState
("dark");
+
+ useEffect(() => {
+ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
+ const resolved: Theme =
+ stored ??
+ (window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light");
+ setThemeState(resolved);
+ document.documentElement.setAttribute("data-theme", resolved);
+ }, []);
+
+ function setTheme(next: Theme) {
+ setThemeState(next);
+ document.documentElement.setAttribute("data-theme", next);
+ localStorage.setItem(STORAGE_KEY, next);
+ }
+
+ function toggleTheme() {
+ setTheme(theme === "dark" ? "light" : "dark");
+ }
+
+ return { theme, toggleTheme, setTheme };
+}