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 ( - @@ -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/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..624f7cd1 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -22,11 +22,23 @@ 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"); 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 lastFit = useRef<{ series: unknown; interval: string; mine: unknown } | null>(null); const { address } = useAccount(); const { subscribe } = useLive(); const box = useRef(null); @@ -107,7 +119,31 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain candleSeries.current = cs; volSeries.current = vs; markers.current = createSeriesMarkers(cs, []); + const syncTheme = () => { + const styles = getComputedStyle(el); + const color = (name: string) => styles.getPropertyValue(`--color-${name}`).trim(); + c.applyOptions({ + layout: { textColor: color("muted") }, + 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("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 + setThemeTick((t) => t + 1); + }; + 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 () => { + observer.disconnect(); c.remove(); chart.current = null; candleSeries.current = null; @@ -122,12 +158,23 @@ 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 colors = cs.options(); + // 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 ? UP : DOWN, shape: m.is_buy ? ("arrowUp" as const) : ("arrowDown" as const), text: m.is_buy ? "buy" : "sell" })); + 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]); + // 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"; const last = series?.candles.at(-1); @@ -153,7 +200,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..57993a15 --- /dev/null +++ b/app/src/components/theme.test.ts @@ -0,0 +1,141 @@ +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 — 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] ?? ""; +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) }; +/** 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], +]; + +/** WCAG relative luminance for opaque sRGB theme colors. */ +function luminance(hex: string) { + 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)); + 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: black ground, white ink", () => { + assert.equal(dark.paper, "#000000"); + assert.equal(dark.ink, "#ffffff"); + assert.equal(dark.inverse, "#000000"); +}); + +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(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", () => { + 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("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)); + 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("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++) { + assert.equal(steps[i] > steps[i - 1], rising, `${name}: surface ramp reverses at index ${i}`); + } + } +}); + +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]), + ["brand", "brand-soft"], ["up", "up-soft"], ["down-ink", "down-soft"], ["warm-ink", "warm-soft"], ["up", "holder-good-bg"], + ]; + for (const [name, palette] of THEMES) { + for (const [foreground, background] of pairs) { + const r = ratio(palette[foreground], palette[background]); + const floor = name === "dark" ? 4.5 : LIGHT_FLOOR; + assert.ok(r >= floor, `${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, 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`); + } +}); 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`, }; 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..f3234f74 --- /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 (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`). + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +}