diff --git a/src/app/_fork/page.tsx b/src/app/_fork/page.tsx
index a9d6e64..55454f6 100644
--- a/src/app/_fork/page.tsx
+++ b/src/app/_fork/page.tsx
@@ -591,7 +591,7 @@ vercel
padding: '0.875rem 1.75rem',
background: 'var(--accent-moss)',
border: '1px solid var(--accent-forest)',
- color: 'var(--text-primary)',
+ color: 'var(--text-on-accent)',
textDecoration: 'none',
fontSize: '1rem',
transition: 'all 0.3s ease',
diff --git a/src/app/_integrate/page.tsx b/src/app/_integrate/page.tsx
index cb3ad3f..dfa7849 100644
--- a/src/app/_integrate/page.tsx
+++ b/src/app/_integrate/page.tsx
@@ -213,7 +213,7 @@ aturi.to is a community tool for the ATProto ecosystem. The service is free and
background: copied ? 'var(--accent-moss)' : 'var(--bg-primary)',
border: '1px solid',
borderColor: copied ? 'var(--accent-forest)' : 'var(--border-medium)',
- color: 'var(--text-primary)',
+ color: copied ? 'var(--text-on-accent)' : 'var(--text-primary)',
fontSize: '0.9rem',
cursor: 'pointer',
transition: 'all 0.3s ease',
diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx
index dfa1329..9922288 100644
--- a/src/app/create/page.tsx
+++ b/src/app/create/page.tsx
@@ -182,7 +182,7 @@ export default function CreatePage() {
style={{
padding: '1rem 2rem',
background: input.trim() ? 'var(--accent-moss)' : 'var(--bg-tertiary)',
- color: input.trim() ? 'var(--text-primary)' : 'var(--text-tertiary)',
+ color: input.trim() ? 'var(--text-on-accent)' : 'var(--text-tertiary)',
border: '1px solid',
borderColor: input.trim() ? 'var(--accent-forest)' : 'var(--border-medium)',
fontSize: '1rem',
@@ -295,7 +295,7 @@ export default function CreatePage() {
minWidth: '200px',
padding: '0.875rem 1.5rem',
background: copied ? 'var(--accent-moss)' : 'var(--bg-tertiary)',
- color: 'var(--text-primary)',
+ color: copied ? 'var(--text-on-accent)' : 'var(--text-primary)',
border: '1px solid',
borderColor: copied ? 'var(--accent-forest)' : 'var(--border-strong)',
fontSize: '0.95rem',
diff --git a/src/app/fork/page.tsx b/src/app/fork/page.tsx
index a9d6e64..55454f6 100644
--- a/src/app/fork/page.tsx
+++ b/src/app/fork/page.tsx
@@ -591,7 +591,7 @@ vercel
padding: '0.875rem 1.75rem',
background: 'var(--accent-moss)',
border: '1px solid var(--accent-forest)',
- color: 'var(--text-primary)',
+ color: 'var(--text-on-accent)',
textDecoration: 'none',
fontSize: '1rem',
transition: 'all 0.3s ease',
diff --git a/src/app/globals.css b/src/app/globals.css
index 874bf67..3bf6b32 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -6,32 +6,107 @@
--bg-secondary: #151515;
--bg-tertiary: #1a1a1a;
--bg-elevated: #202020;
-
+
/* Natural, organic accent colors */
--accent-moss: #4a5a3f;
--accent-forest: #2d3a28;
--accent-bark: #3d3329;
--accent-stone: #4a4a46;
-
+
/* Text colors - muted and calm */
--text-primary: #e8e8e6;
--text-secondary: #a8a8a6;
--text-tertiary: #686866;
--text-accent: #8a9a7f;
-
+
/* Ambient glow effects */
--glow-subtle: rgba(138, 154, 127, 0.05);
--glow-medium: rgba(138, 154, 127, 0.1);
--glow-strong: rgba(138, 154, 127, 0.2);
-
+
/* Borders - subtle and organic */
--border-subtle: rgba(232, 232, 230, 0.08);
--border-medium: rgba(232, 232, 230, 0.12);
--border-strong: rgba(232, 232, 230, 0.16);
-
+
+ /* Page-level ambient glow used by body::before. Tuned moodier than the
+ standard glow tokens so the dark backdrop reads atmospheric; overridden
+ in [data-theme='light'] below. */
+ --page-glow-a: rgba(138, 154, 127, 0.25);
+ --page-glow-b: rgba(74, 90, 63, 0.22);
+ --page-glow-c: rgba(138, 154, 127, 0.18);
+
+ /* Translucent floating-element backdrop used behind backdrop-filter blurs
+ (e.g. the scroll-to-choose indicator). Tracks --bg-primary's hue so the
+ overlay blends with the page in both themes. */
+ --bg-overlay: rgba(15, 15, 15, 0.85);
+ --bg-overlay-hover: rgba(18, 18, 18, 0.9);
+ --shadow-overlay: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
+ --shadow-overlay-hover: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
+
+ /* Raw-JSON / preview modals across PostPreview, RecordPreview, and the
+ margin/* previews share the same look. Themed once here so the modals
+ also flip when the user toggles light mode. */
+ --modal-backdrop: rgba(10, 10, 10, 0.75);
+ --modal-bg: linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%);
+ --modal-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle);
+
+ /* Opacity for the global noise overlay. Lower in light mode so paper
+ backgrounds don't look gritty. */
+ --noise-opacity: 0.6;
+
+ /* Text drawn on the dark-green primary buttons / active toggles. The
+ accent backgrounds stay dark in both themes, so this token does too. */
+ --text-on-accent: #f0f0ee;
+
/* Typography scale */
--font-serif: 'Crimson Pro', serif;
--font-mono: 'IBM Plex Mono', monospace;
+
+ color-scheme: dark;
+}
+
+[data-theme='light'] {
+ --bg-primary: #faf8f3;
+ --bg-secondary: #f1ede4;
+ --bg-tertiary: #e8e2d4;
+ --bg-elevated: #ffffff;
+
+ --accent-moss: #6c7c5f;
+ --accent-forest: #4d5a48;
+ --accent-bark: #6e5f4c;
+ --accent-stone: #76766f;
+
+ --text-primary: #1a1c18;
+ --text-secondary: #3e3f3a;
+ --text-tertiary: #5c5d56;
+ --text-accent: #4d5e3f;
+
+ --glow-subtle: rgba(77, 94, 63, 0.05);
+ --glow-medium: rgba(77, 94, 63, 0.1);
+ --glow-strong: rgba(77, 94, 63, 0.18);
+
+ --border-subtle: rgba(26, 28, 24, 0.14);
+ --border-medium: rgba(26, 28, 24, 0.22);
+ --border-strong: rgba(26, 28, 24, 0.32);
+
+ /* Softer page glow so the paper backdrop reads warm but not muddy. */
+ --page-glow-a: rgba(77, 94, 63, 0.14);
+ --page-glow-b: rgba(110, 95, 76, 0.12);
+ --page-glow-c: rgba(77, 94, 63, 0.1);
+
+ --bg-overlay: rgba(250, 248, 243, 0.85);
+ --bg-overlay-hover: rgba(241, 237, 228, 0.92);
+ --shadow-overlay: 0 8px 32px rgba(26, 28, 24, 0.12), 0 0 0 1px rgba(26, 28, 24, 0.04);
+ --shadow-overlay-hover: 0 12px 40px rgba(26, 28, 24, 0.16), 0 0 0 1px rgba(26, 28, 24, 0.06);
+
+ --modal-backdrop: rgba(26, 28, 24, 0.45);
+ --modal-bg: linear-gradient(145deg, rgba(255, 255, 255, 0.98) 0%, rgba(241, 237, 228, 0.99) 100%);
+ --modal-shadow: 0 20px 60px rgba(26, 28, 24, 0.18), 0 0 80px var(--glow-subtle);
+
+ --noise-opacity: 0.25;
+
+ color-scheme: light;
}
* {
@@ -76,10 +151,10 @@ body::before {
width: 140%;
z-index: 0;
pointer-events: none;
- background:
- radial-gradient(circle at 20% 30%, rgba(138, 154, 127, 0.25) 0%, transparent 50%),
- radial-gradient(circle at 80% 70%, rgba(74, 90, 63, 0.22) 0%, transparent 50%),
- radial-gradient(circle at 50% 50%, rgba(138, 154, 127, 0.18) 0%, transparent 60%);
+ background:
+ radial-gradient(circle at 20% 30%, var(--page-glow-a) 0%, transparent 50%),
+ radial-gradient(circle at 80% 70%, var(--page-glow-b) 0%, transparent 50%),
+ radial-gradient(circle at 50% 50%, var(--page-glow-c) 0%, transparent 60%);
filter: blur(90px);
animation: organicBreathe 8s ease-in-out infinite, organicShift 12s ease-in-out infinite;
will-change: transform, opacity, filter;
@@ -451,7 +526,7 @@ body::after {
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 800 800' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.8' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
- opacity: 0.6;
+ opacity: var(--noise-opacity);
pointer-events: none;
z-index: 9999;
}
@@ -537,7 +612,7 @@ html {
/* Selection */
::selection {
background: var(--accent-moss);
- color: var(--text-primary);
+ color: var(--text-on-accent);
}
/* Focus styles */
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c62c86f..97d77fd 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
import Footer from "@/components/Footer";
import PageTransition from "@/components/PageTransition";
+import ThemeSync from "@/components/ThemeSync";
+import { DEFAULT_THEME, THEME_INIT_SCRIPT } from "@/lib/theme";
import "./globals.css";
export const metadata: Metadata = {
@@ -29,15 +31,20 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
{/* Apple TN3156 requires an `application/activity+json` link element
on social-network post pages for rich previews in Messages.
Bluesky's bskyweb base template emits this with an empty href on
EVERY page; we mirror that exactly. */}
+ {/* Apply the saved theme before first paint to avoid a dark/light
+ flash on cold loads. The script reads from localStorage and sets
+ data-theme on synchronously. */}
+
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 98707fe..5cebf39 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -110,7 +110,7 @@ export default function HomePage() {
style={{
padding: '0.875rem 1.5rem',
background: quickInput.trim() ? 'var(--accent-moss)' : 'var(--bg-tertiary)',
- color: quickInput.trim() ? 'var(--text-primary)' : 'var(--text-tertiary)',
+ color: quickInput.trim() ? 'var(--text-on-accent)' : 'var(--text-tertiary)',
border: '1px solid',
borderColor: quickInput.trim() ? 'var(--accent-forest)' : 'var(--border-medium)',
fontSize: '0.9rem',
diff --git a/src/components/DownloadCTA.tsx b/src/components/DownloadCTA.tsx
index d9e4a4d..b69ab2a 100644
--- a/src/components/DownloadCTA.tsx
+++ b/src/components/DownloadCTA.tsx
@@ -71,7 +71,7 @@ export default function DownloadCTA({ variant = 'hero' }: DownloadCTAProps) {
gap: '0.625rem',
padding: '1rem 1.75rem',
background: 'var(--accent-moss)',
- color: 'var(--text-primary)',
+ color: 'var(--text-on-accent)',
border: '1px solid var(--accent-forest)',
fontSize: '1.05rem',
fontWeight: 400,
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index f803f1c..4b48c21 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { Home, Link2, Leaf, UserPlus } from 'lucide-react';
import { motion } from 'framer-motion';
+import ThemeToggle from './ThemeToggle';
interface HeaderProps {
simple?: boolean; // If true, shows a smaller version without the tagline
@@ -170,6 +171,9 @@ export default function Header({ simple = false, compact = false }: HeaderProps)
follow
+
+
+
@@ -256,6 +260,8 @@ export default function Header({ simple = false, compact = false }: HeaderProps)
follow
+ ·
+
);
@@ -354,6 +360,8 @@ export default function Header({ simple = false, compact = false }: HeaderProps)
follow
+ ·
+
);
diff --git a/src/components/RecordPreview.tsx b/src/components/RecordPreview.tsx
index 5c7aace..6afb49d 100644
--- a/src/components/RecordPreview.tsx
+++ b/src/components/RecordPreview.tsx
@@ -238,7 +238,7 @@ export default function RecordPreview({ record, collection, handle, rkey }: Reco
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -255,9 +255,9 @@ export default function RecordPreview({ record, collection, handle, rkey }: Reco
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/ScrollIndicator.tsx b/src/components/ScrollIndicator.tsx
index 1a0b9dd..d2b8277 100644
--- a/src/components/ScrollIndicator.tsx
+++ b/src/components/ScrollIndicator.tsx
@@ -59,7 +59,7 @@ export default function ScrollIndicator({
alignItems: 'center',
gap: '0.5rem',
padding: '1rem 1.75rem 1.25rem',
- background: 'rgba(15, 15, 15, 0.85)',
+ background: 'var(--bg-overlay)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: '1px solid var(--border-subtle)',
@@ -68,21 +68,21 @@ export default function ScrollIndicator({
transition: 'all 0.4s ease',
position: 'relative',
overflow: 'hidden',
- boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03)',
+ boxShadow: 'var(--shadow-overlay)',
}}
onMouseEnter={(e) => {
- e.currentTarget.style.background = 'rgba(18, 18, 18, 0.9)';
+ e.currentTarget.style.background = 'var(--bg-overlay-hover)';
e.currentTarget.style.borderColor = 'var(--border-medium)';
e.currentTarget.style.color = 'var(--text-accent)';
e.currentTarget.style.transform = 'translateY(-4px)';
- e.currentTarget.style.boxShadow = '0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05)';
+ e.currentTarget.style.boxShadow = 'var(--shadow-overlay-hover)';
}}
onMouseLeave={(e) => {
- e.currentTarget.style.background = 'rgba(15, 15, 15, 0.85)';
+ e.currentTarget.style.background = 'var(--bg-overlay)';
e.currentTarget.style.borderColor = 'var(--border-subtle)';
e.currentTarget.style.color = 'var(--text-tertiary)';
e.currentTarget.style.transform = 'translateY(0)';
- e.currentTarget.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03)';
+ e.currentTarget.style.boxShadow = 'var(--shadow-overlay)';
}}
>
{/* Subtle ambient glow */}
diff --git a/src/components/ThemeSync.tsx b/src/components/ThemeSync.tsx
new file mode 100644
index 0000000..c8a9b11
--- /dev/null
+++ b/src/components/ThemeSync.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { useEffect } from 'react';
+import { applyTheme, getStoredTheme } from '@/lib/theme';
+
+/**
+ * Re-applies the saved theme to after React hydrates.
+ * The inline THEME_INIT_SCRIPT already sets the attribute before first
+ * paint to avoid a flash, but React 19's hydration can wipe attributes
+ * on when there's any other mismatch elsewhere in the tree. This
+ * effect runs once on mount to ensure the user's choice sticks.
+ */
+export default function ThemeSync() {
+ useEffect(() => {
+ applyTheme(getStoredTheme());
+ }, []);
+ return null;
+}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..e19572f
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+import { useSyncExternalStore } from 'react';
+import { Moon, Sun } from 'lucide-react';
+import {
+ applyTheme,
+ DEFAULT_THEME,
+ getStoredTheme,
+ isTheme,
+ setStoredTheme,
+ THEME_STORAGE_KEY,
+ type Theme,
+} from '@/lib/theme';
+
+function subscribe(onChange: () => void): () => void {
+ function handler(event: StorageEvent) {
+ if (event.key === THEME_STORAGE_KEY) onChange();
+ }
+ window.addEventListener('storage', handler);
+ return () => window.removeEventListener('storage', handler);
+}
+
+function getSnapshot(): Theme {
+ const attr = document.documentElement.dataset.theme;
+ if (isTheme(attr)) return attr;
+ return getStoredTheme();
+}
+
+function getServerSnapshot(): Theme {
+ return DEFAULT_THEME;
+}
+
+type Variant = 'inline' | 'row';
+
+interface ThemeToggleProps {
+ /**
+ * `inline` is a small icon-only button for use inside the regular nav row.
+ * `row` is a labeled segmented control sized for the compact-mode nav
+ * expansion drawer.
+ */
+ variant?: Variant;
+}
+
+export default function ThemeToggle({ variant = 'inline' }: ThemeToggleProps) {
+ // Read the live `data-theme` attribute (set by THEME_INIT_SCRIPT before
+ // first paint) so the button reflects the saved theme without a hydration
+ // mismatch — useSyncExternalStore returns the server value during SSR
+ // and swaps to the client value after mount in one pass.
+ const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+ function pick(next: Theme) {
+ setStoredTheme(next);
+ applyTheme(next);
+ // Storage events don't fire in the same tab, so nudge subscribers
+ // manually by dispatching a synthetic one.
+ window.dispatchEvent(
+ new StorageEvent('storage', { key: THEME_STORAGE_KEY, newValue: next })
+ );
+ }
+
+ if (variant === 'row') {
+ return (
+
+
+ {theme === 'light' ? : }
+ theme
+
+
+ {(['dark', 'light'] as const).map((value) => {
+ const active = theme === value;
+ return (
+
+ );
+ })}
+
+
+ );
+ }
+
+ const next: Theme = theme === 'dark' ? 'light' : 'dark';
+ return (
+
+ );
+}
diff --git a/src/components/margin/MarginAnnotationPreview.tsx b/src/components/margin/MarginAnnotationPreview.tsx
index ac99ba5..54efaeb 100644
--- a/src/components/margin/MarginAnnotationPreview.tsx
+++ b/src/components/margin/MarginAnnotationPreview.tsx
@@ -302,7 +302,7 @@ export default function MarginAnnotationPreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -319,9 +319,9 @@ export default function MarginAnnotationPreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/margin/MarginBookmarkPreview.tsx b/src/components/margin/MarginBookmarkPreview.tsx
index 2d7964d..e165495 100644
--- a/src/components/margin/MarginBookmarkPreview.tsx
+++ b/src/components/margin/MarginBookmarkPreview.tsx
@@ -229,7 +229,7 @@ export default function MarginBookmarkPreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -246,9 +246,9 @@ export default function MarginBookmarkPreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/margin/MarginCollectionItemPreview.tsx b/src/components/margin/MarginCollectionItemPreview.tsx
index 63754cd..9a00b1e 100644
--- a/src/components/margin/MarginCollectionItemPreview.tsx
+++ b/src/components/margin/MarginCollectionItemPreview.tsx
@@ -218,7 +218,7 @@ export default function MarginCollectionItemPreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -235,9 +235,9 @@ export default function MarginCollectionItemPreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/margin/MarginCollectionPreview.tsx b/src/components/margin/MarginCollectionPreview.tsx
index 010418d..a0d922a 100644
--- a/src/components/margin/MarginCollectionPreview.tsx
+++ b/src/components/margin/MarginCollectionPreview.tsx
@@ -192,7 +192,7 @@ export default function MarginCollectionPreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -209,9 +209,9 @@ export default function MarginCollectionPreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/margin/MarginHighlightPreview.tsx b/src/components/margin/MarginHighlightPreview.tsx
index 23725d7..bf2d7c3 100644
--- a/src/components/margin/MarginHighlightPreview.tsx
+++ b/src/components/margin/MarginHighlightPreview.tsx
@@ -268,7 +268,7 @@ export default function MarginHighlightPreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -285,9 +285,9 @@ export default function MarginHighlightPreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/margin/MarginLikePreview.tsx b/src/components/margin/MarginLikePreview.tsx
index 9cbfc32..ad3dde0 100644
--- a/src/components/margin/MarginLikePreview.tsx
+++ b/src/components/margin/MarginLikePreview.tsx
@@ -184,7 +184,7 @@ export default function MarginLikePreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -201,9 +201,9 @@ export default function MarginLikePreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/components/margin/MarginReplyPreview.tsx b/src/components/margin/MarginReplyPreview.tsx
index 9ab6a44..adbc89c 100644
--- a/src/components/margin/MarginReplyPreview.tsx
+++ b/src/components/margin/MarginReplyPreview.tsx
@@ -193,7 +193,7 @@ export default function MarginReplyPreview({
style={{
position: 'fixed',
inset: 0,
- background: 'rgba(10, 10, 10, 0.75)',
+ background: 'var(--modal-backdrop)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
zIndex: 1000,
@@ -210,9 +210,9 @@ export default function MarginReplyPreview({
width: '100%',
maxWidth: '900px',
maxHeight: '85vh',
- background: 'linear-gradient(145deg, rgba(21, 21, 21, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%)',
+ background: 'var(--modal-bg)',
border: '1px solid var(--border-medium)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 80px var(--glow-subtle)',
+ boxShadow: 'var(--modal-shadow)',
display: 'flex',
flexDirection: 'column',
animation: 'modal-slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..56da10b
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,39 @@
+export type Theme = 'dark' | 'light';
+
+export const THEME_STORAGE_KEY = 'aturi.theme';
+export const DEFAULT_THEME: Theme = 'dark';
+
+export function isTheme(value: unknown): value is Theme {
+ return value === 'dark' || value === 'light';
+}
+
+export function getStoredTheme(): Theme {
+ if (typeof window === 'undefined') return DEFAULT_THEME;
+ try {
+ const raw = window.localStorage.getItem(THEME_STORAGE_KEY);
+ return isTheme(raw) ? raw : DEFAULT_THEME;
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
+
+export function setStoredTheme(theme: Theme): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(THEME_STORAGE_KEY, theme);
+ } catch {
+ // localStorage can throw in private modes; ignore.
+ }
+}
+
+export function applyTheme(theme: Theme): void {
+ if (typeof document === 'undefined') return;
+ document.documentElement.dataset.theme = theme;
+}
+
+/**
+ * Inline script body, stringified for injection into . Runs before
+ * React hydrates so the saved theme is applied without a flash. Falls back
+ * to the dark default when localStorage is unavailable or unparseable.
+ */
+export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('${THEME_STORAGE_KEY}');if(t!=='dark'&&t!=='light')t='${DEFAULT_THEME}';document.documentElement.setAttribute('data-theme',t);}catch(e){document.documentElement.setAttribute('data-theme','${DEFAULT_THEME}');}})();`;