feat: add theme palettes (#1326)
Add Neutral, Indigo, Forest, and Sand palettes independently of Light, Dark, and System. Persist and hydrate the selection, add the accessible settings picker, and include the validated review fixes for keyboard navigation and Swedish copy.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
DEFAULT_PALETTE,
|
||||
PALETTE_STORAGE_KEY,
|
||||
PALETTE_VALUES,
|
||||
isPalette,
|
||||
type Palette,
|
||||
} from '@/lib/theme/palettes'
|
||||
|
||||
const PALETTE_CHANGE_EVENT = 'accounted:palette-change'
|
||||
|
||||
interface PaletteContextValue {
|
||||
palette: Palette
|
||||
setPalette: (palette: Palette) => void
|
||||
}
|
||||
|
||||
const PaletteContext = createContext<PaletteContextValue | null>(null)
|
||||
|
||||
const paletteInitScript = `
|
||||
(function () {
|
||||
var palette = ${JSON.stringify(DEFAULT_PALETTE)};
|
||||
try {
|
||||
var stored = window.localStorage.getItem(${JSON.stringify(PALETTE_STORAGE_KEY)});
|
||||
if (${JSON.stringify(PALETTE_VALUES)}.indexOf(stored) !== -1) palette = stored;
|
||||
} catch (_) {}
|
||||
document.documentElement.dataset.palette = palette;
|
||||
})();
|
||||
`
|
||||
|
||||
function applyPalette(palette: Palette) {
|
||||
document.documentElement.dataset.palette = palette
|
||||
}
|
||||
|
||||
function getPaletteSnapshot(): Palette {
|
||||
const palette = document.documentElement.dataset.palette
|
||||
return isPalette(palette) ? palette : DEFAULT_PALETTE
|
||||
}
|
||||
|
||||
function subscribeToPalette(onStoreChange: () => void) {
|
||||
function handlePaletteChange() {
|
||||
onStoreChange()
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== PALETTE_STORAGE_KEY && event.key !== null) return
|
||||
const palette = isPalette(event.newValue) ? event.newValue : DEFAULT_PALETTE
|
||||
applyPalette(palette)
|
||||
onStoreChange()
|
||||
}
|
||||
|
||||
window.addEventListener(PALETTE_CHANGE_EVENT, handlePaletteChange)
|
||||
window.addEventListener('storage', handleStorage)
|
||||
return () => {
|
||||
window.removeEventListener(PALETTE_CHANGE_EVENT, handlePaletteChange)
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
}
|
||||
}
|
||||
|
||||
export function PaletteProvider({ children }: { children: ReactNode }) {
|
||||
const palette = useSyncExternalStore(
|
||||
subscribeToPalette,
|
||||
getPaletteSnapshot,
|
||||
() => DEFAULT_PALETTE,
|
||||
)
|
||||
|
||||
const setPalette = useCallback((nextPalette: Palette) => {
|
||||
applyPalette(nextPalette)
|
||||
try {
|
||||
window.localStorage.setItem(PALETTE_STORAGE_KEY, nextPalette)
|
||||
} catch {
|
||||
// The active tab still keeps the selected palette when storage is blocked.
|
||||
}
|
||||
window.dispatchEvent(new Event(PALETTE_CHANGE_EVENT))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ palette, setPalette }), [palette, setPalette])
|
||||
|
||||
return (
|
||||
<>
|
||||
<script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: paletteInitScript }} />
|
||||
<PaletteContext.Provider value={value}>{children}</PaletteContext.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePalette() {
|
||||
const context = useContext(PaletteContext)
|
||||
if (!context) throw new Error('usePalette must be used within PaletteProvider')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PALETTE_VALUES, type Palette } from '@/lib/theme/palettes'
|
||||
|
||||
interface PalettePickerProps {
|
||||
value: Palette
|
||||
onChange: (palette: Palette) => void
|
||||
labels: Record<Palette, string>
|
||||
'aria-label': string
|
||||
}
|
||||
|
||||
export function PalettePicker({
|
||||
value,
|
||||
onChange,
|
||||
labels,
|
||||
'aria-label': ariaLabel,
|
||||
}: PalettePickerProps) {
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const direction =
|
||||
event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
||||
? 1
|
||||
: event.key === 'ArrowLeft' || event.key === 'ArrowUp'
|
||||
? -1
|
||||
: 0
|
||||
|
||||
if (direction === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
const currentIndex = PALETTE_VALUES.indexOf(value)
|
||||
const nextIndex =
|
||||
(currentIndex + direction + PALETTE_VALUES.length) % PALETTE_VALUES.length
|
||||
const nextPalette = PALETTE_VALUES[nextIndex]
|
||||
onChange(nextPalette)
|
||||
event.currentTarget
|
||||
.querySelector<HTMLButtonElement>(`[data-palette-option="${nextPalette}"]`)
|
||||
?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="grid w-full grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
>
|
||||
{PALETTE_VALUES.map((palette) => {
|
||||
const selected = palette === value
|
||||
return (
|
||||
<button
|
||||
key={palette}
|
||||
data-palette-option={palette}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onChange(palette)}
|
||||
className={cn(
|
||||
'flex min-h-10 items-center gap-1 rounded-lg border px-2 py-2 text-left text-xs transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
selected
|
||||
? 'border-foreground bg-secondary/60 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-secondary/35 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-palette-preview={palette}
|
||||
aria-hidden="true"
|
||||
className="flex h-6 w-8 shrink-0 items-center gap-1 rounded-full border border-border bg-background px-1"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="h-2 w-2 rounded-full border border-border bg-secondary" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{labels[palette]}</span>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn('ml-auto h-3 w-3 shrink-0', selected ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,11 +25,15 @@ import { useSettings } from '@/components/settings/useSettings'
|
||||
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
|
||||
import { PalettePicker } from '@/components/settings/PalettePicker'
|
||||
import { usePalette } from '@/components/providers/PaletteProvider'
|
||||
import type { Palette } from '@/lib/theme/palettes'
|
||||
|
||||
export function AccountSettingsContent() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { palette, setPalette } = usePalette()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
|
||||
const { settings } = useSettings()
|
||||
@@ -123,6 +127,13 @@ export function AccountSettingsContent() {
|
||||
en: tCommon('language_english'),
|
||||
}
|
||||
|
||||
const paletteLabels: Record<Palette, string> = {
|
||||
neutral: tSettings('palette_neutral'),
|
||||
indigo: tSettings('palette_indigo'),
|
||||
forest: tSettings('palette_forest'),
|
||||
sand: tSettings('palette_sand'),
|
||||
}
|
||||
|
||||
const nameUnchanged = !fullName.trim() || fullName.trim() === initialName
|
||||
|
||||
return (
|
||||
@@ -195,6 +206,21 @@ export function AccountSettingsContent() {
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={tSettings('palette_label')}
|
||||
help={tSettings('palette_description')}
|
||||
align="baseline"
|
||||
>
|
||||
{mounted && (
|
||||
<PalettePicker
|
||||
value={palette}
|
||||
onChange={setPalette}
|
||||
labels={paletteLabels}
|
||||
aria-label={tSettings('palette_label')}
|
||||
/>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={tSettings('section_language')}
|
||||
help={tSettings('language_description')}
|
||||
|
||||
Reference in New Issue
Block a user