From 51d16b07b4bb2fd92b0965082febb3a6ca4946ff Mon Sep 17 00:00:00 2001 From: euxaristia Date: Mon, 7 Sep 2026 03:36:29 -0400 Subject: [PATCH 1/6] Respect system dark mode while retaining the current design. --- app/src/app/globals.css | 48 +++++++++++++++++-- app/src/app/layout.tsx | 5 +- app/src/components/ConnectButton.tsx | 2 +- app/src/components/HeaderNav.tsx | 2 +- app/src/components/launchpad/HoldersPanel.tsx | 2 +- app/src/components/launchpad/LaunchForm.tsx | 4 +- app/src/components/launchpad/LaunchList.tsx | 6 +-- app/src/components/launchpad/LaunchRow.tsx | 2 +- app/src/components/launchpad/MobileBuyBar.tsx | 2 +- app/src/components/launchpad/PriceChart.tsx | 22 ++++++++- app/src/components/launchpad/TradePanel.tsx | 2 +- app/src/components/theme.test.ts | 46 ++++++++++++++++++ app/src/components/ui.ts | 12 ++--- 13 files changed, 131 insertions(+), 24 deletions(-) create mode 100644 app/src/components/theme.test.ts diff --git a/app/src/app/globals.css b/app/src/app/globals.css index bd498cdd..aa03a711 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1,6 +1,6 @@ @import "tailwindcss"; -/* ── "Clear Sky" — light only ─────────────────────────────────────────────── */ +/* Clear Sky: the existing light palette, with system-driven dark colors. */ @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 +17,11 @@ --color-body: #475569; --color-muted: #64748b; /* AA on paper/card at body sizes */ --color-faint: #94a3b8; /* decorative / placeholders only */ + --color-inverse: #ffffff; + --color-holder-good-line: #bbf7d0; + --color-holder-good-bg: #ecfdf3; + --color-skeleton: #ecebe8; + --color-skeleton-highlight: #f5f4f1; /* accent */ --color-brand: #0052ff; @@ -43,6 +48,41 @@ color-scheme: light; } +/* CSS applies the system preference before hydration and reacts to changes. */ +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --color-paper: #0b1220; + --color-card: #111c2e; + --color-line: #2b3a50; + --color-line-strong: #475569; + --color-ink: #f1f5f9; + --color-body: #cbd5e1; + --color-muted: #9aaec6; + --color-faint: #8296af; + --color-inverse: #0b1220; + --color-brand: #8ab4ff; + --color-brand-strong: #aecbff; + --color-brand-soft: #172b4c; + --color-up: #6ee7a0; + --color-up-soft: #102d22; + --color-down: #fca5a5; + --color-down-ink: #fca5a5; + --color-down-soft: #381c28; + --color-warm: #fbbf24; + --color-warm-ink: #fcd34d; + --color-warm-soft: #332810; + --color-holder-good-line: #286447; + --color-holder-good-bg: #102d22; + --color-skeleton: #1c2a3e; + --color-skeleton-highlight: #293a52; + --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; + } +} + html { background: var(--color-paper); } @@ -119,7 +159,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 +213,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; } } diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 9b450c14..ba13d882 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -31,7 +31,10 @@ export const metadata: Metadata = { twitter: { card: "summary_large_image", site: `@${BRAND_X}`, title: TITLE, description: SOCIAL_DESCRIPTION }, }; -export const viewport: Viewport = { themeColor: "#FAFAF8", colorScheme: "light", width: "device-width", initialScale: 1, viewportFit: "cover" }; +export const viewport: Viewport = { + themeColor: [{ media: "(prefers-color-scheme: light)", color: "#FAFAF8" }, { media: "(prefers-color-scheme: dark)", color: "#0b1220" }], + colorScheme: "light dark", width: "device-width", initialScale: 1, viewportFit: "cover", +}; export default async function RootLayout({ children }: { children: React.ReactNode }) { const usd = await ethUsd(); 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 ( - diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx index 6657acf5..e2ad2cd8 100644 --- a/app/src/components/launchpad/LaunchList.tsx +++ b/app/src/components/launchpad/LaunchList.tsx @@ -296,7 +296,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
{CHAIN_FILTERS.map((c) => ( - ))} @@ -312,7 +312,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor ) : null}
{FILTERS.map((f) => ( - ))} diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index c7e650db..3b8c2610 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -56,7 +56,7 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop {l.symbol} - {hl?.kind === "new" ? new : null} + {hl?.kind === "new" ? new : null}
diff --git a/app/src/components/launchpad/MobileBuyBar.tsx b/app/src/components/launchpad/MobileBuyBar.tsx index 3b6a8719..fd7c6651 100644 --- a/app/src/components/launchpad/MobileBuyBar.tsx +++ b/app/src/components/launchpad/MobileBuyBar.tsx @@ -22,7 +22,7 @@ export default function MobileBuyBar({ symbol, mcap }: { symbol: string; mcap: s
{symbol} · market cap
{mcap}
- { e.preventDefault(); document.getElementById("trade")?.scrollIntoView({ behavior: "smooth", block: "start" }); }} className="inline-flex items-center justify-center min-h-11 px-6 rounded-xl bg-up text-white text-sm font-semibold"> + { e.preventDefault(); document.getElementById("trade")?.scrollIntoView({ behavior: "smooth", block: "start" }); }} className="inline-flex items-center justify-center min-h-11 px-6 rounded-xl bg-up text-inverse text-sm font-semibold"> Buy {symbol}
diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index 90373462..89ff304b 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -107,7 +107,24 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain candleSeries.current = cs; volSeries.current = vs; markers.current = createSeriesMarkers(cs, []); + const theme = window.matchMedia("(prefers-color-scheme: dark)"); + const syncTheme = () => { + const styles = getComputedStyle(el); + const color = (name: string) => styles.getPropertyValue(`--color-${name}`).trim(); + c.applyOptions({ + layout: { textColor: color("muted") }, + grid: { vertLines: { color: theme.matches ? color("line") : "#F1F0EE" }, horzLines: { color: theme.matches ? color("line") : "#F1F0EE" } }, + rightPriceScale: { borderColor: color("line") }, + timeScale: { borderColor: color("line") }, + }); + cs.applyOptions({ upColor: color("up"), borderUpColor: color("up"), wickUpColor: color("up"), downColor: color("down"), borderDownColor: color("down"), wickDownColor: color("down") }); + const currentMarkers = markers.current; + currentMarkers?.setMarkers(currentMarkers.markers().map((marker) => ({ ...marker, color: marker.shape === "arrowUp" ? color("up") : color("down") }))); + }; + syncTheme(); + theme.addEventListener("change", syncTheme); return () => { + theme.removeEventListener("change", syncTheme); c.remove(); chart.current = null; candleSeries.current = null; @@ -124,7 +141,8 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain cs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, open: k.open, high: k.high, low: k.low, close: k.close }))); vs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, value: k.volume, color: k.close >= k.open ? "rgba(21,128,61,0.25)" : "rgba(220,38,38,0.25)" }))); const intervalS = INTERVALS[interval]; - const mine = (data?.mine ?? []).map((m) => ({ time: (Math.floor(m.t / intervalS) * intervalS) as UTCTimestamp, position: m.is_buy ? ("belowBar" as const) : ("aboveBar" as const), color: m.is_buy ? UP : DOWN, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); + const colors = cs.options(); + const mine = (data?.mine ?? []).map((m) => ({ time: (Math.floor(m.t / intervalS) * intervalS) as UTCTimestamp, position: m.is_buy ? ("belowBar" as const) : ("aboveBar" as const), color: m.is_buy ? colors.upColor : colors.downColor, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); markers.current?.setMarkers(mine.sort((a, b) => Number(a.time) - Number(b.time))); chart.current?.timeScale().fitContent(); }, [series, data?.mine, interval]); @@ -153,7 +171,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain
{INTERVAL_KEYS.map((k) => ( - ))} diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index e08f7df4..7c71c928 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -184,7 +184,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs setQuote(null); setPhase({ k: "idle" }); }} - className={`h-10 rounded-lg text-sm font-semibold capitalize transition-colors ${side === s ? (s === "buy" ? "bg-up text-white" : "bg-down text-white") : "text-body hover:text-ink"}`} + className={`h-10 rounded-lg text-sm font-semibold capitalize transition-colors ${side === s ? (s === "buy" ? "bg-up text-inverse" : "bg-down text-inverse") : "text-body hover:text-ink"}`} aria-pressed={side === s} > {s} diff --git a/app/src/components/theme.test.ts b/app/src/components/theme.test.ts new file mode 100644 index 00000000..465a1e5f --- /dev/null +++ b/app/src/components/theme.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); +const colors = (source: string) => Object.fromEntries(Array.from(source.matchAll(/--color-([\w-]+):\s*(#[\da-f]{6});/gi), ([, name, hex]) => [name, hex])); +const light = colors(css.split("@media (prefers-color-scheme: dark)")[0]); +const darkRule = css.match(/@media \(prefers-color-scheme: dark\)\s*\{\s*:root\s*\{([^}]+)\}/)?.[1] ?? ""; +const dark = { ...light, ...colors(darkRule) }; + +/** WCAG relative luminance for opaque sRGB theme colors. */ +function luminance(hex: string) { + assert.match(hex, /^#[\da-f]{6}$/i); + const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255); + const linear = rgb.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722; +} + +test("retains the existing light surfaces, branding, and filled-control foreground", () => { + assert.equal(light.paper, "#fafaf8"); + assert.equal(light.card, "#ffffff"); + assert.equal(light.brand, "#0052ff"); + assert.equal(light.ink, "#0f172a"); + assert.equal(light.inverse, "#ffffff"); +}); + +test("system dark preference changes native controls and surfaces through CSS", () => { + assert.match(darkRule, /color-scheme:\s*dark/); + assert.notEqual(dark.paper, light.paper); + assert.notEqual(dark.skeleton, light.skeleton); + assert.ok(luminance(dark.paper) < 0.01); +}); + +test("dark text, semantic labels, and filled controls maintain AA contrast", () => { + const pairs = [ + ...["paper", "card"].flatMap((surface) => ["ink", "body", "muted", "faint", "brand", "up", "down-ink", "warm-ink"].map((text) => [text, surface])), + ...["ink", "brand", "brand-strong", "up", "down", "warm"].map((fill) => ["inverse", fill]), + ["brand", "brand-soft"], ["up", "up-soft"], ["down-ink", "down-soft"], ["warm-ink", "warm-soft"], ["up", "holder-good-bg"], + ]; + for (const [foreground, background] of pairs) { + const a = luminance(dark[foreground]); + const b = luminance(dark[background]); + const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + assert.ok(ratio >= 4.5, `${foreground} on ${background}: ${ratio.toFixed(2)}:1`); + } +}); diff --git a/app/src/components/ui.ts b/app/src/components/ui.ts index 9eb958ca..84da4909 100644 --- a/app/src/components/ui.ts +++ b/app/src/components/ui.ts @@ -4,14 +4,14 @@ const btnBase = "inline-flex items-center justify-center gap-1.5 rounded-xl font-semibold text-sm whitespace-nowrap transition-colors disabled:opacity-40 disabled:cursor-not-allowed select-none"; export const btn = { - primary: `${btnBase} min-h-11 px-5 bg-brand text-white hover:bg-brand-strong`, - primarySm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-brand text-white hover:bg-brand-strong`, + primary: `${btnBase} min-h-11 px-5 bg-brand text-inverse hover:bg-brand-strong`, + primarySm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-brand text-inverse hover:bg-brand-strong`, secondary: `${btnBase} min-h-11 px-5 bg-card text-ink border border-line-strong hover:border-ink/40 hover:bg-paper`, secondarySm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-card text-ink border border-line-strong hover:border-ink/40`, - soft: `${btnBase} min-h-11 px-5 bg-brand-soft text-brand hover:bg-brand hover:text-white`, - softSm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-brand-soft text-brand hover:bg-brand hover:text-white`, - up: `${btnBase} min-h-11 px-5 bg-up text-white hover:brightness-110`, - warm: `${btnBase} min-h-11 px-5 bg-warm text-white hover:brightness-110`, + soft: `${btnBase} min-h-11 px-5 bg-brand-soft text-brand hover:bg-brand hover:text-inverse`, + softSm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-brand-soft text-brand hover:bg-brand hover:text-inverse`, + up: `${btnBase} min-h-11 px-5 bg-up text-inverse hover:brightness-110`, + warm: `${btnBase} min-h-11 px-5 bg-warm text-inverse hover:brightness-110`, warmOutline: `${btnBase} min-h-11 px-5 bg-warm-soft text-warm-ink border border-warm/40 hover:border-warm`, icon: `${btnBase} h-10 w-10 rounded-xl text-ink hover:bg-paper border border-transparent hover:border-line`, }; From 069d0e0d51b7d4ab9090b362d0c7875dd9535642 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Mon, 7 Sep 2026 16:54:44 +0530 Subject: [PATCH 2/6] theme: class-based dark default with an animated toggle, light behind it Builds on the system-dark-mode work: the palette moves from a prefers-color-scheme media query to a class on so the reader can choose. Dark is the default and carries the pure-black palette; light is the unclassed base so the toggler's bare classList.toggle("dark") works. - globals.css: light tokens in @theme, dark overrides in .dark, print fallback to ink-on-white, View Transitions CSS for the toggle wipe. Hero wash, progress bar and shadow system unchanged. - ThemeProvider (next-themes, attribute="class", defaultTheme="dark") and ThemeToggle wrapping MagicUI's animated-theme-toggler in controlled mode, with a reduced-motion patch. Toggle sits in the header and the mobile menu. - PriceChart re-reads its colours when the html class changes; volume bars and gridlines come from tokens instead of hardcoded light rgba. - Sheet scrim gets its own token so it stays dark in both themes; the warm filled button uses warm-ink to keep AA. - theme.test.ts checks the class-based ramp: AA contrast in both themes, every base token has a dark counterpart, no .light rule, print fallback. --- app/components.json | 24 ++ app/package-lock.json | 42 +++ app/package.json | 4 + app/src/app/globals.css | 182 +++++++--- app/src/app/layout.tsx | 10 +- app/src/components/HeaderNav.tsx | 5 + app/src/components/Sheet.tsx | 2 +- app/src/components/ThemeProvider.tsx | 27 ++ app/src/components/ThemeToggle.tsx | 47 +++ app/src/components/launchpad/PriceChart.tsx | 27 +- app/src/components/theme.test.ts | 130 +++++-- app/src/components/ui.ts | 3 +- .../vendor/animated-theme-toggler.tsx | 337 ++++++++++++++++++ app/src/lib/utils.ts | 11 + 14 files changed, 757 insertions(+), 94 deletions(-) create mode 100644 app/components.json create mode 100644 app/src/components/ThemeProvider.tsx create mode 100644 app/src/components/ThemeToggle.tsx create mode 100644 app/src/components/vendor/animated-theme-toggler.tsx create mode 100644 app/src/lib/utils.ts diff --git a/app/components.json b/app/components.json new file mode 100644 index 00000000..850369ca --- /dev/null +++ b/app/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": false, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/vendor", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@magicui": "https://magicui.design/r/{name}.json" + } +} 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 aa03a711..b8e9c19e 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1,85 +1,122 @@ @import "tailwindcss"; -/* Clear Sky: the existing light palette, with system-driven dark colors. */ +/* Dark is the default and carries the gitlawb.com palette: black ground, an + alpha ramp of white for hierarchy, near-zero chroma greys. Light is + available behind the toggle. + + Structure note: the BASE (@theme, unclassed) is light and `.dark` overrides + it — the inverse of how it reads. That is deliberate. The animated toggler + flips the theme with a bare `classList.toggle("dark")`, so "no class" has to + mean light. next-themes is configured to match (value={{ light: "", dark: + "dark" }}) and still defaults everyone to dark on first visit. It is also + what Aceternity / shadcn `dark:` variants assume. */ +@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; --font-display: var(--font-unbounded), var(--font-inter), ui-sans-serif, system-ui, sans-serif; - /* surfaces */ - --color-paper: #fafaf8; - --color-card: #ffffff; - --color-line: #e7e5e4; - --color-line-strong: #d6d3d1; - - /* text */ - --color-ink: #0f172a; - --color-body: #475569; - --color-muted: #64748b; /* AA on paper/card at body sizes */ - --color-faint: #94a3b8; /* decorative / placeholders only */ + /* LIGHT — the unclassed base. Same ramp as dark, inverted (black over white), + resolved to opaque hex so opacity modifiers do not double up the alpha. */ + --color-paper: #ffffff; + --color-card: #f5f5f5; + --color-line: #e5e5e5; + --color-line-strong: #cccccc; + + --color-ink: #000000; + --color-body: #4d4d4d; /* black .70 */ + --color-muted: #6b6b6b; /* black .58 — .55 lands at 4.31:1 on the card, under AA */ + --color-faint: #999999; /* black .40 — decorative / placeholders only */ --color-inverse: #ffffff; - --color-holder-good-line: #bbf7d0; - --color-holder-good-bg: #ecfdf3; - --color-skeleton: #ecebe8; - --color-skeleton-highlight: #f5f4f1; + --color-skeleton: #ebebeb; + --color-skeleton-highlight: #f5f5f5; - /* accent */ --color-brand: #0052ff; --color-brand-strong: #0041cc; - --color-brand-soft: #eaf0ff; + --color-brand-soft: #e6edff; - /* money: up (refund / locked), down (displaced), warm (takeover / testnet) */ + /* money: up (locked / gain), down (loss), warm (caution / wrong chain) */ --color-up: #15803d; --color-up-soft: #ecfdf3; --color-down: #dc2626; - --color-down-ink: #b91c1c; /* text on down-soft */ + --color-down-ink: #b91c1c; --color-down-soft: #fef2f2; --color-warm: #d97706; - --color-warm-ink: #b45309; /* text on warm-soft / paper */ + --color-warm-ink: #b45309; --color-warm-soft: #fffbeb; + --color-holder-good-line: #bbf7d0; + --color-holder-good-bg: #ecfdf3; + + /* backdrop keeps its own token: deriving it from a text colour inverted it, + and it must stay dark in BOTH themes */ + --color-scrim: #000000; + /* chart gridlines sit just above the card, below the data */ + --color-chart-grid: #f1f0ee; - --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); - --shadow-dialog: 0 24px 64px rgba(15, 23, 42, 0.18); + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 16px rgba(0, 0, 0, 0.04); + --shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.06), 0 10px 28px rgba(0, 0, 0, 0.09); + --shadow-sheet: 0 -8px 40px rgba(0, 0, 0, 0.12); + --shadow-dialog: 0 24px 64px rgba(0, 0, 0, 0.18); } :root { color-scheme: light; } -/* CSS applies the system preference before hydration and reacts to changes. */ -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --color-paper: #0b1220; - --color-card: #111c2e; - --color-line: #2b3a50; - --color-line-strong: #475569; - --color-ink: #f1f5f9; - --color-body: #cbd5e1; - --color-muted: #9aaec6; - --color-faint: #8296af; - --color-inverse: #0b1220; - --color-brand: #8ab4ff; - --color-brand-strong: #aecbff; - --color-brand-soft: #172b4c; - --color-up: #6ee7a0; - --color-up-soft: #102d22; - --color-down: #fca5a5; - --color-down-ink: #fca5a5; - --color-down-soft: #381c28; - --color-warm: #fbbf24; - --color-warm-ink: #fcd34d; - --color-warm-soft: #332810; - --color-holder-good-line: #286447; - --color-holder-good-bg: #102d22; - --color-skeleton: #1c2a3e; - --color-skeleton-highlight: #293a52; - --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; +/* DARK — the default theme, and the gitlawb.com palette. next-themes puts this + class on ; the animated toggler flips the same class directly. + The blue and green brighten here: the light-mode values drop under 3:1 on + black, and the bright ones drop under 3:1 on white. */ +.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-skeleton: #141414; + --color-skeleton-highlight: #1f1f1f; + --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-chart-grid: #1a1a1a; + /* the backdrop stays dark in both themes — that is the whole point of it */ + --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; + --color-scrim: #000000; +} + +/* 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: #e5e5e5; + --color-line-strong: #cccccc; + --color-ink: #000000; + --color-body: #333333; + --color-muted: #595959; + --color-faint: #767676; + --color-inverse: #ffffff; } } @@ -232,3 +269,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 ba13d882..9fc0ad2d 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" }); @@ -32,8 +33,9 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: [{ media: "(prefers-color-scheme: light)", color: "#FAFAF8" }, { media: "(prefers-color-scheme: dark)", color: "#0b1220" }], - colorScheme: "light dark", width: "device-width", initialScale: 1, viewportFit: "cover", + // dark is the default theme; the toggle flips a class, not the OS preference + themeColor: "#000000", + colorScheme: "dark light", width: "device-width", initialScale: 1, viewportFit: "cover", }; export default async function RootLayout({ children }: { children: React.ReactNode }) { @@ -42,8 +44,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo const b = launchpad("base"); const r = launchpad("robinhood"); return ( - + + @@ -84,6 +87,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo + ); diff --git a/app/src/components/HeaderNav.tsx b/app/src/components/HeaderNav.tsx index 0fb8ba7f..bf897ad3 100644 --- a/app/src/components/HeaderNav.tsx +++ b/app/src/components/HeaderNav.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { useState } from "react"; import Mark, { Wordmark } from "./launchpad/Mark"; import ConnectButton from "./ConnectButton"; +import ThemeToggle from "./ThemeToggle"; import Sheet from "./Sheet"; import LivePulse from "./launchpad/LivePulse"; @@ -44,6 +45,9 @@ export default function HeaderNav({ pulse }: { pulse: { visits: number; online: Launch a token +
+ +
@@ -71,6 +75,7 @@ export default function HeaderNav({ pulse }: { pulse: { visits: number; online:
+
diff --git a/app/src/components/Sheet.tsx b/app/src/components/Sheet.tsx index a9987ade..6ab6558b 100644 --- a/app/src/components/Sheet.tsx +++ b/app/src/components/Sheet.tsx @@ -47,7 +47,7 @@ export default function Sheet({ return createPortal(
closeRef.current()} role="dialog" aria-modal diff --git a/app/src/components/ThemeProvider.tsx b/app/src/components/ThemeProvider.tsx new file mode 100644 index 00000000..e9d9ddf1 --- /dev/null +++ b/app/src/components/ThemeProvider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { ThemeProvider as NextThemes } from "next-themes"; + +/** + * Dark by default with an explicit light option — the same two-state model as + * gitlawb.com (which stores "light" | "dark" and never follows the OS). + * + * Class-based rather than gitlawb's `data-theme` attribute: Aceternity and + * shadcn components ship `dark:` variants, and Tailwind's dark variant resolves + * against a class (see @custom-variant at the top of globals.css). + * + * Deliberately NO `value` mapping. next-themes writes `light` or `dark`, while + * the animated toggler flips only `dark` — so mid-toggle you briefly get + * `class="light dark"`. That renders correctly because light is the UNCLASSED + * base and there is no `.light` rule: `dark` present wins, `dark` absent falls + * back to light, and the stray `light` class is inert. (Mapping light to "" + * would be the tidier model, but next-themes calls classList.remove(value) and + * throws on an empty token.) + */ +export default function ThemeProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/src/components/ThemeToggle.tsx b/app/src/components/ThemeToggle.tsx new file mode 100644 index 00000000..dc1ee8da --- /dev/null +++ b/app/src/components/ThemeToggle.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { useTheme } from "next-themes"; +import { AnimatedThemeToggler } from "@/components/vendor/animated-theme-toggler"; + +/** + * Theme switch. Wraps MagicUI's AnimatedThemeToggler in CONTROLLED mode: it + * owns the View Transitions wipe, next-themes owns persistence. Passing `theme` + * is what stops the vendored component writing its own localStorage key. + * + * Renders a neutral placeholder until mounted — the resolved theme only exists + * on the client, so drawing the real icon during SSR would mismatch. + */ +export default function ThemeToggle({ block = false }: { block?: boolean }) { + const { resolvedTheme, setTheme } = useTheme(); + const mounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); + + const control = "h-9 w-9 inline-flex items-center justify-center rounded-xl border border-line bg-card text-body hover:text-ink hover:border-line-strong [&_svg]:size-4"; + + if (!mounted) { + return block ?
:
; + } + + const theme = resolvedTheme === "dark" ? "dark" : "light"; + const next = theme === "dark" ? "light" : "dark"; + const toggler = ( + + ); + + if (!block) return toggler; + + // In the mobile menu the toggler sits in a list of labelled rows, so it gets + // a visible label too — a settings row, label left, control right. + return ( +
+ + Theme · {theme === "dark" ? "Dark" : "Light"} + + {toggler} +
+ ); +} diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index 89ff304b..a6a9ac54 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -27,6 +27,8 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const [unit, setUnit] = useState("usd"); const [data, setData] = useState(null); const [error, setError] = useState(null); + /** Bumped whenever the theme class changes, so the data effect re-pushes bar colours. */ + const [themeTick, setThemeTick] = useState(0); const { address } = useAccount(); const { subscribe } = useLive(); const box = useRef(null); @@ -107,24 +109,31 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain candleSeries.current = cs; volSeries.current = vs; markers.current = createSeriesMarkers(cs, []); - const theme = window.matchMedia("(prefers-color-scheme: dark)"); const syncTheme = () => { const styles = getComputedStyle(el); const color = (name: string) => styles.getPropertyValue(`--color-${name}`).trim(); c.applyOptions({ layout: { textColor: color("muted") }, - grid: { vertLines: { color: theme.matches ? color("line") : "#F1F0EE" }, horzLines: { color: theme.matches ? color("line") : "#F1F0EE" } }, + grid: { vertLines: { color: color("chart-grid") }, horzLines: { color: color("chart-grid") } }, rightPriceScale: { borderColor: color("line") }, timeScale: { borderColor: color("line") }, + // lightweight-charts picks contrasting label text from this background + crosshair: { horzLine: { labelBackgroundColor: color("ink") }, vertLine: { labelBackgroundColor: color("ink") } }, }); cs.applyOptions({ upColor: color("up"), borderUpColor: color("up"), wickUpColor: color("up"), downColor: color("down"), borderDownColor: color("down"), wickDownColor: color("down") }); + vs.applyOptions({ color: color("line-strong") }); const currentMarkers = markers.current; currentMarkers?.setMarkers(currentMarkers.markers().map((marker) => ({ ...marker, color: marker.shape === "arrowUp" ? color("up") : color("down") }))); + // per-bar volume colours live in the data effect; make it re-run + setThemeTick((t) => t + 1); }; syncTheme(); - theme.addEventListener("change", syncTheme); + // The theme is a class on (next-themes), so matchMedia would miss a + // manual toggle. Watching the class covers both the toggle and an OS change. + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => { - theme.removeEventListener("change", syncTheme); + observer.disconnect(); c.remove(); chart.current = null; candleSeries.current = null; @@ -139,13 +148,17 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const vs = volSeries.current; if (!cs || !vs || !series) return; cs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, open: k.open, high: k.high, low: k.low, close: k.close }))); - vs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, value: k.volume, color: k.close >= k.open ? "rgba(21,128,61,0.25)" : "rgba(220,38,38,0.25)" }))); - const intervalS = INTERVALS[interval]; const colors = cs.options(); + // Volume bars follow the candle palette instead of hardcoded light-theme + // rgba, which was invisible against the dark card. + const volUp = `color-mix(in srgb, ${colors.upColor} 25%, transparent)`; + const volDown = `color-mix(in srgb, ${colors.downColor} 25%, transparent)`; + vs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, value: k.volume, color: k.close >= k.open ? volUp : volDown }))); + const intervalS = INTERVALS[interval]; const mine = (data?.mine ?? []).map((m) => ({ time: (Math.floor(m.t / intervalS) * intervalS) as UTCTimestamp, position: m.is_buy ? ("belowBar" as const) : ("aboveBar" as const), color: m.is_buy ? colors.upColor : colors.downColor, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); markers.current?.setMarkers(mine.sort((a, b) => Number(a.time) - Number(b.time))); chart.current?.timeScale().fitContent(); - }, [series, data?.mine, interval]); + }, [series, data?.mine, interval, themeTick]); const unitLabel = effUnit === "usd" ? "USD" : effUnit === "mcap" ? "MCAP" : data?.quote.symbol ?? "quote"; const last = series?.candles.at(-1); diff --git a/app/src/components/theme.test.ts b/app/src/components/theme.test.ts index 465a1e5f..bc20e8b3 100644 --- a/app/src/components/theme.test.ts +++ b/app/src/components/theme.test.ts @@ -2,45 +2,125 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +/** + * Lints the token layer in globals.css. It reads CSS text, so it proves the + * palette is internally consistent — it cannot prove a component uses the right + * token. A pass means "the ramp is sane", not "the UI is fine". + * + * Structure: the unclassed base (@theme) is LIGHT and `.dark` overrides it, + * even though dark is what everyone sees by default. The animated toggler flips + * themes with a bare `classList.toggle("dark")`, so "no class" must mean light. + */ const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); -const colors = (source: string) => Object.fromEntries(Array.from(source.matchAll(/--color-([\w-]+):\s*(#[\da-f]{6});/gi), ([, name, hex]) => [name, hex])); -const light = colors(css.split("@media (prefers-color-scheme: dark)")[0]); -const darkRule = css.match(/@media \(prefers-color-scheme: dark\)\s*\{\s*:root\s*\{([^}]+)\}/)?.[1] ?? ""; -const dark = { ...light, ...colors(darkRule) }; +const themeBlock = css.match(/@theme\s*\{([\s\S]*?)\n\}/)?.[1] ?? ""; +const darkBlock = css.match(/\n\.dark\s*\{([\s\S]*?)\n\}/)?.[1] ?? ""; +const printBlock = css.match(/@media print\s*\{([\s\S]*?)\n\}/)?.[1] ?? ""; +const colors = (source: string) => Object.fromEntries(Array.from(source.matchAll(/--color-([\w-]+):\s*(#[\da-f]{3,8});/gi), ([, name, hex]) => [name, hex])); + +const light = colors(themeBlock); +const dark = { ...light, ...colors(darkBlock) }; +const THEMES: [string, Record][] = [ + ["light", light], + ["dark", dark], +]; /** WCAG relative luminance for opaque sRGB theme colors. */ function luminance(hex: string) { - assert.match(hex, /^#[\da-f]{6}$/i); + assert.match(hex, /^#[\da-f]{6}$/i, `expected a 6-digit hex, got ${hex}`); const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255); - const linear = rgb.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + const linear = rgb.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722; } +const ratio = (fg: string, bg: string) => { + const a = luminance(fg); + const b = luminance(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +}; + +test("dark carries the gitlawb palette: black ground, white ink", () => { + assert.equal(dark.paper, "#000000"); + assert.equal(dark.ink, "#ffffff"); + assert.equal(dark.inverse, "#000000"); +}); + +test("light is the unclassed base, so `no class` means light", () => { + // The animated toggler does classList.toggle("dark") and never writes a + // `light` class — if light lived in its own class this would break. + assert.equal(light.paper, "#ffffff"); + assert.equal(light.ink, "#000000"); + assert.equal(css.includes("\n.light {"), false, "light must not live in a class"); + assert.match(css, /:root\s*\{[^}]*color-scheme:\s*light/); + assert.match(darkBlock, /color-scheme:\s*dark/); +}); -test("retains the existing light surfaces, branding, and filled-control foreground", () => { - assert.equal(light.paper, "#fafaf8"); - assert.equal(light.card, "#ffffff"); - assert.equal(light.brand, "#0052ff"); - assert.equal(light.ink, "#0f172a"); - assert.equal(light.inverse, "#ffffff"); +test("typography is unchanged — only the palette came from gitlawb", () => { + assert.match(themeBlock, /--font-sans:\s*var\(--font-inter\)/); + assert.match(themeBlock, /--font-mono:\s*var\(--font-space-mono\)/); + assert.match(themeBlock, /--font-display:\s*var\(--font-unbounded\)/); +}); + +test("the dark: variant resolves against a class, not a media query", () => { + assert.match(css, /@custom-variant dark \(&:is\(\.dark \*\)\)/); + assert.doesNotMatch(css, /@media \(prefers-color-scheme/, "theming is by explicit choice, never by OS preference"); +}); + +test("greys are near-achromatic in both themes, like gitlawb.com", () => { + for (const [name, palette] of THEMES) { + for (const token of ["paper", "card", "line", "line-strong", "ink", "body", "muted", "faint"]) { + const hex = palette[token]; + const [r, g, b] = [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16)); + const spread = Math.max(r, g, b) - Math.min(r, g, b); + assert.ok(spread <= 8, `${name}: --color-${token} (${hex}) should be neutral, channel spread is ${spread}`); + } + } }); -test("system dark preference changes native controls and surfaces through CSS", () => { - assert.match(darkRule, /color-scheme:\s*dark/); - assert.notEqual(dark.paper, light.paper); - assert.notEqual(dark.skeleton, light.skeleton); - assert.ok(luminance(dark.paper) < 0.01); +test("the surface ramp steps away from the ground monotonically", () => { + for (const [name, palette] of THEMES) { + const steps = ["paper", "card", "line", "line-strong"].map((n) => luminance(palette[n])); + const rising = steps[1] > steps[0]; + for (let i = 1; i < steps.length; i++) { + assert.equal(steps[i] > steps[i - 1], rising, `${name}: surface ramp reverses at index ${i}`); + } + } }); -test("dark text, semantic labels, and filled controls maintain AA contrast", () => { +test("text, semantic labels, and filled controls maintain AA contrast in both themes", () => { const pairs = [ - ...["paper", "card"].flatMap((surface) => ["ink", "body", "muted", "faint", "brand", "up", "down-ink", "warm-ink"].map((text) => [text, surface])), - ...["ink", "brand", "brand-strong", "up", "down", "warm"].map((fill) => ["inverse", fill]), + ...["paper", "card"].flatMap((surface) => ["ink", "body", "muted", "brand", "up", "down-ink", "warm-ink"].map((text) => [text, surface])), + ...["ink", "brand", "brand-strong", "up", "down", "warm-ink"].map((fill) => ["inverse", fill]), ["brand", "brand-soft"], ["up", "up-soft"], ["down-ink", "down-soft"], ["warm-ink", "warm-soft"], ["up", "holder-good-bg"], ]; - for (const [foreground, background] of pairs) { - const a = luminance(dark[foreground]); - const b = luminance(dark[background]); - const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - assert.ok(ratio >= 4.5, `${foreground} on ${background}: ${ratio.toFixed(2)}:1`); + for (const [name, palette] of THEMES) { + for (const [foreground, background] of pairs) { + const r = ratio(palette[foreground], palette[background]); + assert.ok(r >= 4.5, `${name}: ${foreground} on ${background} is ${r.toFixed(2)}:1`); + } + } +}); + +test("every base token has a dark counterpart", () => { + const darkOnly = colors(darkBlock); + const missing = Object.keys(light).filter((name) => !(name in darkOnly)); + assert.deepEqual(missing, [], `tokens with no dark value: ${missing.join(", ")}`); +}); + +test("the modal scrim darkens in both themes rather than inverting", () => { + for (const [name, palette] of THEMES) { + assert.ok(luminance(palette.scrim) < 0.05, `${name}: scrim must be dark`); } }); + +test("the view-transition wipe is disabled under reduced motion", () => { + // The toggler skips startViewTransition, and the pseudo-elements are pinned + // too in case a transition is already in flight. + assert.match(css, /::view-transition-new\(root\)/, "the wipe needs its own transition CSS"); + const reduced = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}\n/g)?.join("\n") ?? ""; + assert.match(reduced, /view-transition/, "reduced motion must also stop the theme wipe"); +}); + +test("print falls back to ink on white", () => { + const print = colors(printBlock); + assert.equal(print.paper, "#ffffff", "black paper does not print"); + assert.equal(print.ink, "#000000"); +}); diff --git a/app/src/components/ui.ts b/app/src/components/ui.ts index 84da4909..fb45a9b4 100644 --- a/app/src/components/ui.ts +++ b/app/src/components/ui.ts @@ -11,7 +11,8 @@ export const btn = { soft: `${btnBase} min-h-11 px-5 bg-brand-soft text-brand hover:bg-brand hover:text-inverse`, softSm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-brand-soft text-brand hover:bg-brand hover:text-inverse`, up: `${btnBase} min-h-11 px-5 bg-up text-inverse hover:brightness-110`, - warm: `${btnBase} min-h-11 px-5 bg-warm text-inverse hover:brightness-110`, + // bg-warm-ink, not bg-warm: white on #d97706 is 3.19:1, below AA for a filled control. + warm: `${btnBase} min-h-11 px-5 bg-warm-ink text-inverse hover:brightness-110`, warmOutline: `${btnBase} min-h-11 px-5 bg-warm-soft text-warm-ink border border-warm/40 hover:border-warm`, icon: `${btnBase} h-10 w-10 rounded-xl text-ink hover:bg-paper border border-transparent hover:border-line`, }; diff --git a/app/src/components/vendor/animated-theme-toggler.tsx b/app/src/components/vendor/animated-theme-toggler.tsx new file mode 100644 index 00000000..5aa9076b --- /dev/null +++ b/app/src/components/vendor/animated-theme-toggler.tsx @@ -0,0 +1,337 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { Moon, Sun } from "lucide-react" +import { flushSync } from "react-dom" + +import { cn } from "@/lib/utils" + +export type TransitionVariant = + | "circle" + | "square" + | "triangle" + | "diamond" + | "hexagon" + | "rectangle" + | "star" + +interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> { + duration?: number + variant?: TransitionVariant + /** When true, the transition expands from the viewport center instead of the button center. */ + fromCenter?: boolean + /** + * Controlled theme value. When provided, the parent owns persistence + * (e.g. `next-themes`) and this component will not write to localStorage. + */ + theme?: "light" | "dark" + /** Called on toggle. Pair with `theme` for controlled usage. */ + onThemeChange?: (theme: "light" | "dark") => void +} + +function polygonCollapsed(point: string, vertexCount: number): string { + const pairs = Array.from({ length: vertexCount }, () => point).join(",") + return `polygon(${pairs})` +} + +// All coordinates are percentages of the snapshot reference box: Chrome 150 +// renders absolute px clip-path coordinates on ::view-transition-new(root) +// unscaled on fractional display scales (e.g. Windows 150%) for the first +// transition after load, so px values land at the wrong position (#989). +function getThemeTransitionClipPaths( + variant: TransitionVariant, + cx: number, + cy: number, + maxRadius: number, + viewportWidth: number, + viewportHeight: number +): [string, string] { + const toX = (x: number) => `${(x / viewportWidth) * 100}%` + const toY = (y: number) => `${(y / viewportHeight) * 100}%` + const point = (x: number, y: number) => `${toX(x)} ${toY(y)}` + // circle() percentage radii resolve against hypot(w, h) / sqrt(2) of the reference box. + const toRadius = (r: number) => + `${(r / (Math.hypot(viewportWidth, viewportHeight) / Math.SQRT2)) * 100}%` + + switch (variant) { + case "circle": + return [ + `circle(0% at ${point(cx, cy)})`, + `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`, + ] + case "square": { + const halfW = Math.max(cx, viewportWidth - cx) + const halfH = Math.max(cy, viewportHeight - cy) + const halfSide = Math.max(halfW, halfH) * 1.05 + const end = [ + point(cx - halfSide, cy - halfSide), + point(cx + halfSide, cy - halfSide), + point(cx + halfSide, cy + halfSide), + point(cx - halfSide, cy + halfSide), + ].join(",") + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] + } + case "triangle": { + const scale = maxRadius * 2.2 + const dx = (Math.sqrt(3) / 2) * scale + const verts = [ + point(cx, cy - scale), + point(cx + dx, cy + 0.5 * scale), + point(cx - dx, cy + 0.5 * scale), + ].join(",") + return [polygonCollapsed(point(cx, cy), 3), `polygon(${verts})`] + } + case "diamond": { + // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal. + const R = maxRadius * Math.SQRT2 + const end = [ + point(cx, cy - R), + point(cx + R, cy), + point(cx, cy + R), + point(cx - R, cy), + ].join(",") + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] + } + case "hexagon": { + const R = maxRadius * Math.SQRT2 + const verts: string[] = [] + for (let i = 0; i < 6; i++) { + const a = -Math.PI / 2 + (i * Math.PI) / 3 + verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a))) + } + return [ + polygonCollapsed(point(cx, cy), 6), + `polygon(${verts.join(",")})`, + ] + } + case "rectangle": { + const halfW = Math.max(cx, viewportWidth - cx) + const halfH = Math.max(cy, viewportHeight - cy) + const end = [ + point(cx - halfW, cy - halfH), + point(cx + halfW, cy - halfH), + point(cx + halfW, cy + halfH), + point(cx - halfW, cy + halfH), + ].join(",") + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] + } + case "star": { + // Small overscan so the last frames never leave a 1px seam before the transition group ends. + const R = maxRadius * Math.SQRT2 * 1.03 + const innerRatio = 0.42 + const starPolygon = (radius: number) => { + const verts: string[] = [] + for (let i = 0; i < 5; i++) { + const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5 + verts.push( + point( + cx + radius * Math.cos(outerA), + cy + radius * Math.sin(outerA) + ) + ) + const innerA = outerA + Math.PI / 5 + verts.push( + point( + cx + radius * innerRatio * Math.cos(innerA), + cy + radius * innerRatio * Math.sin(innerA) + ) + ) + } + return `polygon(${verts.join(",")})` + } + const startR = Math.max(2, R * 0.025) + return [starPolygon(startR), starPolygon(R)] + } + default: + return [ + `circle(0% at ${point(cx, cy)})`, + `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`, + ] + } +} + +export const AnimatedThemeToggler = ({ + className, + duration = 400, + variant, + fromCenter = false, + theme, + onThemeChange, + ...props +}: AnimatedThemeTogglerProps) => { + const shape = variant ?? "circle" + const isControlled = theme !== undefined + const [internalIsDark, setInternalIsDark] = useState(false) + const isDark = isControlled ? theme === "dark" : internalIsDark + const buttonRef = useRef(null) + const isTransitioningRef = useRef(false) + const activeAnimRef = useRef(null) + + const cancelAnim = useCallback(() => { + activeAnimRef.current?.cancel() + activeAnimRef.current = null + }, []) + + useEffect(() => { + return () => { + cancelAnim() + const root = document.documentElement + if (root.dataset.magicuiThemeVt !== "active") return + delete root.dataset.magicuiThemeVt + root.style.removeProperty("--magicui-theme-toggle-vt-duration") + root.style.removeProperty("--magicui-theme-vt-clip-from") + } + }, [cancelAnim]) + + useEffect(() => { + if (isControlled) return + + const updateTheme = () => { + setInternalIsDark(document.documentElement.classList.contains("dark")) + } + + updateTheme() + + const observer = new MutationObserver(updateTheme) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + + return () => observer.disconnect() + }, [isControlled]) + + const toggleTheme = useCallback(() => { + const button = buttonRef.current + if ( + !button || + isTransitioningRef.current || + document.documentElement.dataset.magicuiThemeVt === "active" + ) + return + + // innerWidth/innerHeight (not visualViewport): percentages must resolve + // against the snapshot reference box, which includes classic scrollbars. + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + let x: number + let y: number + if (fromCenter) { + x = viewportWidth / 2 + y = viewportHeight / 2 + } else { + const { top, left, width, height } = button.getBoundingClientRect() + x = left + width / 2 + y = top + height / 2 + } + + const maxRadius = Math.hypot( + Math.max(x, viewportWidth - x), + Math.max(y, viewportHeight - y) + ) + + const applyTheme = () => { + const newTheme = !isDark + // Always toggle the class synchronously so the View Transitions API + // snapshots the new theme inside the startViewTransition callback. + document.documentElement.classList.toggle("dark") + if (isControlled) { + onThemeChange?.(newTheme ? "dark" : "light") + } else { + setInternalIsDark(newTheme) + localStorage.setItem("theme", newTheme ? "dark" : "light") + } + } + + // LOCAL PATCH (openlaunch): upstream runs the wipe unconditionally. Every + // other animation in globals.css is disabled under reduced motion, so this + // one is too — fall through to an instant, unanimated theme swap. + // Re-apply if this file is ever re-pulled from the @magicui registry. + if ( + typeof document.startViewTransition !== "function" || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + applyTheme() + return + } + + const clipPath = getThemeTransitionClipPaths( + shape, + x, + y, + maxRadius, + viewportWidth, + viewportHeight + ) + + const root = document.documentElement + root.dataset.magicuiThemeVt = "active" + root.style.setProperty( + "--magicui-theme-toggle-vt-duration", + `${duration}ms` + ) + // Pin the collapsed clip-path via CSS so Firefox does not paint the new + // theme unclipped between snapshot and the ready.then() JS animation. + root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) + const cleanup = () => { + isTransitioningRef.current = false + delete root.dataset.magicuiThemeVt + root.style.removeProperty("--magicui-theme-toggle-vt-duration") + root.style.removeProperty("--magicui-theme-vt-clip-from") + cancelAnim() + } + + isTransitioningRef.current = true + const transition = document.startViewTransition(() => { + flushSync(applyTheme) + }) + if (typeof transition?.finished?.finally === "function") { + transition.finished.finally(cleanup).catch(() => {}) + } else { + cleanup() + } + + const ready = transition?.ready + if (ready && typeof ready.then === "function") { + ready + .then(() => { + const anim = document.documentElement.animate( + { + clipPath, + }, + { + duration, + // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above. + easing: shape === "star" ? "linear" : "ease-in-out", + fill: "forwards", + pseudoElement: "::view-transition-new(root)", + } + ) + activeAnimRef.current = anim + }) + .catch(() => {}) + } + }, [ + shape, + fromCenter, + duration, + isDark, + isControlled, + onThemeChange, + cancelAnim, + ]) + + return ( + + ) +} diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts new file mode 100644 index 00000000..877f15cd --- /dev/null +++ b/app/src/lib/utils.ts @@ -0,0 +1,11 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Class-name joiner used by shadcn/ui and Aceternity components. + * `twMerge` resolves conflicting Tailwind utilities so a caller's className + * always wins over a component's default (e.g. `p-2` passed in beats `p-4`). + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} From 9b542e9e0e0591e50a079e8518d2f2f1f8d80c1f Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Mon, 7 Sep 2026 19:51:00 +0800 Subject: [PATCH 3/6] theme: light stays the shipped Clear Sky palette and the default; dark behind the toggle Restores every light token to the values on main (the visual diff against main is zero on the home, launch and rules pages), makes light the default instead of dark, and keeps dark as an explicit choice via the header toggle. Also: warm button back to bg-warm, first-buy chips use text-inverse so they read in dark, chart volume gets its own token (matching main's colour in light) and a theme flip no longer resets the chart viewport, components.json (shadcn CLI config with a remote registry) removed, tests updated to guard the light palette against changes rather than re-tune it. --- app/components.json | 24 ------ app/src/app/globals.css | 96 ++++++++++----------- app/src/app/layout.tsx | 6 +- app/src/components/ThemeProvider.tsx | 12 +-- app/src/components/launchpad/LaunchForm.tsx | 2 +- app/src/components/launchpad/PriceChart.tsx | 8 +- app/src/components/theme.test.ts | 37 ++++---- app/src/components/ui.ts | 3 +- app/src/lib/utils.ts | 2 +- 9 files changed, 85 insertions(+), 105 deletions(-) delete mode 100644 app/components.json diff --git a/app/components.json b/app/components.json deleted file mode 100644 index 850369ca..00000000 --- a/app/components.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "slate", - "cssVariables": false, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/vendor", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": { - "@magicui": "https://magicui.design/r/{name}.json" - } -} diff --git a/app/src/app/globals.css b/app/src/app/globals.css index b8e9c19e..df469b26 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1,15 +1,11 @@ @import "tailwindcss"; -/* Dark is the default and carries the gitlawb.com palette: black ground, an - alpha ramp of white for hierarchy, near-zero chroma greys. Light is - available behind the toggle. - - Structure note: the BASE (@theme, unclassed) is light and `.dark` overrides - it — the inverse of how it reads. That is deliberate. The animated toggler - flips the theme with a bare `classList.toggle("dark")`, so "no class" has to - mean light. next-themes is configured to match (value={{ light: "", dark: - "dark" }}) and still defaults everyone to dark on first visit. It is also - what Aceternity / shadcn `dark:` variants assume. */ +/* ── "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 { @@ -17,57 +13,55 @@ --font-mono: var(--font-space-mono), ui-monospace, SFMono-Regular, Menlo, monospace; --font-display: var(--font-unbounded), var(--font-inter), ui-sans-serif, system-ui, sans-serif; - /* LIGHT — the unclassed base. Same ramp as dark, inverted (black over white), - resolved to opaque hex so opacity modifiers do not double up the alpha. */ - --color-paper: #ffffff; - --color-card: #f5f5f5; - --color-line: #e5e5e5; - --color-line-strong: #cccccc; - - --color-ink: #000000; - --color-body: #4d4d4d; /* black .70 */ - --color-muted: #6b6b6b; /* black .58 — .55 lands at 4.31:1 on the card, under AA */ - --color-faint: #999999; /* black .40 — decorative / placeholders only */ - --color-inverse: #ffffff; - --color-skeleton: #ebebeb; - --color-skeleton-highlight: #f5f5f5; + /* surfaces */ + --color-paper: #fafaf8; + --color-card: #ffffff; + --color-line: #e7e5e4; + --color-line-strong: #d6d3d1; + /* text */ + --color-ink: #0f172a; + --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; --color-brand-strong: #0041cc; - --color-brand-soft: #e6edff; + --color-brand-soft: #eaf0ff; - /* money: up (locked / gain), down (loss), warm (caution / wrong chain) */ + /* money: up (refund / locked), down (displaced), warm (takeover / testnet) */ --color-up: #15803d; --color-up-soft: #ecfdf3; --color-down: #dc2626; - --color-down-ink: #b91c1c; + --color-down-ink: #b91c1c; /* text on down-soft */ --color-down-soft: #fef2f2; --color-warm: #d97706; - --color-warm-ink: #b45309; + --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; - - /* backdrop keeps its own token: deriving it from a text colour inverted it, - and it must stay dark in BOTH themes */ - --color-scrim: #000000; - /* chart gridlines sit just above the card, below the data */ + --color-skeleton: #ecebe8; + --color-skeleton-highlight: #f5f4f1; + --color-scrim: #0f172a; /* modal backdrop (rendered at 40%); stays dark in both themes */ --color-chart-grid: #f1f0ee; + --color-chart-vol: #cbd5e1; - --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 16px rgba(0, 0, 0, 0.04); - --shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.06), 0 10px 28px rgba(0, 0, 0, 0.09); - --shadow-sheet: 0 -8px 40px rgba(0, 0, 0, 0.12); - --shadow-dialog: 0 24px 64px rgba(0, 0, 0, 0.18); + --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); + --shadow-dialog: 0 24px 64px rgba(15, 23, 42, 0.18); } :root { color-scheme: light; } -/* DARK — the default theme, and the gitlawb.com palette. next-themes puts this - class on ; the animated toggler flips the same class directly. - The blue and green brighten here: the light-mode values drop under 3:1 on - black, and the bright ones drop under 3:1 on white. */ +/* ── 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; @@ -79,8 +73,6 @@ --color-muted: #8c8c8c; /* white .55 */ --color-faint: #666666; /* white .40 */ --color-inverse: #000000; - --color-skeleton: #141414; - --color-skeleton-highlight: #1f1f1f; --color-brand: #4d8bff; --color-brand-strong: #7aa6ff; --color-brand-soft: #0d1b33; @@ -94,13 +86,15 @@ --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; - /* the backdrop stays dark in both themes — that is the whole point of it */ + --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; - --color-scrim: #000000; } /* Black paper does not print: a class-based theme survives into the print @@ -110,12 +104,12 @@ color-scheme: light; --color-paper: #ffffff; --color-card: #ffffff; - --color-line: #e5e5e5; - --color-line-strong: #cccccc; - --color-ink: #000000; - --color-body: #333333; - --color-muted: #595959; - --color-faint: #767676; + --color-line: #e7e5e4; + --color-line-strong: #d6d3d1; + --color-ink: #0f172a; + --color-body: #475569; + --color-muted: #64748b; + --color-faint: #94a3b8; --color-inverse: #ffffff; } } diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 9fc0ad2d..467ca55e 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -33,9 +33,9 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - // dark is the default theme; the toggle flips a class, not the OS preference - themeColor: "#000000", - colorScheme: "dark light", width: "device-width", initialScale: 1, viewportFit: "cover", + // light is the default; dark is a class the header toggle adds, not an OS preference + themeColor: "#FAFAF8", + colorScheme: "light dark", width: "device-width", initialScale: 1, viewportFit: "cover", }; export default async function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/app/src/components/ThemeProvider.tsx b/app/src/components/ThemeProvider.tsx index e9d9ddf1..21f4652f 100644 --- a/app/src/components/ThemeProvider.tsx +++ b/app/src/components/ThemeProvider.tsx @@ -3,12 +3,12 @@ import { ThemeProvider as NextThemes } from "next-themes"; /** - * Dark by default with an explicit light option — the same two-state model as - * gitlawb.com (which stores "light" | "dark" and never follows the OS). + * Light by default (the shipped design) with an explicit dark option behind the + * header toggle. Two states, stored as "light" | "dark"; the OS preference is + * not consulted so the first paint matches what everyone has seen so far. * - * Class-based rather than gitlawb's `data-theme` attribute: Aceternity and - * shadcn components ship `dark:` variants, and Tailwind's dark variant resolves - * against a class (see @custom-variant at the top of globals.css). + * Class-based: Tailwind's dark variant resolves against `.dark` on + * (see @custom-variant at the top of globals.css). * * Deliberately NO `value` mapping. next-themes writes `light` or `dark`, while * the animated toggler flips only `dark` — so mid-toggle you briefly get @@ -20,7 +20,7 @@ import { ThemeProvider as NextThemes } from "next-themes"; */ export default function ThemeProvider({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index c566b3d8..d3093edf 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -612,7 +612,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: type="button" key={v} onClick={() => setInitialBuy(active ? "" : v)} - className={`h-11 px-4 rounded-xl border font-mono text-sm font-bold tnum ${active ? "bg-ink text-white border-ink" : "bg-card text-ink border-line-strong hover:border-ink/40"}`} + className={`h-11 px-4 rounded-xl border font-mono text-sm font-bold tnum ${active ? "bg-ink text-inverse border-ink" : "bg-card text-ink border-line-strong hover:border-ink/40"}`} > {v} {quote.symbol} diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index a6a9ac54..0431e9ac 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -29,6 +29,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const [error, setError] = useState(null); /** Bumped whenever the theme class changes, so the data effect re-pushes bar colours. */ const [themeTick, setThemeTick] = useState(0); + const fitTick = useRef(0); const { address } = useAccount(); const { subscribe } = useLive(); const box = useRef(null); @@ -121,7 +122,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain crosshair: { horzLine: { labelBackgroundColor: color("ink") }, vertLine: { labelBackgroundColor: color("ink") } }, }); cs.applyOptions({ upColor: color("up"), borderUpColor: color("up"), wickUpColor: color("up"), downColor: color("down"), borderDownColor: color("down"), wickDownColor: color("down") }); - vs.applyOptions({ color: color("line-strong") }); + vs.applyOptions({ color: color("chart-vol") }); const currentMarkers = markers.current; currentMarkers?.setMarkers(currentMarkers.markers().map((marker) => ({ ...marker, color: marker.shape === "arrowUp" ? color("up") : color("down") }))); // per-bar volume colours live in the data effect; make it re-run @@ -157,7 +158,10 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const intervalS = INTERVALS[interval]; const mine = (data?.mine ?? []).map((m) => ({ time: (Math.floor(m.t / intervalS) * intervalS) as UTCTimestamp, position: m.is_buy ? ("belowBar" as const) : ("aboveBar" as const), color: m.is_buy ? colors.upColor : colors.downColor, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); markers.current?.setMarkers(mine.sort((a, b) => Number(a.time) - Number(b.time))); - chart.current?.timeScale().fitContent(); + // a theme flip only recolours: keep the viewport the reader had + const themeOnly = fitTick.current !== themeTick; + fitTick.current = themeTick; + if (!themeOnly) chart.current?.timeScale().fitContent(); }, [series, data?.mine, interval, themeTick]); const unitLabel = effUnit === "usd" ? "USD" : effUnit === "mcap" ? "MCAP" : data?.quote.symbol ?? "quote"; diff --git a/app/src/components/theme.test.ts b/app/src/components/theme.test.ts index bc20e8b3..bff523ac 100644 --- a/app/src/components/theme.test.ts +++ b/app/src/components/theme.test.ts @@ -7,9 +7,10 @@ import test from "node:test"; * palette is internally consistent — it cannot prove a component uses the right * token. A pass means "the ramp is sane", not "the UI is fine". * - * Structure: the unclassed base (@theme) is LIGHT and `.dark` overrides it, - * even though dark is what everyone sees by default. The animated toggler flips - * themes with a bare `classList.toggle("dark")`, so "no class" must mean light. + * Structure: the unclassed base (@theme) is LIGHT — the shipped "Clear Sky" + * palette, which is also the default — and `.dark` overrides it behind the + * header toggle. The toggler flips themes with a bare `classList.toggle("dark")`, + * so "no class" must mean light. */ const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); const themeBlock = css.match(/@theme\s*\{([\s\S]*?)\n\}/)?.[1] ?? ""; @@ -19,6 +20,8 @@ const colors = (source: string) => Object.fromEntries(Array.from(source.matchAll const light = colors(themeBlock); const dark = { ...light, ...colors(darkBlock) }; +/** Light pairs are the pre-existing design: guard against regressions below their current worst case, never re-tune them here. */ +const LIGHT_FLOOR = 3.0; const THEMES: [string, Record][] = [ ["light", light], ["dark", dark], @@ -37,23 +40,26 @@ const ratio = (fg: string, bg: string) => { return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }; -test("dark carries the gitlawb palette: black ground, white ink", () => { +test("dark: black ground, white ink", () => { assert.equal(dark.paper, "#000000"); assert.equal(dark.ink, "#ffffff"); assert.equal(dark.inverse, "#000000"); }); -test("light is the unclassed base, so `no class` means light", () => { +test("light is the original Clear Sky palette, unchanged, and the unclassed base", () => { + // The shipped design. Adding dark mode must not move a single light value. + assert.deepEqual( + { paper: light.paper, card: light.card, line: light.line, "line-strong": light["line-strong"], ink: light.ink, body: light.body, muted: light.muted, faint: light.faint, brand: light.brand, "brand-strong": light["brand-strong"], "brand-soft": light["brand-soft"], up: light.up, "up-soft": light["up-soft"], down: light.down, "down-ink": light["down-ink"], "down-soft": light["down-soft"], warm: light.warm, "warm-ink": light["warm-ink"], "warm-soft": light["warm-soft"] }, + { paper: "#fafaf8", card: "#ffffff", line: "#e7e5e4", "line-strong": "#d6d3d1", ink: "#0f172a", body: "#475569", muted: "#64748b", faint: "#94a3b8", brand: "#0052ff", "brand-strong": "#0041cc", "brand-soft": "#eaf0ff", up: "#15803d", "up-soft": "#ecfdf3", down: "#dc2626", "down-ink": "#b91c1c", "down-soft": "#fef2f2", warm: "#d97706", "warm-ink": "#b45309", "warm-soft": "#fffbeb" }, + ); // The animated toggler does classList.toggle("dark") and never writes a // `light` class — if light lived in its own class this would break. - assert.equal(light.paper, "#ffffff"); - assert.equal(light.ink, "#000000"); assert.equal(css.includes("\n.light {"), false, "light must not live in a class"); assert.match(css, /:root\s*\{[^}]*color-scheme:\s*light/); assert.match(darkBlock, /color-scheme:\s*dark/); }); -test("typography is unchanged — only the palette came from gitlawb", () => { +test("typography is unchanged", () => { assert.match(themeBlock, /--font-sans:\s*var\(--font-inter\)/); assert.match(themeBlock, /--font-mono:\s*var\(--font-space-mono\)/); assert.match(themeBlock, /--font-display:\s*var\(--font-unbounded\)/); @@ -64,8 +70,8 @@ test("the dark: variant resolves against a class, not a media query", () => { assert.doesNotMatch(css, /@media \(prefers-color-scheme/, "theming is by explicit choice, never by OS preference"); }); -test("greys are near-achromatic in both themes, like gitlawb.com", () => { - for (const [name, palette] of THEMES) { +test("dark greys are near-achromatic", () => { + for (const [name, palette] of THEMES.filter(([n]) => n === "dark")) { for (const token of ["paper", "card", "line", "line-strong", "ink", "body", "muted", "faint"]) { const hex = palette[token]; const [r, g, b] = [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16)); @@ -75,8 +81,8 @@ test("greys are near-achromatic in both themes, like gitlawb.com", () => { } }); -test("the surface ramp steps away from the ground monotonically", () => { - for (const [name, palette] of THEMES) { +test("the dark surface ramp steps away from the ground monotonically (light: white cards on off-white paper, by design)", () => { + for (const [name, palette] of THEMES.filter(([n]) => n === "dark")) { const steps = ["paper", "card", "line", "line-strong"].map((n) => luminance(palette[n])); const rising = steps[1] > steps[0]; for (let i = 1; i < steps.length; i++) { @@ -85,7 +91,7 @@ test("the surface ramp steps away from the ground monotonically", () => { } }); -test("text, semantic labels, and filled controls maintain AA contrast in both themes", () => { +test("dark text, semantic labels, and filled controls meet AA contrast (light is the shipped palette; it is checked for regressions, not re-tuned)", () => { const pairs = [ ...["paper", "card"].flatMap((surface) => ["ink", "body", "muted", "brand", "up", "down-ink", "warm-ink"].map((text) => [text, surface])), ...["ink", "brand", "brand-strong", "up", "down", "warm-ink"].map((fill) => ["inverse", fill]), @@ -94,7 +100,8 @@ test("text, semantic labels, and filled controls maintain AA contrast in both th for (const [name, palette] of THEMES) { for (const [foreground, background] of pairs) { const r = ratio(palette[foreground], palette[background]); - assert.ok(r >= 4.5, `${name}: ${foreground} on ${background} is ${r.toFixed(2)}:1`); + const floor = name === "dark" ? 4.5 : LIGHT_FLOOR; + assert.ok(r >= floor, `${name}: ${foreground} on ${background} is ${r.toFixed(2)}:1`); } } }); @@ -122,5 +129,5 @@ test("the view-transition wipe is disabled under reduced motion", () => { test("print falls back to ink on white", () => { const print = colors(printBlock); assert.equal(print.paper, "#ffffff", "black paper does not print"); - assert.equal(print.ink, "#000000"); + assert.equal(print.ink, light.ink); }); diff --git a/app/src/components/ui.ts b/app/src/components/ui.ts index fb45a9b4..84da4909 100644 --- a/app/src/components/ui.ts +++ b/app/src/components/ui.ts @@ -11,8 +11,7 @@ export const btn = { soft: `${btnBase} min-h-11 px-5 bg-brand-soft text-brand hover:bg-brand hover:text-inverse`, softSm: `${btnBase} min-h-9 px-3.5 text-[13px] bg-brand-soft text-brand hover:bg-brand hover:text-inverse`, up: `${btnBase} min-h-11 px-5 bg-up text-inverse hover:brightness-110`, - // bg-warm-ink, not bg-warm: white on #d97706 is 3.19:1, below AA for a filled control. - warm: `${btnBase} min-h-11 px-5 bg-warm-ink text-inverse hover:brightness-110`, + warm: `${btnBase} min-h-11 px-5 bg-warm text-inverse hover:brightness-110`, warmOutline: `${btnBase} min-h-11 px-5 bg-warm-soft text-warm-ink border border-warm/40 hover:border-warm`, icon: `${btnBase} h-10 w-10 rounded-xl text-ink hover:bg-paper border border-transparent hover:border-line`, }; diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index 877f15cd..f3234f74 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -2,7 +2,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; /** - * Class-name joiner used by shadcn/ui and Aceternity components. + * Class-name joiner (the vendored theme toggler uses it). * `twMerge` resolves conflicting Tailwind utilities so a caller's className * always wins over a component's default (e.g. `p-2` passed in beats `p-4`). */ From f6bcceaaadd167d00c3f7d8c13ee9c9d56c9229a Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Mon, 7 Sep 2026 19:57:14 +0800 Subject: [PATCH 4/6] chart: volume bar colours as rgba, not color-mix() lightweight-charts parses colours itself and threw "Failed to parse color: color-mix(...)" on every token page. Derive the 25% alpha from the hex token in JS. --- app/src/components/launchpad/PriceChart.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index 0431e9ac..d2f00c15 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -22,6 +22,15 @@ const DOWN = "#DC2626"; * feed shows a new trade for this token, the last buckets are re-fetched and * merged. Own trades (connected wallet) are drawn as markers. */ +/** "#rgb" / "#rrggbb" → "rgba(r,g,b,a)"; anything else is passed through unchanged. */ +function withAlpha(color: string, alpha: number): string { + const m = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(color.trim()); + if (!m) return color; + const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1]; + const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); + return `rgba(${r},${g},${b},${alpha})`; +} + export default function PriceChart({ chain, token, symbol, launchedAt }: { chain: ChainKey; token: string; symbol: string; launchedAt: string }) { const [interval, setInterval_] = useState(() => defaultInterval((nowMs() - new Date(launchedAt).getTime()) / 1000)); const [unit, setUnit] = useState("usd"); @@ -150,10 +159,10 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain if (!cs || !vs || !series) return; cs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, open: k.open, high: k.high, low: k.low, close: k.close }))); const colors = cs.options(); - // Volume bars follow the candle palette instead of hardcoded light-theme - // rgba, which was invisible against the dark card. - const volUp = `color-mix(in srgb, ${colors.upColor} 25%, transparent)`; - const volDown = `color-mix(in srgb, ${colors.downColor} 25%, transparent)`; + // Volume bars follow the candle palette (theme-aware) at 25% alpha. Plain rgba: + // lightweight-charts parses colours itself and rejects color-mix(). + const volUp = withAlpha(colors.upColor, 0.25); + const volDown = withAlpha(colors.downColor, 0.25); vs.setData(series.candles.map((k) => ({ time: k.t as UTCTimestamp, value: k.volume, color: k.close >= k.open ? volUp : volDown }))); const intervalS = INTERVALS[interval]; const mine = (data?.mine ?? []).map((m) => ({ time: (Math.floor(m.t / intervalS) * intervalS) as UTCTimestamp, position: m.is_buy ? ("belowBar" as const) : ("aboveBar" as const), color: m.is_buy ? colors.upColor : colors.downColor, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); From 1a5b3bc641b825cc4f7fa3357b147033840dd7c6 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Mon, 7 Sep 2026 20:00:11 +0800 Subject: [PATCH 5/6] chart: light gridlines and initial fit restored The chart-only tokens sat in @theme, and Tailwind drops @theme variables no class references, so in light the chart read an empty grid colour and drew black lines. They live in :root now (test added). The viewport-preserving change also skipped the very first fitContent; fit now keys on series/interval/markers identity instead. --- app/src/app/globals.css | 6 ++++-- app/src/components/launchpad/PriceChart.tsx | 13 ++++++++----- app/src/components/theme.test.ts | 8 ++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/app/globals.css b/app/src/app/globals.css index df469b26..82b6f585 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -47,8 +47,6 @@ --color-skeleton: #ecebe8; --color-skeleton-highlight: #f5f4f1; --color-scrim: #0f172a; /* modal backdrop (rendered at 40%); stays dark in both themes */ - --color-chart-grid: #f1f0ee; - --color-chart-vol: #cbd5e1; --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); @@ -58,6 +56,10 @@ :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 diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index d2f00c15..624f7cd1 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -38,7 +38,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const [error, setError] = useState(null); /** Bumped whenever the theme class changes, so the data effect re-pushes bar colours. */ const [themeTick, setThemeTick] = useState(0); - const fitTick = useRef(0); + const lastFit = useRef<{ series: unknown; interval: string; mine: unknown } | null>(null); const { address } = useAccount(); const { subscribe } = useLive(); const box = useRef(null); @@ -167,10 +167,13 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const intervalS = INTERVALS[interval]; const mine = (data?.mine ?? []).map((m) => ({ time: (Math.floor(m.t / intervalS) * intervalS) as UTCTimestamp, position: m.is_buy ? ("belowBar" as const) : ("aboveBar" as const), color: m.is_buy ? colors.upColor : colors.downColor, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); markers.current?.setMarkers(mine.sort((a, b) => Number(a.time) - Number(b.time))); - // a theme flip only recolours: keep the viewport the reader had - const themeOnly = fitTick.current !== themeTick; - fitTick.current = themeTick; - if (!themeOnly) chart.current?.timeScale().fitContent(); + // fit on new data / interval / markers; a theme flip only recolours and keeps the reader's viewport + const prev = lastFit.current; + const themeOnly = prev !== null && prev.series === series && prev.interval === interval && prev.mine === data?.mine; + if (!themeOnly) { + chart.current?.timeScale().fitContent(); + lastFit.current = { series, interval, mine: data?.mine }; + } }, [series, data?.mine, interval, themeTick]); const unitLabel = effUnit === "usd" ? "USD" : effUnit === "mcap" ? "MCAP" : data?.quote.symbol ?? "quote"; diff --git a/app/src/components/theme.test.ts b/app/src/components/theme.test.ts index bff523ac..57993a15 100644 --- a/app/src/components/theme.test.ts +++ b/app/src/components/theme.test.ts @@ -131,3 +131,11 @@ test("print falls back to ink on white", () => { assert.equal(print.paper, "#ffffff", "black paper does not print"); assert.equal(print.ink, light.ink); }); + +test("chart tokens are plain CSS in :root and overridden in .dark (Tailwind would drop unreferenced @theme vars)", () => { + const root = css.match(/:root\s*\{([\s\S]*?)\n\}/)?.[1] ?? ""; + for (const t of ["chart-grid", "chart-vol"]) { + assert.match(root, new RegExp(`--color-${t}:\\s*#[\\da-f]{6}`), `--color-${t} must be set in :root`); + assert.ok(t in colors(darkBlock), `--color-${t} must be set in .dark`); + } +}); From e39d1f6163fdce9bd35f6c1c78bc92f787eac823 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Mon, 7 Sep 2026 20:05:04 +0800 Subject: [PATCH 6/6] theme: pre-CSS colour hints stay light as on main; theme-color follows the toggle viewport colorScheme back to "light" (dark is a class, never an OS preference) and a tiny ThemeColorSync keeps the meta theme-color in step so mobile browser chrome matches. --- app/src/app/layout.tsx | 8 +++----- app/src/components/ThemeProvider.tsx | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 467ca55e..14c3ab0a 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -32,11 +32,9 @@ export const metadata: Metadata = { twitter: { card: "summary_large_image", site: `@${BRAND_X}`, title: TITLE, description: SOCIAL_DESCRIPTION }, }; -export const viewport: Viewport = { - // light is the default; dark is a class the header toggle adds, not an OS preference - themeColor: "#FAFAF8", - colorScheme: "light dark", width: "device-width", initialScale: 1, viewportFit: "cover", -}; +// 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 }) { const usd = await ethUsd(); diff --git a/app/src/components/ThemeProvider.tsx b/app/src/components/ThemeProvider.tsx index 21f4652f..95fdabd4 100644 --- a/app/src/components/ThemeProvider.tsx +++ b/app/src/components/ThemeProvider.tsx @@ -1,6 +1,19 @@ "use client"; -import { ThemeProvider as NextThemes } from "next-themes"; +import { useEffect } from "react"; +import { ThemeProvider as NextThemes, useTheme } from "next-themes"; + +const THEME_COLOR = { light: "#FAFAF8", dark: "#000000" } as const; + +/** Keeps the browser chrome (mobile address bar, PWA title bar) in step with the toggled theme. */ +function ThemeColorSync() { + const { resolvedTheme } = useTheme(); + useEffect(() => { + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.content = resolvedTheme === "dark" ? THEME_COLOR.dark : THEME_COLOR.light; + }, [resolvedTheme]); + return null; +} /** * Light by default (the shipped design) with an explicit dark option behind the @@ -21,6 +34,7 @@ import { ThemeProvider as NextThemes } from "next-themes"; export default function ThemeProvider({ children }: { children: React.ReactNode }) { return ( + {children} );