Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/explorer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/explorer/src/comps/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -32,6 +33,7 @@ export function Header(): React.JSX.Element {
</div>
<Header.Search />
<div className="relative z-1 print:hidden flex items-center gap-[8px]">
<ThemeToggle />
<Header.BlockNumber />
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/explorer/src/comps/Sphere.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function Sphere(props: Sphere.Props) {
}, [])

return (
<div className="fixed bottom-0 w-full pointer-events-none overflow-hidden h-[300px] z-0 print:hidden hidden sm:block">
<div className="fixed bottom-0 w-full pointer-events-none overflow-hidden h-[300px] z-0 print:hidden hidden sm:block sphere-container">
<div
ref={containerRef}
className="absolute top-0 z-0 w-full flex justify-center pointer-events-none"
Expand Down
22 changes: 22 additions & 0 deletions apps/explorer/src/comps/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type * as React from 'react'
import { useTheme } from '#lib/theme'
import Sun from '~icons/lucide/sun'
import Moon from '~icons/lucide/moon'

export function ThemeToggle(): React.JSX.Element {
const { theme, setTheme } = useTheme()
const nextTheme = theme === 'dark' ? 'light' : 'dark'
const Icon = theme === 'dark' ? Moon : Sun

return (
<button
type="button"
onClick={() => 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.`}
>
<Icon className="size-[16px]" />
</button>
)
}
88 changes: 88 additions & 0 deletions apps/explorer/src/lib/theme.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeContextValue | null>(null)

export function ThemeProvider(props: { children: React.ReactNode }) {
const [theme, setThemeState] = React.useState<Theme>('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 (
<ThemeContext.Provider value={{ theme, setTheme }}>
{props.children}
</ThemeContext.Provider>
)
}

export function useTheme() {
const context = React.useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
37 changes: 35 additions & 2 deletions apps/explorer/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)',
},
],
}),
Expand Down Expand Up @@ -367,7 +398,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
})

return (
<html lang="en" className="scrollbar-gutter-stable">
<html lang="en" className="scheme-light-dark scrollbar-gutter-stable">
<head>
<HeadContent />
</head>
Expand All @@ -382,7 +413,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<TokenListMembershipProvider>
<BreadcrumbsProvider>
<AddressHighlightProvider>
<IntroSeenProvider>{children}</IntroSeenProvider>
<IntroSeenProvider>
<ThemeProvider>{children}</ThemeProvider>
</IntroSeenProvider>
</AddressHighlightProvider>
</BreadcrumbsProvider>
</TokenListMembershipProvider>
Expand Down
49 changes: 30 additions & 19 deletions apps/explorer/src/routes/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -116,7 +118,7 @@ html,
:host {
background-color: var(--color-base-background);
color: var(--color-primary);
color-scheme: dark;
color-scheme: light dark;
}

:root {
Expand All @@ -125,7 +127,7 @@ html,

@layer base {
html {
color-scheme: dark;
color-scheme: light dark;
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading