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
+2
View File
@@ -715,6 +715,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-30] HOVER_REVEAL_CLASS gains focus-within:opacity-100. focus-visible only matches the element itself, so using the constant on a non-focusable wrapper <span> (the skattekonto Matcha/Bokför cluster, which previously hand-rolled focus-within) left those buttons transparent while a keyboard user tabbed through them. Fixed in the shared constant rather than per call site, since the constant is documented as the one true row-control helper and every wrapper use has the same trap.
[2026-07-30] Report-vocabulary synonyms live in ReportDescriptor.searchTerms, NOT in the command-palette keywords, when they are words another report already owns. "stäm av"/"avstämning" on the huvudbok palette entry hijacked Enter from Bankavstämning, because the palette auto-selects the first hit and huvudbok is listed above it. The library search shows a list and has no such failure mode, so broad task-vocabulary belongs there.
[2026-08-01] Palette is persisted as an independent root data attribute instead of new next-themes names: Light, Dark, and System values remain backward-compatible, and System can still follow the OS while each palette supplies both light and dark tokens.
[2026-08-01] Palette refinement increases hue in primary, frame, secondary, muted, and border tokens only: Indigo, Forest, and Sand read more clearly without recoloring success, warning, destructive, or chart semantics.
[2026-07-30] A missing org number on either side of the Bokio connect probe does NOT block the connection; only a confident mismatch does. Accounted allows companies without an org number and a provider response can omit it, so blocking on absence would refuse legitimate connections to prevent a mismatch we have no evidence of. Absence instead falls through to labelling the consent with the company the credentials actually opened, which is what lets the user catch it. Same reasoning applied to keeping 429/5xx from the probe out of the invalid-credentials mapping: a provider outage must not read as "your token is wrong".
+151
View File
@@ -156,6 +156,157 @@
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
}
/* Color palettes are independent of appearance mode. next-themes continues
to own the .dark class while data-palette replaces only semantic chrome
tokens. Preview swatches share these rules so their colors cannot drift. */
:root[data-palette="neutral"],
[data-palette-preview="neutral"] {
--background: 0 0% 100%;
--primary: 0 0% 9%;
--secondary: 40 11% 89%;
--border: 45 5% 85%;
}
.dark[data-palette="neutral"],
.dark [data-palette-preview="neutral"] {
--background: 0 0% 7%;
--primary: 0 0% 87%;
--secondary: 0 0% 14%;
--border: 45 4% 18%;
}
:root[data-palette="indigo"],
[data-palette-preview="indigo"] {
--background: 228 42% 98%;
--foreground: 229 30% 14%;
--frame: 228 52% 94%;
--card: 0 0% 100%;
--card-foreground: 229 30% 14%;
--popover: 0 0% 100%;
--popover-foreground: 229 30% 14%;
--primary: 231 48% 34%;
--primary-foreground: 225 40% 98%;
--secondary: 229 42% 89%;
--secondary-foreground: 229 30% 14%;
--muted: 228 36% 93%;
--muted-foreground: 229 20% 38%;
--accent: 228 36% 93%;
--accent-foreground: 229 30% 14%;
--border: 229 24% 78%;
--input: 229 24% 78%;
--ring: 231 48% 34%;
}
.dark[data-palette="indigo"],
.dark [data-palette-preview="indigo"] {
--background: 231 28% 7%;
--foreground: 228 22% 89%;
--frame: 232 34% 4%;
--card: 231 24% 11%;
--card-foreground: 228 22% 89%;
--popover: 231 24% 11%;
--popover-foreground: 228 22% 89%;
--primary: 228 58% 74%;
--primary-foreground: 231 25% 10%;
--secondary: 231 28% 16%;
--secondary-foreground: 228 22% 89%;
--muted: 230 23% 18%;
--muted-foreground: 228 16% 64%;
--accent: 230 23% 18%;
--accent-foreground: 228 22% 89%;
--border: 231 20% 26%;
--input: 231 20% 26%;
--ring: 228 58% 74%;
}
:root[data-palette="forest"],
[data-palette-preview="forest"] {
--background: 145 25% 98%;
--foreground: 150 24% 12%;
--frame: 140 38% 93%;
--card: 0 0% 100%;
--card-foreground: 150 24% 12%;
--popover: 0 0% 100%;
--popover-foreground: 150 24% 12%;
--primary: 151 42% 28%;
--primary-foreground: 140 30% 98%;
--secondary: 140 30% 87%;
--secondary-foreground: 150 24% 12%;
--muted: 140 24% 92%;
--muted-foreground: 148 18% 34%;
--accent: 140 24% 92%;
--accent-foreground: 150 24% 12%;
--border: 142 20% 76%;
--input: 142 20% 76%;
--ring: 151 42% 28%;
}
.dark[data-palette="forest"],
.dark [data-palette-preview="forest"] {
--background: 150 22% 6%;
--foreground: 140 15% 88%;
--frame: 150 28% 4%;
--card: 150 19% 10%;
--card-foreground: 140 15% 88%;
--popover: 150 19% 10%;
--popover-foreground: 140 15% 88%;
--primary: 145 38% 66%;
--primary-foreground: 150 18% 8%;
--secondary: 150 22% 16%;
--secondary-foreground: 140 15% 88%;
--muted: 150 20% 18%;
--muted-foreground: 143 14% 62%;
--accent: 150 20% 18%;
--accent-foreground: 140 15% 88%;
--border: 150 17% 26%;
--input: 150 17% 26%;
--ring: 145 38% 66%;
}
:root[data-palette="sand"],
[data-palette-preview="sand"] {
--background: 42 52% 97%;
--foreground: 28 23% 13%;
--frame: 39 56% 91%;
--card: 42 45% 98%;
--card-foreground: 28 23% 13%;
--popover: 42 45% 98%;
--popover-foreground: 28 23% 13%;
--primary: 24 36% 30%;
--primary-foreground: 42 35% 98%;
--secondary: 38 42% 85%;
--secondary-foreground: 28 23% 13%;
--muted: 40 38% 90%;
--muted-foreground: 28 18% 34%;
--accent: 40 38% 90%;
--accent-foreground: 28 23% 13%;
--border: 36 28% 74%;
--input: 36 28% 74%;
--ring: 24 36% 30%;
}
.dark[data-palette="sand"],
.dark [data-palette-preview="sand"] {
--background: 28 22% 7%;
--foreground: 39 20% 88%;
--frame: 25 26% 4%;
--card: 29 20% 10%;
--card-foreground: 39 20% 88%;
--popover: 29 20% 10%;
--popover-foreground: 39 20% 88%;
--primary: 35 44% 66%;
--primary-foreground: 28 18% 9%;
--secondary: 30 25% 16%;
--secondary-foreground: 39 20% 88%;
--muted: 30 22% 18%;
--muted-foreground: 34 15% 63%;
--accent: 30 22% 18%;
--accent-foreground: 39 20% 88%;
--border: 31 18% 26%;
--input: 31 18% 26%;
--ring: 35 44% 66%;
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
+11 -8
View File
@@ -9,6 +9,7 @@ import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip-provider";
import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
import { ThemeProvider } from "@/components/theme-provider";
import { PaletteProvider } from "@/components/providers/PaletteProvider";
import { SWRProvider } from "@/components/providers/SWRProvider";
import { ScrollbarReveal } from "@/components/ScrollbarReveal";
import { ensureInitialized } from "@/lib/init";
@@ -89,14 +90,16 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
<SWRProvider>
<TooltipProvider>
{children}
<Toaster />
<DeployReloadPrompt />
<ScrollbarReveal />
</TooltipProvider>
</SWRProvider>
<PaletteProvider>
<SWRProvider>
<TooltipProvider>
{children}
<Toaster />
<DeployReloadPrompt />
<ScrollbarReveal />
</TooltipProvider>
</SWRProvider>
</PaletteProvider>
</ThemeProvider>
</NextIntlClientProvider>
<SpeedInsights />
+99
View File
@@ -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
}
+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')}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_PALETTE,
PALETTE_STORAGE_KEY,
PALETTE_VALUES,
isPalette,
} from '@/lib/theme/palettes'
describe('palettes', () => {
it('keeps neutral as the backward-compatible default', () => {
expect(DEFAULT_PALETTE).toBe('neutral')
expect(PALETTE_STORAGE_KEY).toBe('accounted-palette')
})
it.each(PALETTE_VALUES)('accepts the %s palette', (palette) => {
expect(isPalette(palette)).toBe(true)
})
it.each([null, undefined, '', 'light', 'dark', 'sepia', 1])(
'rejects unsupported palette value %s',
(value) => {
expect(isPalette(value)).toBe(false)
},
)
})
+10
View File
@@ -0,0 +1,10 @@
export const PALETTE_VALUES = ['neutral', 'indigo', 'forest', 'sand'] as const
export type Palette = (typeof PALETTE_VALUES)[number]
export const DEFAULT_PALETTE: Palette = 'neutral'
export const PALETTE_STORAGE_KEY = 'accounted-palette'
export function isPalette(value: unknown): value is Palette {
return typeof value === 'string' && PALETTE_VALUES.includes(value as Palette)
}
+6
View File
@@ -403,6 +403,12 @@
"name_saved": "Name updated",
"name_save_failed": "Could not save name",
"section_appearance": "Appearance",
"palette_label": "Color palette",
"palette_description": "Changes accent and surface tones. Light, dark, and system continue to work independently.",
"palette_neutral": "Neutral",
"palette_indigo": "Indigo",
"palette_forest": "Forest",
"palette_sand": "Sand",
"section_language": "Language",
"language_description": "Choose the language for the interface. Invoices and reports always stay in Swedish.",
"language_saved": "Language updated",
+6
View File
@@ -403,6 +403,12 @@
"name_saved": "Namnet har uppdaterats",
"name_save_failed": "Kunde inte spara namnet",
"section_appearance": "Utseende",
"palette_label": "Färgpalett",
"palette_description": "Byter accentfärg och yttoner. Ljust, mörkt och system fortsätter fungera oberoende.",
"palette_neutral": "Neutral",
"palette_indigo": "Indigo",
"palette_forest": "Skog",
"palette_sand": "Sand",
"section_language": "Språk",
"language_description": "Välj språket för gränssnittet. Fakturor och rapporter förblir alltid på svenska.",
"language_saved": "Språket har uppdaterats",