Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ import './public/css/font.css'
import './public/css/bgTransparent.css'

const preview: Preview = {
globalTypes: {
theme: {
description: 'Site color theme',
toolbar: {
title: 'Theme',
icon: 'mirror',
items: [
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: 'light',
},
decorators: [
(Story, { globals }) => {
document.documentElement.setAttribute('data-theme', globals.theme)
return Story()
},
],
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
Expand Down
2 changes: 1 addition & 1 deletion src/app/_layout/_components/consentBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const ConsentBanner = ({
return (
<section
aria-label={region}
className="fixed inset-x-3 bottom-3 z-50 mx-auto max-w-xl rounded-md border border-line bg-paper-card p-5 shadow-[0_8px_30px_hsl(var(--ink)/0.12)] sm:inset-x-6 sm:bottom-6"
className="fixed inset-x-3 bottom-3 z-50 mx-auto max-w-xl rounded-md border border-line bg-paper-card p-5 shadow-[0_8px_30px_hsl(var(--shadow)/0.12)] sm:inset-x-6 sm:bottom-6"
>
<p className="font-mono text-xs leading-5 text-ink-soft">
{text}{' '}
Expand Down
10 changes: 5 additions & 5 deletions src/app/_layout/_components/siteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Container } from '@/components/Container'
import { Brand } from './brand'
import { LanguageToggle } from './languageToggle'
import { Navigation } from './navigation'
import { ThemeToggle } from './themeToggle'

const SiteHeader = ({ locale }: { locale: Locale }) => {
const { ui } = getContent(locale)
Expand All @@ -19,11 +20,10 @@ const SiteHeader = ({ locale }: { locale: Locale }) => {
</a>
<Container className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 py-4 md:h-16 md:flex-nowrap md:py-0">
<Brand href={homeAnchor(locale, 'top')} />
<LanguageToggle
locale={locale}
label={ui.language}
className="md:order-last"
/>
<div className="flex items-center gap-4 md:order-last">
<LanguageToggle locale={locale} label={ui.language} />
<ThemeToggle labels={ui.theme} />
</div>
<Navigation
items={ui.nav}
label={ui.primaryNav}
Expand Down
65 changes: 65 additions & 0 deletions src/app/_layout/_components/themeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client'

import { useSyncExternalStore } from 'react'
import { cn } from '@/utils/cn'
import { Monitor, Moon, Sun } from 'lucide-react'

import type { SiteContent } from '@/config/content'
import { Theme, themes } from '@/config/theme'
import { trackable } from '@/lib/analytics'
import { readTheme, setTheme, subscribeTheme } from '@/lib/theme'

const icons: Record<Theme, typeof Sun> = {
system: Monitor,
light: Sun,
dark: Moon,
}

type ThemeToggleProps = {
labels: SiteContent['ui']['theme']
className?: string
}

const ThemeToggle = ({ labels, className }: ThemeToggleProps) => {
const current = useSyncExternalStore<Theme>(
subscribeTheme,
readTheme,
() => 'system'
)

return (
<div
role="group"
aria-label={labels.label}
className={cn(
'flex items-center gap-0.5 rounded-md border border-line p-0.5',
className
)}
>
{themes.map((theme) => {
const Icon = icons[theme]
const active = theme === current

return (
<button
key={theme}
type="button"
aria-pressed={active}
title={labels[theme]}
onClick={() => setTheme(theme)}
{...trackable('theme_change', { theme })}
className={cn(
'grid h-6 w-6 place-items-center rounded transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-pen',
active ? 'bg-pen-soft text-ink' : 'text-ink-subtle hover:text-pen'
)}
>
<Icon aria-hidden="true" className="h-3.5 w-3.5" strokeWidth={2} />
<span className="sr-only">{labels[theme]}</span>
</button>
)
})}
</div>
)
}

export { ThemeToggle }
25 changes: 25 additions & 0 deletions src/app/_layout/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
--line: 43 12% 83%;
--pen: 224 69% 46%;
--signal: 146 64% 38%;
--shadow: 220 13% 10%;

--background: var(--paper);
--foreground: var(--ink);
Expand Down Expand Up @@ -60,6 +61,30 @@
font-family: var(--font-sans);
}

:root {
color-scheme: light;
}

:root[data-theme='dark'] {
color-scheme: dark;

--paper: 40 6% 8%;
--paper-card: 40 6% 10.5%;
--ink: 42 24% 91%;
--ink-soft: 40 9% 76%;
--ink-subtle: 40 5% 61%;
--line: 40 5% 20%;
--pen: 222 88% 73%;
--signal: 146 50% 55%;
--shadow: 0 0% 0%;

--muted: 40 5% 14%;
--muted-foreground: 40 9% 76%;
--primary: 40 5% 13%;
--primary-foreground: 42 24% 91%;
--secondary: 40 4% 36%;
}

html {
@apply custom-scroll;
scroll-behavior: smooth;
Expand Down
10 changes: 6 additions & 4 deletions src/app/_layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cn } from '@/utils/cn'

import { getContent } from '@/config/content'
import { htmlLang, languageRedirect, Locale, paths } from '@/config/i18n'
import { themeInit } from '@/config/theme'
import { fontHand, fontMono, fontSans } from '@/lib/fonts'
import { DevTools } from '@/components/DevTools'

Expand All @@ -22,11 +23,12 @@ const RootLayout = ({ locale, children }: RootLayoutProps) => {

return (
<html lang={htmlLang[locale]} suppressHydrationWarning>
{locale === 'en' && (
<head>
<head>
{locale === 'en' && (
<script dangerouslySetInnerHTML={{ __html: languageRedirect }} />
</head>
)}
)}
<script dangerouslySetInnerHTML={{ __html: themeInit }} />
</head>
<body
className={cn(
'min-h-screen bg-paper font-sans text-ink antialiased',
Expand Down
6 changes: 5 additions & 1 deletion src/app/_layout/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Metadata, Viewport } from 'next'

import { Locale, ogLocale, paths } from '@/config/i18n'
import { __site } from '@/config/site'
import { themeColor } from '@/config/theme'

const { metadata: meta } = __site

Expand Down Expand Up @@ -86,7 +87,10 @@ const buildMetadata = (locale: Locale): Metadata => ({
})

const viewport: Viewport = {
themeColor: [{ media: '(prefers-color-scheme: light)', color: '#f7f6f2' }],
themeColor: [
{ media: '(prefers-color-scheme: light)', color: themeColor.light },
{ media: '(prefers-color-scheme: dark)', color: themeColor.dark },
],
}

export { buildMetadata, viewport }
2 changes: 1 addition & 1 deletion src/components/Mockups/window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type WindowProps = OptChildClassNameProps & {
const Window = ({ children, className, chrome = true }: WindowProps) => (
<div
className={cn(
'rounded-md border border-line bg-paper-card p-4 text-[11px] text-ink shadow-[0_1px_2px_hsl(var(--ink)/0.04)]',
'rounded-md border border-line bg-paper-card p-4 text-[11px] text-ink shadow-[0_1px_2px_hsl(var(--shadow)/0.04)]',
className
)}
>
Expand Down
1 change: 1 addition & 0 deletions src/config/content/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const en: SiteContent = {
{ id: 'contact', label: 'Contact', href: homeAnchor('en', 'contact') },
],
language: 'Language',
theme: { label: 'Theme', system: 'System', light: 'Light', dark: 'Dark' },
newTab: '(opens in a new tab)',
stack: 'Stack',
toolbox: 'Toolbox',
Expand Down
1 change: 1 addition & 0 deletions src/config/content/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const pt: SiteContent = {
{ id: 'contact', label: 'Contato', href: homeAnchor('pt', 'contact') },
],
language: 'Idioma',
theme: { label: 'Tema', system: 'Sistema', light: 'Claro', dark: 'Escuro' },
newTab: '(abre em nova aba)',
stack: 'Stack',
toolbox: 'Ferramentas',
Expand Down
1 change: 1 addition & 0 deletions src/config/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type Ui = {
primaryNav: string
nav: LinkItem[]
language: string
theme: { label: string; system: string; light: string; dark: string }
newTab: string
stack: string
toolbox: string
Expand Down
4 changes: 2 additions & 2 deletions src/config/privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const en: Policy = {
'the site that referred you.',
],
after: [
'Your answer to the consent banner is kept in your browser’s local storage, under the key “rafer.consent”, so you aren’t asked again. If you pick a language, that choice is kept the same way, under “rafer.lang”. Neither ever leaves your device.',
'Your answer to the consent banner is kept in your browser’s local storage, under the key “rafer.consent”, so you aren’t asked again. If you pick a language or a light or dark theme, those choices are kept the same way, under “rafer.lang” and “rafer.theme”. None of them ever leaves your device.',
],
},
{
Expand Down Expand Up @@ -142,7 +142,7 @@ const pt: Policy = {
'o site que trouxe você até aqui.',
],
after: [
'Sua resposta ao aviso de consentimento fica guardada no armazenamento local do seu navegador, na chave “rafer.consent”, para que a pergunta não se repita. Se você escolher um idioma, essa escolha fica guardada do mesmo jeito, na chave “rafer.lang”. Nenhuma das duas sai do seu dispositivo.',
'Sua resposta ao aviso de consentimento fica guardada no armazenamento local do seu navegador, na chave “rafer.consent”, para que a pergunta não se repita. Se você escolher um idioma ou um tema claro ou escuro, essas escolhas ficam guardadas do mesmo jeito, nas chaves “rafer.lang” e “rafer.theme”. Nenhuma delas sai do seu dispositivo.',
],
},
{
Expand Down
42 changes: 42 additions & 0 deletions src/config/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const themes = ['system', 'light', 'dark'] as const

type Theme = (typeof themes)[number]
type Scheme = Exclude<Theme, 'system'>

const THEME_KEY = 'rafer.theme'
const THEME_EVENT = 'rafer:theme'
const DARK_QUERY = '(prefers-color-scheme: dark)'

const themeColor: Record<Scheme, string> = {
light: '#f7f6f3',
dark: '#161513',
}

const isTheme = (value: unknown): value is Theme =>
themes.includes(value as Theme)

const themeInit = `(function () {
try {
var media = matchMedia('${DARK_QUERY}');
var apply = function () {
var saved = localStorage.getItem('${THEME_KEY}');
var dark = saved === 'dark' || (saved !== 'light' && media.matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
};
apply();
media.addEventListener('change', function () {
if (!localStorage.getItem('${THEME_KEY}')) apply();
});
} catch (e) {}
})();`

export {
DARK_QUERY,
isTheme,
THEME_EVENT,
THEME_KEY,
themeColor,
themeInit,
themes,
}
export type { Scheme, Theme }
1 change: 1 addition & 0 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type AnalyticsEvents = {
contact_click: { method: string }
nav_click: { section: string }
language_change: { language: string }
theme_change: { theme: string }
section_view: { section: string }
}

Expand Down
76 changes: 76 additions & 0 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
DARK_QUERY,
isTheme,
Scheme,
Theme,
THEME_EVENT,
THEME_KEY,
themeColor,
} from '@/config/theme'

const readTheme = (): Theme => {
try {
const saved = window.localStorage.getItem(THEME_KEY)
return isTheme(saved) ? saved : 'system'
} catch {
return 'system'
}
}

const systemScheme = (): Scheme =>
window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'

const resolveScheme = (theme: Theme): Scheme =>
theme === 'system' ? systemScheme() : theme

const withoutTransitions = (change: () => void) => {
const style = document.createElement('style')
style.textContent = '*,*::before,*::after{transition:none!important}'
document.head.appendChild(style)
change()
void window.getComputedStyle(document.body).opacity
style.remove()
}

const applyTheme = (theme: Theme) => {
const scheme = resolveScheme(theme)

withoutTransitions(() => {
document.documentElement.setAttribute('data-theme', scheme)
})

document
.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
.forEach((meta) => {
const media: Scheme = meta.media.includes('dark') ? 'dark' : 'light'
meta.content = themeColor[theme === 'system' ? media : scheme]
})
}

const setTheme = (theme: Theme) => {
try {
if (theme === 'system') window.localStorage.removeItem(THEME_KEY)
else window.localStorage.setItem(THEME_KEY, theme)
} catch {}

applyTheme(theme)
window.dispatchEvent(new Event(THEME_EVENT))
}

const subscribeTheme = (onChange: () => void) => {
const onStorage = (event: StorageEvent) => {
if (event.key !== THEME_KEY && event.key !== null) return
applyTheme(readTheme())
onChange()
}

window.addEventListener('storage', onStorage)
window.addEventListener(THEME_EVENT, onChange)

return () => {
window.removeEventListener('storage', onStorage)
window.removeEventListener(THEME_EVENT, onChange)
}
}

export { readTheme, setTheme, subscribeTheme }
Loading
Loading