diff --git a/src/a11y.css b/src/a11y.css new file mode 100644 index 0000000..93adcd5 --- /dev/null +++ b/src/a11y.css @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * inbuxa: high contrast and reduced motion (admin UX roadmap, item 13). + * Set on by src/lib/a11yPrefs.ts from the reader's choice or the + * system's. High contrast uses its own colors over any palette: text at + * 7:1 or better (WCAG AAA), borders and inputs that stand out, and a thick + * focus ring. Imported after palettes.css; the selectors are also more + * specific than a palette's, so they win in light and dark alike. + */ + +:root[data-contrast='high']:not(.dark) { + --background: #ffffff; + --foreground: #000000; + --card: #ffffff; + --card-foreground: #000000; + --popover: #ffffff; + --popover-foreground: #000000; + --content-background: #ffffff; + --primary: #00524d; + --primary-foreground: #ffffff; + --secondary: #ebebeb; + --secondary-foreground: #000000; + --muted: #ebebeb; + --muted-foreground: #333333; + --accent: #d3efec; + --accent-foreground: #002e2b; + --highlight: #8a4300; + --highlight-soft: #fde6cc; + --destructive: #a3001b; + --destructive-foreground: #ffffff; + --border: #595959; + --input: #3d3d3d; + --ring: #000000; +} + +:root.dark[data-contrast='high'] { + --background: #000000; + --foreground: #ffffff; + --card: #0a0a0a; + --card-foreground: #ffffff; + --popover: #0a0a0a; + --popover-foreground: #ffffff; + --content-background: #000000; + --primary: #7ff0e8; + --primary-foreground: #000000; + --secondary: #1f1f1f; + --secondary-foreground: #ffffff; + --muted: #1f1f1f; + --muted-foreground: #d9d9d9; + --accent: #003b38; + --accent-foreground: #c2fffa; + --highlight: #ffb866; + --highlight-soft: #3d2600; + --destructive: #ff9494; + --destructive-foreground: #000000; + --border: #a6a6a6; + --input: #c4c4c4; + --ring: #ffffff; +} + +/* Where keyboard focus is, plainly. */ +:root[data-contrast='high'] :focus-visible { + outline: 3px solid var(--ring) !important; + outline-offset: 2px !important; +} + +/* Links read as links without relying on color. */ +:root[data-contrast='high'] main a:not([role]):not(:has(> svg:only-child)) { + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Reduced motion: no animations or transitions, and no smooth scrolling. */ +:root[data-motion='reduce'] *, +:root[data-motion='reduce'] *::before, +:root[data-motion='reduce'] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; +} + +/* The "skip to content" link: out of sight until it has keyboard focus. */ +.skip-link { + position: absolute; + left: 0.75rem; + top: -4rem; + z-index: 100; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + padding: 0.5rem 0.875rem; + font-weight: 500; +} +.skip-link:focus { + top: 0.75rem; +} diff --git a/src/components/common/KeyboardShortcuts.tsx b/src/components/common/KeyboardShortcuts.tsx new file mode 100644 index 0000000..27c9299 --- /dev/null +++ b/src/components/common/KeyboardShortcuts.tsx @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: keyboard shortcuts (admin UX roadmap, item 13) and the `?` sheet + * that lists them (item 12). Single keys never fire while typing in a field, + * with a modifier held, or while a dialog is open. + */ + +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { SETTINGS_OVERVIEW_VIEW } from '@/lib/settingsLayout'; +import { GO_WINDOW_MS, isTyping, OPEN_COMMAND_BAR, OPEN_SHORTCUTS, pageKeys } from '@/lib/shortcuts'; + +/** The `g` destinations: a fixed page, a whole section, or one view wherever the schema puts it. */ +const GO: Record = { + h: { path: '/Management/CustomComponent/Dashboard' }, + p: { view: 'x:Account/User' }, + d: { view: 'x:Domain' }, + s: { path: `/Settings/${SETTINGS_OVERVIEW_VIEW}` }, + a: { section: 'Account' }, +}; + +function Keys({ keys }: { keys: string[] }) { + return ( + + {keys.map((k) => ( + + {k} + + ))} + + ); +} + +export function KeyboardShortcuts() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const [sheetOpen, setSheetOpen] = useState(false); + const goUntil = useRef(0); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return; + if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return; + + if (goUntil.current > Date.now()) { + goUntil.current = 0; + const dest = GO[e.key.toLowerCase()]; + if (!dest) return; + e.preventDefault(); + if (dest.path) navigate(dest.path); + else if (dest.section) navigate(`/${dest.section}`); + else if (dest.view && viewToSection[dest.view]) navigate(`/${viewToSection[dest.view]}/${dest.view}`); + return; + } + + if (e.key === '?') { + e.preventDefault(); + setSheetOpen(true); + } else if (e.key === '/') { + e.preventDefault(); + window.dispatchEvent(new Event(OPEN_COMMAND_BAR)); + } else if (e.key === 'g') { + goUntil.current = Date.now() + GO_WINDOW_MS; + } else { + const handler = pageKeys.get(e.key); + if (handler) { + e.preventDefault(); + handler(); + } + } + } + const openSheet = () => setSheetOpen(true); + document.addEventListener('keydown', onKeyDown); + window.addEventListener(OPEN_SHORTCUTS, openSheet); + return () => { + document.removeEventListener('keydown', onKeyDown); + window.removeEventListener(OPEN_SHORTCUTS, openSheet); + }; + }, [navigate, viewToSection]); + + const rows: { keys: string[]; label: string }[] = [ + { keys: ['?'], label: t('shortcuts.sheet', 'Show these shortcuts') }, + { keys: ['/'], label: t('shortcuts.commandBar', 'Search pages and run actions') }, + { keys: ['Ctrl', 'K'], label: t('shortcuts.commandBarToo', 'The same, from anywhere, even in a field') }, + { keys: ['g', 'h'], label: t('shortcuts.goHome', 'Go to the dashboard') }, + { keys: ['g', 'p'], label: t('shortcuts.goPeople', 'Go to people') }, + { keys: ['g', 'd'], label: t('shortcuts.goDomains', 'Go to domains') }, + { keys: ['g', 's'], label: t('shortcuts.goSettings', 'Go to settings') }, + { keys: ['g', 'a'], label: t('shortcuts.goAccount', 'Go to your account') }, + { keys: ['n'], label: t('shortcuts.new', 'Create a new one, on a list') }, + { keys: ['Esc'], label: t('shortcuts.escape', 'Close a dialog or menu') }, + ]; + + return ( + + + + {t('shortcuts.title', 'Keyboard shortcuts')} + + {t('shortcuts.hint', 'Single keys work anywhere except while you type in a field.')} + + +
    + {rows.map((r) => ( +
  • + {r.label} + +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 862fda1..8c4b193 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -10,9 +10,12 @@ import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import * as LucideIcons from 'lucide-react'; -const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate } = LucideIcons; +const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate, Accessibility, Keyboard } = + LucideIcons; import { Button } from '@/components/ui/button'; import { CommandPalette } from '@/components/common/CommandPalette'; +import { OPEN_COMMAND_BAR, OPEN_SHORTCUTS } from '@/lib/shortcuts'; +import { isContrastPref, isMotionPref } from '@/lib/a11yPrefs'; import { DropdownMenu, DropdownMenuContent, @@ -61,6 +64,10 @@ export function TopBar() { const setPalette = useUIStore((s) => s.setPalette); const adminLayout = useUIStore((s) => s.adminLayout); const setAdminLayout = useUIStore((s) => s.setAdminLayout); + const contrast = useUIStore((s) => s.contrast); + const setContrast = useUIStore((s) => s.setContrast); + const motion = useUIStore((s) => s.motion); + const setMotion = useUIStore((s) => s.setMotion); const toggleSidebar = useUIStore((s) => s.toggleSidebar); const setActiveSection = useUIStore((s) => s.setActiveSection); const activeSection = useUIStore((s) => s.activeSection); @@ -81,8 +88,14 @@ export function TopBar() { setPaletteOpen((open) => !open); } } + // inbuxa: `/` asks for it too (keyboard shortcuts, roadmap item 13) + const open = () => setPaletteOpen(true); document.addEventListener('keydown', handleGlobalKeyDown); - return () => document.removeEventListener('keydown', handleGlobalKeyDown); + window.addEventListener(OPEN_COMMAND_BAR, open); + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown); + window.removeEventListener(OPEN_COMMAND_BAR, open); + }; }, []); const navigableLayouts = schema @@ -284,6 +297,43 @@ export function TopBar() { + + {/* inbuxa: contrast and motion, over any theme (roadmap item 13) */} + + + + {t('a11y.menu', 'Accessibility')} + + + {t('a11y.contrast', 'Contrast')} + isContrastPref(v) && setContrast(v)}> + + {t('a11y.followSystem', 'Follow the system')} + + + {t('a11y.contrastStandard', 'Standard')} + + {t('a11y.contrastHigh', 'High')} + + + {t('a11y.motion', 'Motion')} + isMotionPref(v) && setMotion(v)}> + + {t('a11y.followSystem', 'Follow the system')} + + {t('a11y.motionReduce', 'Reduce')} + {t('a11y.motionFull', 'Full')} + + + window.dispatchEvent(new Event(OPEN_SHORTCUTS))}> + + {t('a11y.shortcuts', 'Keyboard shortcuts')} + + + + diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index e9143f2..38158c9 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -60,6 +60,7 @@ import { ObjectPicker } from '@/components/common/ObjectPicker'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { toast } from '@/hooks/use-toast'; import { deleteWithUndo } from '@/components/common/undoableDelete'; +import { useShortcut } from '@/lib/shortcuts'; import { isPendingDelete, usePendingDeletes } from '@/lib/pendingDeletes'; import { friendlySetError } from '@/lib/jmapErrors'; import { coerceLabel } from '@/lib/objectOptions'; @@ -852,6 +853,16 @@ export function DynamicList({ viewName }: DynamicListProps) { ); const canCreate = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Create') : false; + // inbuxa: `n` creates a new one, as the Create button does (roadmap item 13) + useShortcut( + 'n', + canCreate && resolved?.obj.objectType.type === 'object' + ? () => { + const section = viewToSection[viewName]; + if (section) navigate(`/${section}/${viewName}/new`); + } + : null, + ); const canUpdate = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Update') : false; const canDelete = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Destroy') : false; @@ -1088,6 +1099,12 @@ export function DynamicList({ viewName }: DynamicListProps) { ); } + /** inbuxa: what a screen reader calls a row (roadmap item 13). */ + function rowName(item: Record): string { + const shown = schema && resolved ? item[getDisplayProperty(schema, resolved.obj.objectName)] : undefined; + return typeof shown === 'string' && shown ? shown : String(item.id ?? ''); + } + function renderItemActions(item: Record): React.ReactNode { // inbuxa: Lock… or Unlock… for a person (AL-1) const lockItems = directoryLocks.menuItems(item); @@ -1114,7 +1131,13 @@ export function DynamicList({ viewName }: DynamicListProps) { return ( - @@ -1348,8 +1371,16 @@ export function DynamicList({ viewName }: DynamicListProps) { return ( handleRowClick(item)} + // inbuxa: a row opens from the keyboard too (roadmap item 13) + tabIndex={0} + onKeyDown={(e) => { + if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + handleRowClick(item); + } + }} > {hasMassActions && ( e.stopPropagation()}> diff --git a/src/i18n/en.json b/src/i18n/en.json index cdeb8cd..9d4976e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -308,7 +308,8 @@ "sort": "Sort", "unknownError": "Unknown error", "emptyTitle": "Nothing here yet", - "showingRange": "Showing {{from}}-{{to}} {{name}}" + "showingRange": "Showing {{from}}-{{to}} {{name}}", + "rowActions": "Actions for {{name}}" }, "login": { "continue": "Continue", @@ -2149,5 +2150,31 @@ "undo": "Undo", "count_one": "{{count}} {{name}}", "count_other": "{{count}} {{name}}" + }, + "shortcuts": { + "sheet": "Show these shortcuts", + "commandBar": "Search pages and run actions", + "commandBarToo": "The same, from anywhere, even in a field", + "goHome": "Go to the dashboard", + "goPeople": "Go to people", + "goDomains": "Go to domains", + "goSettings": "Go to settings", + "goAccount": "Go to your account", + "new": "Create a new one, on a list", + "escape": "Close a dialog or menu", + "title": "Keyboard shortcuts", + "hint": "Single keys work anywhere except while you type in a field." + }, + "a11y": { + "menu": "Accessibility", + "contrast": "Contrast", + "followSystem": "Follow the system", + "contrastStandard": "Standard", + "contrastHigh": "High", + "motion": "Motion", + "motionReduce": "Reduce", + "motionFull": "Full", + "shortcuts": "Keyboard shortcuts", + "skip": "Skip to content" } } diff --git a/src/index.css b/src/index.css index be6f511..4414502 100644 --- a/src/index.css +++ b/src/index.css @@ -9,6 +9,7 @@ @import 'tailwindcss'; @import './palettes.css'; +@import './a11y.css'; :root { /* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */ diff --git a/src/lib/a11yPrefs.test.ts b/src/lib/a11yPrefs.test.ts new file mode 100644 index 0000000..edd4788 --- /dev/null +++ b/src/lib/a11yPrefs.test.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { applyA11yPrefs, effectiveContrast, effectiveMotion, isContrastPref, isMotionPref } from './a11yPrefs'; +import { isTyping } from './shortcuts'; + +describe('a11y preferences', () => { + afterEach(() => { + delete document.documentElement.dataset.contrast; + delete document.documentElement.dataset.motion; + }); + + it('"system" follows the system; a choice overrides it', () => { + expect(effectiveContrast('system', true)).toBe('high'); + expect(effectiveContrast('system', false)).toBe('standard'); + expect(effectiveContrast('standard', true)).toBe('standard'); + expect(effectiveContrast('high', false)).toBe('high'); + expect(effectiveMotion('system', true)).toBe('reduce'); + expect(effectiveMotion('system', false)).toBe('full'); + expect(effectiveMotion('full', true)).toBe('full'); + expect(effectiveMotion('reduce', false)).toBe('reduce'); + }); + + it('marks for the stylesheet', () => { + applyA11yPrefs('high', 'reduce'); + expect(document.documentElement.dataset.contrast).toBe('high'); + expect(document.documentElement.dataset.motion).toBe('reduce'); + applyA11yPrefs('standard', 'full'); + expect(document.documentElement.dataset.contrast).toBeUndefined(); + expect(document.documentElement.dataset.motion).toBeUndefined(); + }); + + it('rejects stored values it does not know', () => { + expect(isContrastPref('high')).toBe(true); + expect(isContrastPref('max')).toBe(false); + expect(isMotionPref('reduce')).toBe(true); + expect(isMotionPref(undefined)).toBe(false); + }); +}); + +describe('isTyping', () => { + it('is true in text fields and false on buttons and checkboxes', () => { + const el = (html: string) => { + const box = document.createElement('div'); + box.innerHTML = html; + return box.firstElementChild; + }; + expect(isTyping(el(''))).toBe(true); + expect(isTyping(el(''))).toBe(true); + expect(isTyping(el(''))).toBe(true); + expect(isTyping(el(''))).toBe(false); + expect(isTyping(el(''))).toBe(false); + expect(isTyping(el('
'))).toBe(true); + expect(isTyping(null)).toBe(false); + }); +}); diff --git a/src/lib/a11yPrefs.ts b/src/lib/a11yPrefs.ts new file mode 100644 index 0000000..11c3fea --- /dev/null +++ b/src/lib/a11yPrefs.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: contrast and motion, each the reader's own choice or the system's + * (admin UX roadmap, item 13). They sit on top of any palette and of light + * or dark: `data-contrast="high"` and `data-motion="reduce"` on , read + * by a11y.css. + */ + +export type ContrastPref = 'system' | 'standard' | 'high'; +export type MotionPref = 'system' | 'reduce' | 'full'; + +export const isContrastPref = (v: unknown): v is ContrastPref => v === 'system' || v === 'standard' || v === 'high'; +export const isMotionPref = (v: unknown): v is MotionPref => v === 'system' || v === 'reduce' || v === 'full'; + +const query = (q: string) => (typeof window !== 'undefined' ? (window.matchMedia?.(q) ?? null) : null); + +/** What the choice comes to, given what the system asks for. */ +export function effectiveContrast(pref: ContrastPref, systemWantsMore: boolean): 'standard' | 'high' { + return pref === 'system' ? (systemWantsMore ? 'high' : 'standard') : pref; +} + +export function effectiveMotion(pref: MotionPref, systemWantsLess: boolean): 'full' | 'reduce' { + return pref === 'system' ? (systemWantsLess ? 'reduce' : 'full') : pref; +} + +let current: { contrast: ContrastPref; motion: MotionPref } = { contrast: 'system', motion: 'system' }; + +function apply() { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const high = effectiveContrast(current.contrast, query('(prefers-contrast: more)')?.matches ?? false) === 'high'; + const reduce = + effectiveMotion(current.motion, query('(prefers-reduced-motion: reduce)')?.matches ?? false) === 'reduce'; + if (high) root.dataset.contrast = 'high'; + else delete root.dataset.contrast; + if (reduce) root.dataset.motion = 'reduce'; + else delete root.dataset.motion; +} + +let watching = false; + +/** Sets the choices and keeps "system" in step with the system's own setting. */ +export function applyA11yPrefs(contrast: ContrastPref, motion: MotionPref) { + current = { contrast, motion }; + apply(); + if (!watching) { + watching = true; + query('(prefers-contrast: more)')?.addEventListener?.('change', apply); + query('(prefers-reduced-motion: reduce)')?.addEventListener?.('change', apply); + } +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000..36b0143 --- /dev/null +++ b/src/lib/shortcuts.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: what the keyboard shortcuts share (admin UX roadmap, item 13); the + * handler and the `?` sheet are components/common/KeyboardShortcuts.tsx. + */ + +import { useEffect, useRef } from 'react'; + +/** Ask the top bar to open the command bar. */ +export const OPEN_COMMAND_BAR = 'inbuxa:open-command-bar'; +/** Open the shortcut sheet (the account menu's Keyboard shortcuts). */ +export const OPEN_SHORTCUTS = 'inbuxa:open-shortcuts'; + +/** How long `g` waits for its second key. */ +export const GO_WINDOW_MS = 1500; + +type Handler = () => void; +export const pageKeys = new Map(); + +/** A page's own single-key shortcut, while it is on screen (e.g. `n` for a new item). */ +export function useShortcut(key: string, handler: Handler | null) { + const latest = useRef(handler); + useEffect(() => { + latest.current = handler; + }); + const active = handler !== null; + useEffect(() => { + if (!active) return; + const run = () => latest.current?.(); + pageKeys.set(key, run); + return () => { + if (pageKeys.get(key) === run) pageKeys.delete(key); + }; + }, [key, active]); +} + +/** Whether a key press belongs to whatever has focus, not to the shortcuts. */ +export function isTyping(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + if (tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (tag === 'INPUT') { + const type = (target as HTMLInputElement).type; + return !['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].includes(type); + } + return target.closest('[role="textbox"], [role="combobox"], [role="searchbox"]') !== null; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 8d68bfb..0c545c2 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -19,6 +19,7 @@ import { getApiBaseUrl } from '@/services/api'; import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSettings'; import { setLocale } from '@/i18n'; import { TopBar } from '@/components/layout/TopBar'; +import { KeyboardShortcuts } from '@/components/common/KeyboardShortcuts'; import { Sidebar } from '@/components/layout/Sidebar'; import { SectionNav } from '@/components/layout/SectionNav'; import { MainContent } from '@/components/layout/MainContent'; @@ -324,13 +325,20 @@ export default function AdminPanel() { return (
+ {/* inbuxa: keyboard users can jump past the menus (roadmap item 13) */} + + {t('a11y.skip', 'Skip to content')} + + {useSectionNav && currentLayout && }
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 2ed25e1..e9e3844 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -9,6 +9,7 @@ import { queueAccountTheme } from '@/lib/accountSettings'; import { applyPalette, DEFAULT_PALETTE, isPaletteId, type PaletteId } from '@/lib/palettes'; +import { applyA11yPrefs, isContrastPref, isMotionPref, type ContrastPref, type MotionPref } from '@/lib/a11yPrefs'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -34,6 +35,9 @@ interface UIState { palette: PaletteId; /** INBUXA: the navigation shell, the reader's own choice. */ adminLayout: AdminLayout; + /** inbuxa: high contrast and reduced motion, or the system's setting (roadmap item 13). */ + contrast: ContrastPref; + motion: MotionPref; sidebarOpen: boolean; /** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */ sidebarCollapsed: boolean; @@ -43,6 +47,8 @@ interface UIState { setTheme: (theme: Theme) => void; setPalette: (palette: PaletteId) => void; setAdminLayout: (layout: AdminLayout) => void; + setContrast: (contrast: ContrastPref) => void; + setMotion: (motion: MotionPref) => void; /** INBUXA: take the theme stored with the account, without writing it back. */ applyAccountTheme: (palette: PaletteId | null, mode: 'system' | 'light' | 'dark' | null) => void; toggleSidebar: () => void; @@ -68,6 +74,8 @@ export const useUIStore = create()( sidebarCollapsed: false, palette: DEFAULT_PALETTE, adminLayout: DEFAULT_ADMIN_LAYOUT, + contrast: 'system', + motion: 'system', activeSection: '', toggleTheme: () => { @@ -101,6 +109,16 @@ export const useUIStore = create()( set({ adminLayout: layout }); }, + setContrast: (contrast) => { + applyA11yPrefs(contrast, get().motion); + set({ contrast }); + }, + + setMotion: (motion) => { + applyA11yPrefs(get().contrast, motion); + set({ motion }); + }, + applyAccountTheme: (palette, mode) => { const next: Partial = {}; if (palette) { @@ -130,6 +148,8 @@ export const useUIStore = create()( theme: state.theme, palette: state.palette, adminLayout: state.adminLayout, + contrast: state.contrast, + motion: state.motion, sidebarCollapsed: state.sidebarCollapsed, }), onRehydrateStorage: () => { @@ -138,6 +158,9 @@ export const useUIStore = create()( applyThemeClass(state.theme); applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE); if (!isAdminLayout(state.adminLayout)) state.adminLayout = DEFAULT_ADMIN_LAYOUT; + if (!isContrastPref(state.contrast)) state.contrast = 'system'; + if (!isMotionPref(state.motion)) state.motion = 'system'; + applyA11yPrefs(state.contrast, state.motion); } }; },