diff --git a/DECISIONS.md b/DECISIONS.md index 616b9ab5..970ec6b5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -715,6 +715,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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 (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". diff --git a/app/globals.css b/app/globals.css index 748234ba..f37433b6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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)); diff --git a/app/layout.tsx b/app/layout.tsx index 1bf28756..734366c3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 > - - - {children} - - - - - + + + + {children} + + + + + + diff --git a/components/providers/PaletteProvider.tsx b/components/providers/PaletteProvider.tsx new file mode 100644 index 00000000..69a6e4cc --- /dev/null +++ b/components/providers/PaletteProvider.tsx @@ -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(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 ( + <> +