From 0731d24f654f87cb0df63228f0656d8b3a387358 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:47:14 +0100
Subject: [PATCH] feat: show a small picture of what each accessibility preset
changes
---
components/icons/index.ts | 3 +
.../__tests__/accessibility-settings.test.tsx | 14 +-
.../settings/accessibility-preset-preview.tsx | 195 ++++++++++++++++++
.../settings/accessibility-settings.tsx | 10 +-
4 files changed, 218 insertions(+), 4 deletions(-)
create mode 100644 components/settings/accessibility-preset-preview.tsx
diff --git a/components/icons/index.ts b/components/icons/index.ts
index 422f660ad..6b1972953 100644
--- a/components/icons/index.ts
+++ b/components/icons/index.ts
@@ -33,6 +33,7 @@ export {
IconBaseline as Baseline,
IconBell as Bell,
IconBellOff as BellOff,
+ IconBellRinging as BellRinging,
IconBold as Bold,
IconBook2 as Book,
IconBook as BookOpen,
@@ -184,6 +185,7 @@ export {
IconPlayerPlay as Play,
IconPlayerPlay as PlayCircle,
IconPlus as Plus,
+ IconPointer as Pointer,
IconPower as Power,
IconPower as PowerOff,
IconPresentation as Presentation,
@@ -249,6 +251,7 @@ export {
IconUsers as UsersRound,
IconVideo as Video,
IconVolume2 as Volume2,
+ IconVolumeOff as VolumeOff,
IconX as X,
IconCircleX as XCircle,
IconBolt as Zap,
diff --git a/components/settings/__tests__/accessibility-settings.test.tsx b/components/settings/__tests__/accessibility-settings.test.tsx
index d4907cbf3..d8a73d641 100644
--- a/components/settings/__tests__/accessibility-settings.test.tsx
+++ b/components/settings/__tests__/accessibility-settings.test.tsx
@@ -4,7 +4,7 @@ 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';
+import { ACCESSIBILITY_PRESET_IDS, presetChanges } from '@/lib/accessibility-presets';
// Labels are their keys; plural/ICU arguments are appended so tests can see them.
vi.mock('next-intl', () => ({
@@ -50,6 +50,18 @@ describe('accessibility presets', () => {
expect(settings().contrast).toBe('system');
});
+ it('draws each preset on its card without adding to the name screen readers hear', () => {
+ render();
+ for (const id of ACCESSIBILITY_PRESET_IDS) {
+ // jsdom joins the two spans without a space, so compare without any.
+ const expected = `settings.accessibility.presets.${id}.labelsettings.accessibility.presets.${id}.description`;
+ const button = screen.getByRole('button', { name: (name) => name.replace(/\s/g, '') === expected });
+ const preview = within(button).getByTestId(`preset-preview-${id}`);
+ expect(preview.getAttribute('aria-hidden')).toBe('true');
+ expect(preview.querySelector('span, svg')).toBeTruthy();
+ }
+ });
+
it('combines presets: a second one keeps what the first set', () => {
render();
fireEvent.click(presetButton('dyslexia'));
diff --git a/components/settings/accessibility-preset-preview.tsx b/components/settings/accessibility-preset-preview.tsx
new file mode 100644
index 000000000..2e63f2964
--- /dev/null
+++ b/components/settings/accessibility-preset-preview.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import type { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+import { BellRinging, Ellipsis, Image as ImageIcon, Pause, Pointer, Volume2, VolumeOff, X } from '@/components/icons';
+import type { AccessibilityPresetId } from '@/lib/accessibility-presets';
+
+/*
+ * A small drawing of the mail view on each preset card that shows what the
+ * preset changes before it is applied. Sizes are in px so the drawing keeps
+ * its proportions whatever text size the user has set, and it is hidden
+ * from screen readers: the card's description says the same in words.
+ */
+
+function Bar({ width, strong, className }: { width: number | string; strong?: boolean; className?: string }) {
+ return (
+
+ );
+}
+
+function Avatar({ size = 8 }: { size?: number }) {
+ return ;
+}
+
+function Frame({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** A message row: avatar, sender, and optionally the preview line. */
+function Row({ preview = true, avatar = true, className }: { preview?: boolean; avatar?: boolean; className?: string }) {
+ return (
+
+ {avatar && }
+
+
+ {preview && }
+
+
+ );
+}
+
+function ScreenReaderPreview() {
+ // One column, no reading pane, and the focused row read out.
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function LowVisionPreview() {
+ // Big, high-contrast text, a thick focus outline, an underlined link, a big pointer.
+ return (
+
+ Aa
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DyslexiaPreview() {
+ // A clearer font, more room between letters, words and lines, a narrower column.
+ const lines = [[14, 9, 18, 10], [8, 16, 12], [12, 10, 15, 6], [16, 11]];
+ return (
+
+
+
+ Abc
+
+ {lines.map((words, i) => (
+
+ {words.map((width, j) => )}
+
+ ))}
+
+
+ );
+}
+
+function FocusPreview() {
+ // A plain list: no pictures, no preview lines, a dot instead of a count.
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function MotorPreview() {
+ // Tall rows, an actions button on each, a thick outline on the focused one.
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+function MotionPreview() {
+ // A paused animated image on a solid panel, with no movement anywhere.
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function HearingPreview() {
+ // A notification that stays until closed and a flash at the edge instead of a sound.
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const PREVIEWS: Record ReactNode> = {
+ 'screen-reader': ScreenReaderPreview,
+ 'low-vision': LowVisionPreview,
+ dyslexia: DyslexiaPreview,
+ focus: FocusPreview,
+ motor: MotorPreview,
+ motion: MotionPreview,
+ hearing: HearingPreview,
+};
+
+export function AccessibilityPresetPreview({ id }: { id: AccessibilityPresetId }) {
+ const Preview = PREVIEWS[id];
+ return (
+
+
+
+ );
+}
diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx
index 689af64c9..371f96ac8 100644
--- a/components/settings/accessibility-settings.tsx
+++ b/components/settings/accessibility-settings.tsx
@@ -31,6 +31,7 @@ import {
type AccessibilityPresetId,
type PresetChange,
} from '@/lib/accessibility-presets';
+import { AccessibilityPresetPreview } from './accessibility-preset-preview';
import { FONT_SIZES as COMPOSE_FONT_SIZES } from '@/components/email/font-size';
import type { Density, SendDelaySeconds } from '@/stores/settings-store';
@@ -139,10 +140,13 @@ export function AccessibilitySettings() {
key={id}
type="button"
onClick={() => applyPreset(id)}
- className="text-start rounded-md border border-border px-3 py-2.5 hover:bg-muted transition-colors"
+ className="flex items-start gap-3 text-start rounded-md border border-border px-3 py-2.5 hover:bg-muted transition-colors"
>
- {t(`presets.${id}.label`)}
- {t(`presets.${id}.description`)}
+
+
+ {t(`presets.${id}.label`)}
+ {t(`presets.${id}.description`)}
+
))}