diff --git a/NOTICE b/NOTICE index 284947e7e..3f04ecd8a 100644 --- a/NOTICE +++ b/NOTICE @@ -39,3 +39,14 @@ Third-party artwork: (https://github.com/jdecked/twemoji), Copyright 2019 Twitter, Inc and other contributors, licensed under CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/). + +Bundled fonts (app/fonts), offered in Settings > Accessibility: + +- Atkinson Hyperlegible Next, Copyright 2020 Braille Institute of America, + Inc., licensed under the SIL Open Font License 1.1 +- Lexend, Copyright 2018 The Lexend Project Authors, licensed under the SIL + Open Font License 1.1 +- OpenDyslexic, Copyright 2019-2020 Abbie Gonzalez, licensed under the SIL + Open Font License 1.1 + +The woff2 files come from Fontsource (https://fontsource.org). diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 33134aff3..fdf2fa044 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -17,6 +17,7 @@ import { import { withBasePath } from "@/lib/browser-navigation"; import { locales, defaultLocale } from "@/i18n/routing"; import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite"; +import { accessibilityBootScript } from "@/lib/accessibility-settings"; import "../globals.css"; import "../print.css"; @@ -312,6 +313,7 @@ export default async function RootLayout({ } catch (e) { document.documentElement.classList.add('light'); } + ${accessibilityBootScript()} })(); `, }} diff --git a/app/accessibility.css b/app/accessibility.css new file mode 100644 index 000000000..b4dcf6a84 --- /dev/null +++ b/app/accessibility.css @@ -0,0 +1,255 @@ +/* + * Settings > Accessibility, the CSS half (lib/accessibility-settings.ts writes + * the data attributes on ). + * + * Each "follow the system" option has two parts: a media query that applies + * unless the user overrode it (`:root:not([data-x="..."])`), and an attribute + * rule for an explicit choice. Overrides use `:root[...]`, which outranks the + * `:root` and `.dark` blocks themes compile to (lib/theme-compiler.ts). + */ + +/* ---------- Bundled fonts (SIL Open Font License, see NOTICE) ---------- */ + +@font-face { + font-family: "Atkinson Hyperlegible Next"; + font-style: normal; + font-display: swap; + font-weight: 200 800; + src: url("./fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Atkinson Hyperlegible Next"; + font-style: normal; + font-display: swap; + font-weight: 200 800; + src: url("./fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Lexend"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("./fonts/lexend-latin-ext-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Lexend"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("./fonts/lexend-latin-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "OpenDyslexic"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("./fonts/opendyslexic-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "OpenDyslexic"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url("./fonts/opendyslexic-latin-700-normal.woff2") format("woff2"); +} + +/* The fonts cover Latin script; other scripts fall through to the stack. */ +:root[data-app-font="atkinson"] { + --font-sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; +} +:root[data-app-font="lexend"] { + --font-sans: "Lexend", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; +} +:root[data-app-font="opendyslexic"] { + --font-sans: "OpenDyslexic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; +} +:root[data-app-font] body { + font-family: var(--font-sans); +} + +/* ---------- Text spacing (WCAG 1.4.8, 1.4.12) ---------- */ + +:root[data-line-spacing="relaxed"] { --a11y-line-height: 1.6; } +:root[data-line-spacing="loose"] { --a11y-line-height: 1.9; } +:root[data-line-spacing] body *:not(svg, svg *) { + line-height: var(--a11y-line-height) !important; +} +:root[data-letter-spacing="wide"] { --a11y-letter-spacing: 0.05em; } +:root[data-letter-spacing="wider"] { --a11y-letter-spacing: 0.12em; } +:root[data-letter-spacing] body *:not(svg, svg *) { + letter-spacing: var(--a11y-letter-spacing) !important; +} +:root[data-word-spacing="wide"] body *:not(svg, svg *) { + word-spacing: 0.16em !important; +} +:root[data-paragraph-spacing="wide"] body p { + margin-block-end: 1.5em !important; +} + +/* ---------- Links ---------- */ + +:root[data-underline-links="on"] a[href]:not([role]), +:root[data-underline-links="on"] .a11y-link { + text-decoration-line: underline !important; + text-underline-offset: 0.15em; +} + +/* ---------- Focus (WCAG 2.4.7, 2.4.11, 2.4.13) ---------- + * + * One outline for every focusable element, drawn as a real outline rather + * than a box-shadow ring: forced-colors mode removes box-shadows, and an + * outline also keeps its width at any zoom. It outranks the utilities that + * used to switch the outline off (outline-none plus a ring). Elements that + * only take focus from script (tabindex="-1": lists, panes, dialog bodies) + * keep no outline unless "always show focus" is on. + */ +:root { + --focus-ring-width: 2px; + --focus-ring-offset: 2px; + --focus-ring-color: var(--color-primary); +} +:root[data-focus-ring="thick"] { + --focus-ring-width: 3px; +} +:root[data-focus-ring="extra"] { + --focus-ring-width: 5px; + --focus-ring-offset: 3px; +} +:focus-visible:not([tabindex="-1"]), +:root[data-focus-always="on"] :focus:not([tabindex="-1"]) { + outline: var(--focus-ring-width) solid var(--focus-ring-color) !important; + outline-offset: var(--focus-ring-offset) !important; +} +/* Inside a scroll container a positive offset is clipped; draw it inside. */ +:is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"]):focus-visible:not([tabindex="-1"]) { + outline-offset: calc(var(--focus-ring-width) * -1) !important; +} +/* The focused element scrolls clear of sticky headers and the bottom bar. */ +:root { + scroll-padding-block: 4rem; +} + +/* ---------- More contrast (WCAG 1.4.6 as a setting) ---------- */ + +@media (prefers-contrast: more) { + :root:not([data-contrast="normal"]) { + --color-foreground: #000000; + --color-muted-foreground: #1f2937; + --color-border: #4b5563; + --color-input: #4b5563; + --color-ring: #000000; + --color-sidebar-border: #4b5563; + --color-primary: #1d4ed8; + --color-unread: #1d4ed8; + --color-destructive: #b91c1c; + --color-accent-foreground: #1e3a8a; + --color-selection-foreground: #1e3a8a; + --color-popover-foreground: #000000; + --color-card-foreground: #000000; + --color-sidebar-foreground: #000000; + --focus-ring-color: #000000; + } + .dark:not([data-contrast="normal"]) { + --color-foreground: #ffffff; + --color-muted-foreground: #e5e7eb; + --color-border: #9ca3af; + --color-input: #9ca3af; + --color-ring: #ffffff; + --color-sidebar-border: #9ca3af; + --color-primary: #93c5fd; + --color-primary-foreground: #000000; + --color-unread: #93c5fd; + --color-destructive: #fca5a5; + --color-accent-foreground: #dbeafe; + --color-selection-foreground: #dbeafe; + --color-popover-foreground: #ffffff; + --color-card-foreground: #ffffff; + --color-sidebar-foreground: #ffffff; + --focus-ring-color: #ffffff; + } + :root:not([data-contrast="normal"]) a[href]:not([role]) { + text-decoration-line: underline; + } +} +:root[data-contrast="more"] { + --color-foreground: #000000; + --color-muted-foreground: #1f2937; + --color-border: #4b5563; + --color-input: #4b5563; + --color-ring: #000000; + --color-sidebar-border: #4b5563; + --color-primary: #1d4ed8; + --color-unread: #1d4ed8; + --color-destructive: #b91c1c; + --color-accent-foreground: #1e3a8a; + --color-selection-foreground: #1e3a8a; + --color-popover-foreground: #000000; + --color-card-foreground: #000000; + --color-sidebar-foreground: #000000; + --focus-ring-color: #000000; +} +html.dark[data-contrast="more"] { + --color-foreground: #ffffff; + --color-muted-foreground: #e5e7eb; + --color-border: #9ca3af; + --color-input: #9ca3af; + --color-ring: #ffffff; + --color-sidebar-border: #9ca3af; + --color-primary: #93c5fd; + --color-primary-foreground: #000000; + --color-unread: #93c5fd; + --color-destructive: #fca5a5; + --color-accent-foreground: #dbeafe; + --color-selection-foreground: #dbeafe; + --color-popover-foreground: #ffffff; + --color-card-foreground: #ffffff; + --color-sidebar-foreground: #ffffff; + --focus-ring-color: #ffffff; +} +:root[data-contrast="more"] a[href]:not([role]) { + text-decoration-line: underline; +} + +/* ---------- Reduced transparency ---------- */ + +@media (prefers-reduced-transparency: reduce) { + :root:not([data-transparency="full"]) * { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + :root:not([data-transparency="full"]) :is([class*="bg-background/"], [class*="bg-card/"], [class*="bg-popover/"], [class*="bg-sidebar/"], [class*="bg-muted/"]) { + background-color: var(--color-background) !important; + } +} +:root[data-transparency="reduce"] * { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} +:root[data-transparency="reduce"] :is([class*="bg-background/"], [class*="bg-card/"], [class*="bg-popover/"], [class*="bg-sidebar/"], [class*="bg-muted/"]) { + background-color: var(--color-background) !important; +} + +/* ---------- Large cursor ---------- */ + +:root[data-large-cursor="on"], +:root[data-large-cursor="on"] body { + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M4 2 L4 32 L12 25 L18 38 L24 35 L18 23 L29 23 Z' fill='black' stroke='white' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E") 4 2, auto; +} +:root[data-large-cursor="on"] :is(a[href], button:not(:disabled), [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"], [role="checkbox"], label, summary, select) { + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M13 3 a3 3 0 0 1 6 0 V17 l1-1 a3 3 0 0 1 5 2 l1-1 a3 3 0 0 1 5 2 l1-1 a3 3 0 0 1 5 2 V28 c0 5-4 10-10 10 h-5 c-4 0-6-2-8-5 L6 25 a3 3 0 0 1 5-4 l2 2 Z' fill='white' stroke='black' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E") 15 2, pointer; +} +:root[data-large-cursor="on"] :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"], [type="submit"]), textarea, [contenteditable="true"]) { + cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M14 4 h12 M20 4 V36 M14 36 h12' fill='none' stroke='white' stroke-width='7' stroke-linecap='round'/%3E%3Cpath d='M14 4 h12 M20 4 V36 M14 36 h12' fill='none' stroke='black' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E") 20 20, text; +} + +/* ---------- Forced colours (Windows contrast themes) ---------- */ + +@media (forced-colors: active) { + :focus-visible:not([tabindex="-1"]) { + outline-color: Highlight !important; + } +} diff --git a/app/fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2 b/app/fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2 new file mode 100644 index 000000000..d2b4185e2 Binary files /dev/null and b/app/fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2 differ diff --git a/app/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 b/app/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 new file mode 100644 index 000000000..a9ca4bd6f Binary files /dev/null and b/app/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 differ diff --git a/app/fonts/lexend-latin-ext-wght-normal.woff2 b/app/fonts/lexend-latin-ext-wght-normal.woff2 new file mode 100644 index 000000000..80eeee87e Binary files /dev/null and b/app/fonts/lexend-latin-ext-wght-normal.woff2 differ diff --git a/app/fonts/lexend-latin-wght-normal.woff2 b/app/fonts/lexend-latin-wght-normal.woff2 new file mode 100644 index 000000000..066c5172b Binary files /dev/null and b/app/fonts/lexend-latin-wght-normal.woff2 differ diff --git a/app/fonts/opendyslexic-latin-400-normal.woff2 b/app/fonts/opendyslexic-latin-400-normal.woff2 new file mode 100644 index 000000000..47e26d82a Binary files /dev/null and b/app/fonts/opendyslexic-latin-400-normal.woff2 differ diff --git a/app/fonts/opendyslexic-latin-700-normal.woff2 b/app/fonts/opendyslexic-latin-700-normal.woff2 new file mode 100644 index 000000000..2f04ad119 Binary files /dev/null and b/app/fonts/opendyslexic-latin-700-normal.woff2 differ diff --git a/app/globals.css b/app/globals.css index 4dd7d9d64..8e55371f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,7 @@ @import "tw-animate-css"; @import "./theme-inline.css"; +@import "./accessibility.css"; /* The app's font stack replaces Tailwind's default --font-sans, so the font-sans utility and body use the same stack, and a theme's @@ -74,8 +75,10 @@ --color-chart-4: #ef4444; --color-chart-5: #8b5cf6; - /* Settings variables */ - --font-size-base: 16px; + /* Settings variables. The text size is a percentage of the browser's own + default, so the browser's font size setting still counts (EN 301 549 + 9.7); Settings > Accessibility multiplies it. */ + --font-size-base: 100%; --list-item-height: 48px; --transition-duration: 0.2s; @@ -250,22 +253,24 @@ optgroup { } } -/* Minimalist scrollbar */ -::-webkit-scrollbar { +/* Minimalist scrollbar, unless the user asked for the browser's own wider + one in Settings > Accessibility: Chromium draws its native scrollbar only + while no ::-webkit-scrollbar rule matches. */ +:root:not([data-native-scrollbars="on"]) ::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { +:root:not([data-native-scrollbars="on"]) ::-webkit-scrollbar-track { background-color: transparent; } -::-webkit-scrollbar-thumb { +:root:not([data-native-scrollbars="on"]) ::-webkit-scrollbar-thumb { background-color: var(--color-border); border-radius: 9999px; } -::-webkit-scrollbar-thumb:hover { +:root:not([data-native-scrollbars="on"]) ::-webkit-scrollbar-thumb:hover { background-color: var(--color-muted-foreground); } @@ -740,11 +745,12 @@ optgroup { animation: cal-slide-in-left 0.25s ease-out; } -/* Reduced motion: respect user OS preference */ +/* Reduced motion: respect the OS preference unless the user turned Reduce + motion off in Settings > Accessibility. */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { + :root:not([data-motion="full"]) *, + :root:not([data-motion="full"]) *::before, + :root:not([data-motion="full"]) *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; /* Every element transitions `all` by default once it has a duration. @@ -756,10 +762,10 @@ optgroup { } } -/* The same when the user turns animations off in the settings (#1215). */ -[data-animations="off"] *, -[data-animations="off"] *::before, -[data-animations="off"] *::after { +/* The same when the user turns Reduce motion on in the settings (#1215). */ +[data-motion="reduce"] *, +[data-motion="reduce"] *::before, +[data-motion="reduce"] *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-property: all, visibility !important; diff --git a/components/icons/index.ts b/components/icons/index.ts index a05b8550a..4efb9bc5b 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -13,6 +13,7 @@ export type { TablerIcon as AppIcon, IconProps as AppIconProps } from '@tabler/i export { iconForName, toStoredIconName, toTablerName, POPULAR_ICON_NAMES, loadAllIconNames, TABLER_ICON_PREFIX } from './named'; export { + IconAccessible as Accessibility, IconActivity as Activity, IconAlertCircle as AlertCircle, IconAlertTriangle as AlertTriangle, diff --git a/components/settings/__tests__/accessibility-settings.test.tsx b/components/settings/__tests__/accessibility-settings.test.tsx new file mode 100644 index 000000000..251723983 --- /dev/null +++ b/components/settings/__tests__/accessibility-settings.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AccessibilitySettings } from '../accessibility-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { presetChanges } from '@/lib/accessibility-presets'; + +// Labels are their keys; plural/ICU arguments are appended so tests can see them. +vi.mock('next-intl', () => ({ + useTranslations: (ns?: string) => (key: string, values?: Record) => + `${ns ? `${ns}.` : ''}${key}${values ? ` ${JSON.stringify(values)}` : ''}`, +})); + +const settings = () => useSettingsStore.getState(); + +beforeEach(() => { + localStorage.clear(); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: true }); + settings().resetToDefaults(); +}); + +function presetButton(name: string) { + return screen.getByRole('button', { name: new RegExp(`settings\\.accessibility\\.presets\\.${name}\\.label`) }); +} + +describe('accessibility presets', () => { + it('changes several settings at once and lists what changed', () => { + render(); + fireEvent.click(presetButton('low-vision')); + expect(settings().fontSize).toBe('xx-large'); + expect(settings().contrast).toBe('more'); + expect(settings().largeCursor).toBe(true); + const summary = screen.getByTestId('preset-summary'); + expect(within(summary).getByText('settings.accessibility.text_size.label')).toBeTruthy(); + expect(within(summary).getByText('settings.accessibility.contrast.label')).toBeTruthy(); + }); + + it('undoes exactly what the preset changed', () => { + settings().updateSetting('fontSize', 'large'); + settings().updateSetting('underlineLinks', true); + render(); + fireEvent.click(presetButton('low-vision')); + expect(settings().fontSize).toBe('xx-large'); + fireEvent.click(screen.getByRole('button', { name: 'settings.accessibility.presets.undo' })); + expect(settings().fontSize).toBe('large'); + // Already on before the preset; the undo must not switch it off. + expect(settings().underlineLinks).toBe(true); + expect(settings().contrast).toBe('system'); + }); + + it('combines presets: a second one keeps what the first set', () => { + render(); + fireEvent.click(presetButton('dyslexia')); + fireEvent.click(presetButton('motion')); + expect(settings().appFont).toBe('atkinson'); + expect(settings().reduceMotion).toBe('on'); + }); + + it('offers to show the quick button after a preset', () => { + render(); + fireEvent.click(presetButton('motor')); + fireEvent.click(screen.getByRole('button', { name: 'settings.accessibility.presets.show_button' })); + expect(settings().showAccessibilityButton).toBe(true); + }); + + it('skips settings the admin locked and toast durations under the minimum', () => { + const state = { ...settings() } as unknown as Record; + const changes = presetChanges('hearing', state, (key) => key === 'emailNotificationSound'); + expect(changes.map((c) => c.key)).not.toContain('emailNotificationSound'); + + const motor = presetChanges('motor', state, () => false, (key, value) => key !== 'toastDuration' || (value as number) >= 60); + expect(motor.map((c) => c.key)).not.toContain('toastDuration'); + }); +}); + +describe('reset', () => { + it('puts the accessibility settings back to their defaults', () => { + settings().updateSetting('appFont', 'lexend'); + settings().updateSetting('fontSize', 'huge'); + settings().updateSetting('disableThreading', true); + render(); + fireEvent.click(screen.getByRole('button', { name: 'settings.accessibility.reset.button' })); + expect(settings().appFont).toBe('default'); + expect(settings().fontSize).toBe('medium'); + // A shared everyday preference is not an accessibility setting to reset. + expect(settings().disableThreading).toBe(true); + }); +}); + +describe('toast duration', () => { + it('only offers durations at or above the admin minimum', () => { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: { toastDuration: { min: 20 } } }, loaded: true }); + render(); + const select = screen.getAllByRole('combobox').find((el) => + Array.from((el as HTMLSelectElement).options).some((o) => o.textContent?.includes('until_closed')), + ) as HTMLSelectElement; + const values = Array.from(select.options).map((o) => o.value); + expect(values).toEqual(['20', '60', '0']); + }); +}); diff --git a/components/settings/__tests__/settings-palette-commands.test.tsx b/components/settings/__tests__/settings-palette-commands.test.tsx index 08a58972a..3d0be7c52 100644 --- a/components/settings/__tests__/settings-palette-commands.test.tsx +++ b/components/settings/__tests__/settings-palette-commands.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => nu vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null })); +vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => null })); const { SettingsApp } = await import('../settings-app'); const { useAuthStore } = await import('@/stores/auth-store'); diff --git a/components/settings/__tests__/settings-phone-deep-link.test.tsx b/components/settings/__tests__/settings-phone-deep-link.test.tsx index ee8ee0917..6d5ccd899 100644 --- a/components/settings/__tests__/settings-phone-deep-link.test.tsx +++ b/components/settings/__tests__/settings-phone-deep-link.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => nu vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () =>
appearance pane
})); +vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () =>
accessibility pane
})); vi.mock('@/components/settings/organize-settings', () => ({ OrganizeSettings: () =>
organize pane
})); const { SettingsApp } = await import('../settings-app'); diff --git a/components/settings/__tests__/settings-plugin-page.test.tsx b/components/settings/__tests__/settings-plugin-page.test.tsx index 1d6252d6c..e9b225c3a 100644 --- a/components/settings/__tests__/settings-plugin-page.test.tsx +++ b/components/settings/__tests__/settings-plugin-page.test.tsx @@ -28,6 +28,7 @@ vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => nu vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () =>
appearance pane
})); +vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () =>
accessibility pane
})); const { SettingsApp } = await import('../settings-app'); const registry = await import('@/lib/plugin-sandbox/registry'); diff --git a/components/settings/__tests__/settings-search-advanced.test.tsx b/components/settings/__tests__/settings-search-advanced.test.tsx index a0255588e..7e895ac12 100644 --- a/components/settings/__tests__/settings-search-advanced.test.tsx +++ b/components/settings/__tests__/settings-search-advanced.test.tsx @@ -23,6 +23,7 @@ vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => nu vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null })); +vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => null })); // The page is not drawn here, so no result finds its row. vi.mock('@/components/settings/message-list-settings', () => ({ MessageListSettings: () => null })); diff --git a/components/settings/__tests__/settings-search-anchors.test.tsx b/components/settings/__tests__/settings-search-anchors.test.tsx index 492e7180f..9f72444e9 100644 --- a/components/settings/__tests__/settings-search-anchors.test.tsx +++ b/components/settings/__tests__/settings-search-anchors.test.tsx @@ -39,6 +39,7 @@ const pages = { general: (await import('../general-settings')).GeneralSettings, notifications: (await import('../notification-settings')).NotificationSettings, appearance: (await import('../appearance-settings')).AppearanceSettings, + accessibility: (await import('../accessibility-settings')).AccessibilitySettings, message_list: (await import('../message-list-settings')).MessageListSettings, reading: (await import('../reading-settings')).ReadingSettings, composing: (await import('../composing-settings')).ComposingSettings, @@ -72,6 +73,7 @@ const CLIENT = new Proxy({}, { // The settings tabs and the components settings-app.tsx draws for them. const TAB_PAGES: Record = { account: ['account'], general: ['general'], notifications: ['notifications'], appearance: ['appearance'], + accessibility: ['accessibility'], message_list: ['message_list'], reading: ['reading'], composing: ['composing'], vacation: ['vacation'], filters: ['filters'], organize: ['organize'], security: ['security'], sharing: ['sharing'], recovery: ['recovery'], calendar: ['calendar', 'calendar2'], contacts: ['contacts', 'contacts2'], files: ['files'], addons: ['addons'], backup: ['backup'], diff --git a/components/settings/__tests__/settings-section-links.test.tsx b/components/settings/__tests__/settings-section-links.test.tsx index bfae802e1..63b29cfff 100644 --- a/components/settings/__tests__/settings-section-links.test.tsx +++ b/components/settings/__tests__/settings-section-links.test.tsx @@ -27,6 +27,7 @@ vi.mock('@/components/settings/about-data-settings', () => ({ AboutFooter: () =>
, })); vi.mock('@/components/settings/appearance-settings',() => ({ AppearanceSettings: () =>
appearance pane
})); +vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () =>
accessibility pane
})); vi.mock('@/components/settings/organize-settings', () => ({ OrganizeSettings: () => (
diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx new file mode 100644 index 000000000..2266fe2cf --- /dev/null +++ b/components/settings/accessibility-settings.tsx @@ -0,0 +1,601 @@ +"use client"; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { SettingsSection, SettingItem, RadioGroup, Select, ToggleSwitch } from './settings-section'; +import { Button } from '@/components/ui/button'; +import { ChevronRight, Keyboard, Volume2 } from '@/components/icons'; +import { IS_LITE } from '@/lib/lite'; +import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; +import { openKeyboardShortcuts } from '@/lib/app-commands'; +import { NOTIFICATION_SOUNDS, playNotificationSound, type NotificationSoundChoice } from '@/lib/notification-sound'; +import { + ACCESSIBILITY_DEFAULTS, + ACCESSIBILITY_KEYS, + FONT_SIZES, + MAIL_MIN_FONT_SIZES, + MAIL_TEXT_SCALES, + READ_ALOUD_RATES, + TOAST_DURATIONS, + toastDurationAtLeast, + type FontSize, +} from '@/lib/accessibility-settings'; +import { + ACCESSIBILITY_PRESET_IDS, + PRESET_SETTING_LABELS, + presetChanges, + type AccessibilityPresetId, + type PresetChange, +} from '@/lib/accessibility-presets'; +import { FONT_SIZES as COMPOSE_FONT_SIZES } from '@/components/email/font-size'; +import type { Density, SendDelaySeconds } from '@/stores/settings-store'; + +type SettingKey = Parameters['updateSetting']>[0]; + +/** Opens another settings page (and section) the way deep links do. */ +function goToSettings(tab: string, section?: string) { + window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: section ? { tab, section } : tab })); +} + +function LinkButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +/** + * Settings > Accessibility: presets on top, then the options by topic. Rows + * that are also everyday preferences (conversation view, undo send, sounds…) + * are the same setting shown on their own page too, not a copy. + */ +export function AccessibilitySettings() { + const t = useTranslations('settings.accessibility'); + const tRoot = useTranslations(); + const tEmail = useTranslations('settings.email_behavior'); + const tAppearance = useTranslations('settings.appearance'); + const tNotifications = useTranslations('settings.notifications'); + const tCalendar = useTranslations('calendar.settings'); + const settings = useSettingsStore(); + const { updateSetting } = settings; + const { isSettingLocked, isSettingHidden, getRestriction } = usePolicyStore(); + const faviconsAllowed = !useNoThirdPartyTraffic(); + const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null); + const [resetDone, setResetDone] = useState(false); + + const toastMin = getRestriction('toastDuration')?.min; + const allowsValue = (key: string, value: unknown) => + key !== 'toastDuration' || typeof toastMin !== 'number' || toastDurationAtLeast(value as number, toastMin); + + const set = (key: string, value: unknown) => updateSetting(key as SettingKey, value as never); + + const applyPreset = (id: AccessibilityPresetId) => { + const changes = presetChanges(id, useSettingsStore.getState() as unknown as Record, isSettingLocked, allowsValue); + for (const change of changes) set(change.key, change.to); + setApplied({ id, changes }); + setResetDone(false); + }; + + const undoPreset = () => { + if (!applied) return; + for (const change of applied.changes) set(change.key, change.from); + setApplied(null); + }; + + const resetAll = () => { + for (const key of ACCESSIBILITY_KEYS) { + if (isSettingLocked(key)) continue; + set(key, ACCESSIBILITY_DEFAULTS[key]); + } + set('fontSize', 'medium'); + setApplied(null); + setResetDone(true); + }; + + const systemToggle = (key: 'reduceMotion' | 'reduceTransparency' | 'pauseAnimatedImages' | 'largeTargets') => ( + set(key, value)} + options={[ + { value: 'system', label: t('system') }, + { value: 'on', label: t('on') }, + { value: 'off', label: t('off') }, + ]} + /> + ); + + const toastOptions = TOAST_DURATIONS + .filter((value) => allowsValue('toastDuration', value)) + .map((value) => ({ + value: String(value), + label: value === 0 ? t('toast_duration.until_closed') : t('toast_duration.seconds', { seconds: value }), + })); + + return ( +
+ +
+ {ACCESSIBILITY_PRESET_IDS.map((id) => ( + + ))} +
+
+ {applied && ( +
+

+ {applied.changes.length === 0 + ? t('presets.nothing_changed', { name: t(`presets.${applied.id}.label`) }) + : t('presets.applied', { name: t(`presets.${applied.id}.label`), count: applied.changes.length })} +

+ {applied.changes.length > 0 && ( +
    + {applied.changes.map((change) => ( +
  • {PRESET_SETTING_LABELS[change.key] ? tRoot(PRESET_SETTING_LABELS[change.key]) : change.key}
  • + ))} +
+ )} +
+ {applied.changes.length > 0 && ( + + )} + {!settings.showAccessibilityButton && ( + + )} +
+
+ )} +
+ + set('showAccessibilityButton', checked)} /> + +
+ + + + set('appFont', value)} + options={[ + { value: 'default', label: t('font.default') }, + { value: 'atkinson', label: 'Atkinson Hyperlegible' }, + { value: 'lexend', label: 'Lexend' }, + { value: 'opendyslexic', label: 'OpenDyslexic' }, + ]} + /> + + + set('density', value as Density)} + options={[ + { value: 'extra-compact', label: tAppearance('list_density.extra_compact') }, + { value: 'compact', label: tAppearance('list_density.compact') }, + { value: 'regular', label: tAppearance('list_density.regular') }, + { value: 'comfortable', label: tAppearance('list_density.comfortable') }, + ]} + /> + + )} + + set('showToolbarLabels', checked)} /> + + + + + + set('contrast', value)} + options={[ + { value: 'system', label: t('system') }, + { value: 'more', label: t('contrast.more') }, + { value: 'normal', label: t('contrast.normal') }, + ]} + /> + + + {systemToggle('reduceTransparency')} + + + set('underlineLinks', checked)} /> + + + set('showColorNames', checked)} /> + + + set('colorBlindPalette', checked)} /> + + + set('emailAlwaysLightMode', checked)} /> + + + goToSettings('appearance', 'theme')} /> + + + + + + {systemToggle('reduceMotion')} + + + {systemToggle('pauseAnimatedImages')} + + + + + {!isSettingHidden('singleKeyShortcuts') && ( + + set('singleKeyShortcuts', checked)} /> + + )} + + + + + set('focusRing', value)} + options={[ + { value: 'normal', label: t('focus_ring.normal') }, + { value: 'thick', label: t('focus_ring.thick') }, + { value: 'extra', label: t('focus_ring.extra') }, + ]} + /> + + + set('focusAlwaysVisible', checked)} /> + + + + + + {systemToggle('largeTargets')} + + + set('largeCursor', checked)} /> + + + set('openOnHover', checked)} /> + + + set('mailTextScale', Number(value))} + options={MAIL_TEXT_SCALES.map((scale) => ({ + value: String(scale), + label: scale === 0 ? t('mail_text_size.follow') : `${Math.round(scale * 100)} %`, + }))} + /> + + + set('mailOverrideStyles', value)} + options={[ + { value: 'never', label: t('mail_override.never') }, + { value: 'high-contrast', label: t('mail_override.high_contrast') }, + { value: 'always', label: t('mail_override.always') }, + ]} + /> + + + set('mailFitWidth', checked)} /> + + + set('readAloudRate', Number(value))} + options={READ_ALOUD_RATES.map((rate) => ({ value: String(rate), label: `${rate}×` }))} + /> + + + set('composeFontSize', value)} + options={[ + { value: '', label: t('compose_size.default') }, + ...COMPOSE_FONT_SIZES.map((size) => ({ value: size, label: size })), + ]} + /> + + + set('disableThreading', !checked)} /> + + {!isSettingHidden('showPreview') && ( + + set('showPreview', checked)} /> + + )} + {!IS_LITE && faviconsAllowed && ( + + set('senderFavicons', checked)} /> + + )} + {!isSettingHidden('markAsReadDelay') && ( + + set('toastDuration', Number(value))} + options={toastOptions} + /> + + + { + const choice = value as NotificationSoundChoice; + set('notificationSoundChoice', choice); + playNotificationSound(choice); + }} + options={NOTIFICATION_SOUNDS.map((s) => ({ value: s.id, label: tNotifications(`sounds.${s.id}`) }))} + /> +
+ + + set('faviconUnreadBadge', checked)} /> + + + set('unreadCountStyle', value)} + options={[ + { value: 'count', label: t('unread_counts.count') }, + { value: 'dot', label: t('unread_counts.dot') }, + { value: 'hidden', label: t('unread_counts.hidden') }, + ]} + /> + + + set('doNotDisturb', checked)} /> + + + + + + set('announceNewMail', checked)} /> + + + set('announceRowPreview', checked)} /> + + + set('simpleMode', checked)} /> + + {settings.simpleMode && ( + + set('simpleModePagedList', checked)} /> + + )} + + + + +
+ +

{resetDone ? t('reset.done') : ''}

+
+
+
+
+ ); +} diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 8a875eed3..b72b6e6eb 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -3,7 +3,9 @@ import { useTranslations } from 'next-intl'; import { useThemeStore } from '@/stores/theme-store'; import { useSettingsStore, type Density } from '@/stores/settings-store'; -import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; +import { SettingsSection, SettingItem, RadioGroup } from './settings-section'; +import { Button } from '@/components/ui/button'; +import { ChevronRight } from '@/components/icons'; import { ThemesSettings } from './themes-settings'; import { LayoutSection, SidebarSection, ExperimentalSection } from './layout-settings'; import { cn } from '@/lib/utils'; @@ -69,7 +71,7 @@ function DensityPreview({ density }: { density: Density }) { export function AppearanceSettings() { const t = useTranslations('settings.appearance'); const { theme, setTheme } = useThemeStore(); - const { fontSize, density, animationsEnabled, updateSetting } = useSettingsStore(); + const { density, updateSetting } = useSettingsStore(); const { isSettingLocked, isSettingHidden, getColorModePolicy, isFeatureEnabled } = usePolicyStore(); return ( @@ -90,20 +92,6 @@ export function AppearanceSettings() { - {!isSettingHidden('fontSize') && ( - - updateSetting('fontSize', value as 'small' | 'medium' | 'large')} - options={[ - { value: 'small', label: t('font_size.small') }, - { value: 'medium', label: t('font_size.medium') }, - { value: 'large', label: t('font_size.large') }, - ]} - /> - - )} - {!isSettingHidden('density') && ( )} - {!isSettingHidden('animationsEnabled') && ( - - updateSetting('animationsEnabled', checked)} - /> + {/* Text size and motion moved to Settings > Accessibility. */} + + - )} diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index eb9e8716d..8f015cb2f 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -35,7 +35,6 @@ export function ReadingSettings() { hideInlineImageAttachments, attachmentImagePreviewsEnabled, messageSpacing, - plainTextFont, externalContentPolicy, emailAlwaysLightMode, trustedSenders, @@ -100,18 +99,17 @@ export function ReadingSettings() { )} - {!isSettingHidden('plainTextFont') && ( - -