diff --git a/apps/explorer/.env.example b/apps/explorer/.env.example index 1ab3e66e2..d1195eb5a 100644 --- a/apps/explorer/.env.example +++ b/apps/explorer/.env.example @@ -30,6 +30,9 @@ VITE_CONTRACT_VERIFICATION_API_BASE_URL="https://contracts.tempo.xyz" # show react query + react router devtools # VITE_ENABLE_DEVTOOLS=true +# enable light/dark mode keyboard toggle +# VITE_ENABLE_COLOR_SCHEME_TOGGLE=false + # (dev/preview) comma-separated list of allowed hosts to use in vite config. See # https://vite.dev/config/server-options # https://vite.dev/config/preview-options#preview-allowedhosts diff --git a/apps/explorer/src/comps/Header.tsx b/apps/explorer/src/comps/Header.tsx index 5b5dd9742..a318691bd 100644 --- a/apps/explorer/src/comps/Header.tsx +++ b/apps/explorer/src/comps/Header.tsx @@ -10,6 +10,7 @@ import { useAnimatedBlockNumber, useLiveBlockNumber } from '#lib/block-number' import { cx } from '#lib/css' import { getTempoEnv, isTestnet } from '#lib/env' import SquareSquare from '~icons/lucide/square-square' +import { ThemeToggle } from '#comps/ThemeToggle' export function Header(): React.JSX.Element { const tempoEnv = getTempoEnv() @@ -32,6 +33,7 @@ export function Header(): React.JSX.Element {
+
diff --git a/apps/explorer/src/comps/Sphere.tsx b/apps/explorer/src/comps/Sphere.tsx index fb88df51a..acff060fa 100644 --- a/apps/explorer/src/comps/Sphere.tsx +++ b/apps/explorer/src/comps/Sphere.tsx @@ -23,7 +23,7 @@ export function Sphere(props: Sphere.Props) { }, []) return ( -
+
setTheme(nextTheme)} + className="flex items-center justify-center size-[28px] text-secondary hover:text-primary transition-colors press-down cursor-pointer" + title={`Theme: ${theme}`} + aria-label={`Current theme: ${theme}. Click to change.`} + > + + + ) +} diff --git a/apps/explorer/src/lib/theme.tsx b/apps/explorer/src/lib/theme.tsx new file mode 100644 index 000000000..da757e8a5 --- /dev/null +++ b/apps/explorer/src/lib/theme.tsx @@ -0,0 +1,88 @@ +import * as React from 'react' + +export type Theme = 'light' | 'dark' + +interface ThemeContextValue { + theme: Theme + setTheme: (theme: Theme) => void +} + +const ThemeContext = React.createContext(null) + +export function ThemeProvider(props: { children: React.ReactNode }) { + const [theme, setThemeState] = React.useState('light') + const hasManualPreference = React.useRef(false) + const setTheme = React.useCallback((nextTheme: Theme) => { + hasManualPreference.current = true + setThemeState(nextTheme) + }, []) + + React.useEffect(() => { + if (typeof window === 'undefined') return + const readStoredTheme = (): Theme | undefined => { + try { + const stored = window.localStorage.getItem('theme') + if (stored === 'light' || stored === 'dark') { + return stored + } + } catch { + return undefined + } + return undefined + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const storedTheme = readStoredTheme() + const applyTheme = (matches: boolean) => { + setThemeState(matches ? 'dark' : 'light') + } + + if (storedTheme) { + hasManualPreference.current = true + setThemeState(storedTheme) + } else { + applyTheme(mediaQuery.matches) + } + const handler = (event: MediaQueryListEvent) => { + if (hasManualPreference.current) return + applyTheme(event.matches) + } + mediaQuery.addEventListener('change', handler) + return () => mediaQuery.removeEventListener('change', handler) + }, []) + + React.useEffect(() => { + if (typeof window === 'undefined') return + if (!hasManualPreference.current) return + try { + window.localStorage.setItem('theme', theme) + } catch { + // Ignore storage errors. + } + }, [theme]) + + React.useEffect(() => { + const root = document.documentElement + root.classList.remove('scheme-light!', 'scheme-dark!') + + if (theme === 'light') { + root.classList.add('scheme-light!') + } else if (theme === 'dark') { + root.classList.add('scheme-dark!') + } + }, [theme]) + + return ( + + {props.children} + + ) +} + +export function useTheme() { + const context = React.useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} diff --git a/apps/explorer/src/routes/__root.tsx b/apps/explorer/src/routes/__root.tsx index f03e786f6..b426c9950 100644 --- a/apps/explorer/src/routes/__root.tsx +++ b/apps/explorer/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { AddressHighlightProvider } from '#comps/AddressHighlight' import { BreadcrumbsProvider } from '#comps/Breadcrumbs' import { ErrorBoundary } from '#comps/ErrorBoundary' import { IntroSeenProvider } from '#comps/Intro' +import { ThemeProvider } from '#lib/theme' import { TokenListMembershipProvider } from '#comps/TokenListMembership' import { OG_BASE_URL } from '#lib/og' import { ProgressLine } from '#comps/ProgressLine' @@ -106,27 +107,57 @@ export const Route = createRootRouteWithContext<{ rel: 'stylesheet', href: css, }, + { + rel: 'icon', + type: 'image/svg+xml', + href: '/favicon-light.svg', + media: '(prefers-color-scheme: light)', + }, { rel: 'icon', type: 'image/svg+xml', href: '/favicon-dark.svg', + media: '(prefers-color-scheme: dark)', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32-light.png', + media: '(prefers-color-scheme: dark)', }, { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32-dark.png', + media: '(prefers-color-scheme: light)', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16-light.png', + media: '(prefers-color-scheme: dark)', }, { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16-dark.png', + media: '(prefers-color-scheme: light)', + }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/favicon-light.png', + media: '(prefers-color-scheme: light)', }, { rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon-dark.png', + media: '(prefers-color-scheme: dark)', }, ], }), @@ -367,7 +398,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { }) return ( - + @@ -382,7 +413,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { - {children} + + {children} + diff --git a/apps/explorer/src/routes/styles.css b/apps/explorer/src/routes/styles.css index 1ab3f83e6..82bfb497e 100644 --- a/apps/explorer/src/routes/styles.css +++ b/apps/explorer/src/routes/styles.css @@ -2,13 +2,15 @@ @import "tw-animate-css"; @import "./fonts.css"; +@custom-variant dark (&:is(.scheme-light-dark *)); + @theme { /* Background Colors */ --color-background-inverse: light-dark(#202020, #fcfcfc); --background-color-inverse: var(--color-background-inverse); - --color-background-primary: light-dark(#191919, #191919); + --color-background-primary: light-dark(#f8f8f8, #111111); --background-color-primary: var(--color-background-primary); - --color-background-surface: light-dark(#1a1a1a, #1a1a1a); + --color-background-surface: light-dark(#ffffff, #1a1a1a); --background-color-surface: var(--color-background-surface); --color-background-alt: light-dark(#fcfcfc, #161616); --background-alt: var(--color-background-alt); @@ -23,11 +25,11 @@ --color-frame: var(--color-frame-background); /* Content/Text Colors */ - --color-content-primary: light-dark(#ffffff, #ffffff); + --color-content-primary: light-dark(#0a0a0a, #f5f5f5); --content-primary: var(--color-content-primary); - --color-content-secondary: light-dark(#6e6e6e, #6e6e6e); + --color-content-secondary: light-dark(#737373, #a0a0a0); --content-secondary: var(--color-content-secondary); - --color-content-tertiary: light-dark(#6e6e6e, #6e6e6e); + --color-content-tertiary: light-dark(#8d8d8d, #6e6e6e); --content-tertiary: var(--color-content-tertiary); --color-content-positive: light-dark(#16a34a, #22c55e); --content-positive: var(--color-content-positive); @@ -55,11 +57,11 @@ --color-focus: var(--color-accent); /* Border Colors */ - --color-border-primary: light-dark(#232323, #232323); + --color-border-primary: light-dark(#f0f0f0, #262626); --border-primary: var(--color-border-primary); - --color-border-secondary: light-dark(#232323, #232323); + --color-border-secondary: light-dark(#f8f8f8, #1a1a1a); --border-secondary: var(--color-border-secondary); - --color-border-tertiary: light-dark(#232323, #232323); + --color-border-tertiary: light-dark(#e5e5e5, #2e2e2e); --border-tertiary: var(--color-border-tertiary); --color-border-negative: light-dark(#dc2626, #ef4444); --border-negative: var(--color-border-negative); @@ -91,17 +93,17 @@ /* Card */ --color-card: light-dark(#f9f9f9, #222222); --color-card-header: light-dark(#f9f9f9, #191919); - --color-card-border: light-dark(#232323, #232323); + --color-card-border: light-dark(#e0e0e0, #2a2a2a); /* Other Colors */ - --color-base-background: light-dark(#191919, #191919); - --color-base-plane: light-dark(#1a1a1a, #1a1a1a); - --color-base-plane-background: light-dark(#1a1a1a, #1a1a1a); + --color-base-background: light-dark(#fcfcfc, #191919); + --color-base-plane: light-dark(#fcfcfc, #222222); + --color-base-plane-background: light-dark(#f9f9f9, #222222); --color-base-plane-interactive: light-dark(#f8f8f8, #1c1c1c); - --color-base-alt: light-dark(#232323, #232323); - --color-base-border: light-dark(#232323, #232323); - --color-base-content: light-dark(#ffffff, #ffffff); - --color-base-content-secondary: light-dark(#6e6e6e, #6e6e6e); + --color-base-alt: light-dark(#e5e5e5, #2a2a2a); + --color-base-border: light-dark(#e0e0e0, #2a2a2a); + --color-base-content: light-dark(#202020, #eeeeee); + --color-base-content-secondary: light-dark(#7b7b7b, #7b7b7b); --color-base-content-positive: light-dark(#30a46c, #30a46c); --color-base-content-negative: light-dark(#e5484d, #e5484d); --color-distinct: light-dark(#e8e8e8, #313131); @@ -116,7 +118,7 @@ html, :host { background-color: var(--color-base-background); color: var(--color-primary); - color-scheme: dark; + color-scheme: light dark; } :root { @@ -125,7 +127,7 @@ html, @layer base { html { - color-scheme: dark; + color-scheme: light dark; } } @@ -161,7 +163,7 @@ input[type="number"] { } @utility text-ui-meta { - color: #fff; + color: var(--color-content-primary); text-align: center; leading-trim: both; text-edge: cap; @@ -255,6 +257,15 @@ input[type="number"] { } } +@utility sphere-container { + mask-image: linear-gradient(to bottom, black 60%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); + + &:is(.scheme-light\! *) { + opacity: 0.3; + } +} + @utility scrollbar-gutter-stable { scrollbar-gutter: stable; }