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:
Mattsson
2026-08-01 16:02:12 +02:00
committed by GitHub
parent f24e1076cf
commit d684e3c440
10 changed files with 422 additions and 8 deletions
+86
View File
@@ -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')}