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;
}