diff --git a/app/package-lock.json b/app/package-lock.json
index ca695a48..6e352768 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -10,13 +10,17 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1127.0",
"@tanstack/react-query": "^5.91.3",
+ "clsx": "^2.1.1",
"lightweight-charts": "^5.2.1",
+ "lucide-react": "^1.42.0",
"next": "^16.2.9",
+ "next-themes": "^0.4.6",
"postgres": "^3.4.9",
"react": "19.2.3",
"react-dom": "19.2.3",
"server-only": "^0.0.1",
"sharp": "^0.35.4",
+ "tailwind-merge": "^3.6.0",
"viem": "^2.47.6",
"wagmi": "^3.5.0"
},
@@ -3359,6 +3363,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5731,6 +5744,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.42.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz",
+ "integrity": "sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5919,6 +5941,16 @@
}
}
},
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -7051,6 +7083,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
diff --git a/app/package.json b/app/package.json
index ca6f517f..e7ae4806 100644
--- a/app/package.json
+++ b/app/package.json
@@ -14,13 +14,17 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1127.0",
"@tanstack/react-query": "^5.91.3",
+ "clsx": "^2.1.1",
"lightweight-charts": "^5.2.1",
+ "lucide-react": "^1.42.0",
"next": "^16.2.9",
+ "next-themes": "^0.4.6",
"postgres": "^3.4.9",
"react": "19.2.3",
"react-dom": "19.2.3",
"server-only": "^0.0.1",
"sharp": "^0.35.4",
+ "tailwind-merge": "^3.6.0",
"viem": "^2.47.6",
"wagmi": "^3.5.0"
},
diff --git a/app/src/app/globals.css b/app/src/app/globals.css
index bd498cdd..82b6f585 100644
--- a/app/src/app/globals.css
+++ b/app/src/app/globals.css
@@ -1,6 +1,13 @@
@import "tailwindcss";
-/* ── "Clear Sky" — light only ─────────────────────────────────────────────── */
+/* ── "Clear Sky" ──────────────────────────────────────────────────────────────
+ LIGHT is the default and the unclassed base: every value in @theme is the
+ original light palette, unchanged. DARK is an explicit choice behind the
+ header toggle: next-themes puts `.dark` on and the toggler flips that
+ same class with classList.toggle("dark"), so "no class" must mean light —
+ never add a `.light` rule. The OS preference is not consulted. */
+@custom-variant dark (&:is(.dark *));
+
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: var(--font-space-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -17,6 +24,7 @@
--color-body: #475569;
--color-muted: #64748b; /* AA on paper/card at body sizes */
--color-faint: #94a3b8; /* decorative / placeholders only */
+ --color-inverse: #ffffff; /* text on ink / brand / up / down fills */
/* accent */
--color-brand: #0052ff;
@@ -33,6 +41,13 @@
--color-warm-ink: #b45309; /* text on warm-soft / paper */
--color-warm-soft: #fffbeb;
+ /* values that used to be hardcoded in components — same light values, now themeable */
+ --color-holder-good-line: #bbf7d0;
+ --color-holder-good-bg: #ecfdf3;
+ --color-skeleton: #ecebe8;
+ --color-skeleton-highlight: #f5f4f1;
+ --color-scrim: #0f172a; /* modal backdrop (rendered at 40%); stays dark in both themes */
+
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 16px rgba(15, 23, 42, 0.04);
--shadow-card-hover: 0 2px 4px rgba(15, 23, 42, 0.06), 0 10px 28px rgba(15, 23, 42, 0.09);
--shadow-sheet: 0 -8px 40px rgba(15, 23, 42, 0.12);
@@ -41,6 +56,64 @@
:root {
color-scheme: light;
+ /* Chart-only tokens live here, not in @theme: Tailwind drops @theme variables
+ that no class references, and the chart reads these with getComputedStyle. */
+ --color-chart-grid: #f1f0ee;
+ --color-chart-vol: #cbd5e1;
+}
+
+/* ── Dark: black ground, alpha ramp of white, near-zero chroma greys. The blue
+ and green brighten because the light values drop under 3:1 on black. ── */
+.dark {
+ color-scheme: dark;
+ --color-paper: #000000;
+ --color-card: #0a0a0a;
+ --color-line: #1f1f1f;
+ --color-line-strong: #333333;
+ --color-ink: #ffffff;
+ --color-body: #b3b3b3; /* white .70 */
+ --color-muted: #8c8c8c; /* white .55 */
+ --color-faint: #666666; /* white .40 */
+ --color-inverse: #000000;
+ --color-brand: #4d8bff;
+ --color-brand-strong: #7aa6ff;
+ --color-brand-soft: #0d1b33;
+ --color-up: #05df72;
+ --color-up-soft: #06241a;
+ --color-down: #ff6b6b;
+ --color-down-ink: #ff8a8a;
+ --color-down-soft: #2a0f12;
+ --color-warm: #fbbf24;
+ --color-warm-ink: #fcd34d;
+ --color-warm-soft: #2a2007;
+ --color-holder-good-line: #0d5233;
+ --color-holder-good-bg: #06241a;
+ --color-skeleton: #141414;
+ --color-skeleton-highlight: #1f1f1f;
+ --color-scrim: #000000;
+ --color-chart-grid: #1a1a1a;
+ --color-chart-vol: #333333;
+ --shadow-card: 0 1px 2px #00000026, 0 4px 16px #00000026;
+ --shadow-card-hover: 0 2px 4px #00000033, 0 10px 28px #00000040;
+ --shadow-sheet: 0 -8px 40px #00000066;
+ --shadow-dialog: 0 24px 64px #00000080;
+}
+
+/* Black paper does not print: a class-based theme survives into the print
+ context, so strip the dark overrides back to ink-on-white. */
+@media print {
+ .dark {
+ color-scheme: light;
+ --color-paper: #ffffff;
+ --color-card: #ffffff;
+ --color-line: #e7e5e4;
+ --color-line-strong: #d6d3d1;
+ --color-ink: #0f172a;
+ --color-body: #475569;
+ --color-muted: #64748b;
+ --color-faint: #94a3b8;
+ --color-inverse: #ffffff;
+ }
}
html {
@@ -119,7 +192,7 @@ input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
-::selection { background: var(--color-brand); color: #fff; }
+::selection { background: var(--color-brand); color: var(--color-inverse); }
:focus-visible {
outline: 2px solid var(--color-brand);
@@ -173,13 +246,13 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.bb-progress-run { opacity: 1; animation: bb-progress-creep 12s cubic-bezier(0.1, 0.6, 0.2, 1) forwards; }
.bb-progress-done { opacity: 0; width: 100%; transition: width 0.2s ease, opacity 0.35s ease 0.15s; }
@keyframes bb-progress-creep { 0% { width: 0; } 10% { width: 35%; } 40% { width: 70%; } 100% { width: 92%; } }
-.bb-skel { background: linear-gradient(90deg, #ecebe8 0%, #f5f4f1 40%, #ecebe8 80%); background-size: 200% 100%; animation: bb-shimmer 1.6s ease-in-out infinite; }
+.bb-skel { background: linear-gradient(90deg, var(--color-skeleton) 0%, var(--color-skeleton-highlight) 40%, var(--color-skeleton) 80%); background-size: 200% 100%; animation: bb-shimmer 1.6s ease-in-out infinite; }
@keyframes bb-shimmer { 0% { background-position: 120% 0; } 100% { background-position: -80% 0; } }
.bb-spin { animation: bb-rotate 0.8s linear infinite; }
@keyframes bb-rotate { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.bb-progress-run { animation: none; width: 60%; }
- .bb-skel { animation: none; background: #ecebe8; }
+ .bb-skel { animation: none; background: var(--color-skeleton); }
.bb-spin { animation-duration: 1.6s; }
}
@@ -192,3 +265,34 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.bb-hot { box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.0); animation: bb-hot 2.8s ease-in-out infinite; }
@keyframes bb-hot { 0%, 100% { box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.0); } 50% { box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.14); } }
@media (prefers-reduced-motion: reduce) { .bb-hot { animation: none; } }
+/* ── animated theme toggle (View Transitions) ─────────────────────────────── */
+/* The toggler drives the reveal itself with a clip-path animation on
+ ::view-transition-new(root), so the browser's default cross-fade has to be
+ switched off or the two fight and the wipe reads as a flash. */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+/* The old snapshot stays put underneath while the new theme is clipped in. */
+::view-transition-old(root) {
+ z-index: 0;
+}
+::view-transition-new(root) {
+ z-index: 1;
+}
+/* Pin the collapsed clip-path from CSS so the new theme is never painted
+ unclipped between the snapshot and the JS animation starting. */
+[data-magicui-theme-vt="active"]::view-transition-new(root) {
+ clip-path: var(--magicui-theme-vt-clip-from);
+ animation-duration: var(--magicui-theme-toggle-vt-duration, 400ms);
+}
+/* Every other animation in this file is disabled under reduced motion; this
+ one has to be too. The toggler also skips startViewTransition entirely. */
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-group(root),
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation: none !important;
+ }
+}
\ No newline at end of file
diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx
index 9b450c14..14c3ab0a 100644
--- a/app/src/app/layout.tsx
+++ b/app/src/app/layout.tsx
@@ -12,6 +12,7 @@ import { launchpad } from "@/lib/launchpad/config";
import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_TLD, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand";
import Mark from "@/components/launchpad/Mark";
import RouteProgress from "@/components/RouteProgress";
+import ThemeProvider from "@/components/ThemeProvider";
import { Suspense } from "react";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
@@ -31,6 +32,8 @@ export const metadata: Metadata = {
twitter: { card: "summary_large_image", site: `@${BRAND_X}`, title: TITLE, description: SOCIAL_DESCRIPTION },
};
+// Light is the default; dark is a class the header toggle adds, never an OS preference, so the
+// pre-CSS hints stay light (as on main). ThemeProvider keeps theme-color in step after a toggle.
export const viewport: Viewport = { themeColor: "#FAFAF8", colorScheme: "light", width: "device-width", initialScale: 1, viewportFit: "cover" };
export default async function RootLayout({ children }: { children: React.ReactNode }) {
@@ -39,8 +42,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const b = launchpad("base");
const r = launchpad("robinhood");
return (
-
+
+
@@ -81,6 +85,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
+
);
diff --git a/app/src/components/ConnectButton.tsx b/app/src/components/ConnectButton.tsx
index d480623f..a16aa879 100644
--- a/app/src/components/ConnectButton.tsx
+++ b/app/src/components/ConnectButton.tsx
@@ -49,7 +49,7 @@ export default function ConnectButton({ block = false }: { block?: boolean }) {
);
}
return (
-