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:
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user