diff --git a/apps/web/e2e/settings-nav.spec.ts b/apps/web/e2e/settings-nav.spec.ts index 87accf8..aebfdaf 100644 --- a/apps/web/e2e/settings-nav.spec.ts +++ b/apps/web/e2e/settings-nav.spec.ts @@ -28,7 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async ( await expect(nav).toBeVisible(); const links = nav.locator('.settings-nav__link'); // Profile, password, sessions, watches, API tokens, feed tokens, data export. - await expect(links).toHaveCount(7); + // 8 seit #170 (neue Bedienungs-Sektion). + await expect(links).toHaveCount(8); // Jump to the last section: it scrolls into view and becomes active. const last = links.last(); diff --git a/apps/web/src/components/Toast.tsx b/apps/web/src/components/Toast.tsx index 64d5983..c24e1eb 100644 --- a/apps/web/src/components/Toast.tsx +++ b/apps/web/src/components/Toast.tsx @@ -18,7 +18,9 @@ interface ToastItem { variant: ToastVariant; } -const TOAST_DURATION_MS = 2500; +// 6 s statt 2,5 s (issue #170, WCAG 2.2.1): kurzlebige Statusmeldungen +// waren für Screenreader-/Vergrößerungs-Nutzer kaum erfassbar. +const TOAST_DURATION_MS = 6000; const ToastContext = createContext(() => {}); diff --git a/apps/web/src/graph/ForceGraph.tsx b/apps/web/src/graph/ForceGraph.tsx index 925eaa2..c70c73f 100644 --- a/apps/web/src/graph/ForceGraph.tsx +++ b/apps/web/src/graph/ForceGraph.tsx @@ -156,8 +156,18 @@ export function ForceGraph({ // Settle noticeably faster than d3's default so e2e clicks and the // reading eye get a resting layout within a couple of seconds. .alphaDecay(0.04) - .alpha(previous.size > 0 ? 0.5 : 1) - .on('tick', applyPositions); + .alpha(previous.size > 0 ? 0.5 : 1); + // prefers-reduced-motion (issue #170, WCAG 2.2.2): das Layout wird + // synchron zu Ende gerechnet und einmal gemalt statt zu animieren. + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + simulation.stop(); + simulation.tick(200); + // applyPositions läuft nach dem Mount der SVG-Knoten (unten im + // Layout-Effekt ohnehin einmal aufgerufen über den ersten Paint). + requestAnimationFrame(applyPositions); + } else { + simulation.on('tick', applyPositions); + } simRef.current = simulation; simNodesRef.current = new Map(simNodes.map((n) => [n.id, n])); return () => { diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index 577d42f..de45722 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -15,6 +15,7 @@ import { usePageActionsSlot } from './page-actions'; import { PondSwitcher } from './PondSwitcher'; import { useCurrentPondRoute } from './use-pond-route'; +import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts'; interface TopBarProps { sidebarCollapsed: boolean; onToggleSidebar: () => void; @@ -49,7 +50,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac useEffect(() => { if (!user) return undefined; const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === '/' && !isTypingTarget(event.target) && !searchOpen) { + if ( + event.key === '/' && + !isTypingTarget(event.target) && + !searchOpen && + !singleKeyShortcutsDisabled() + ) { event.preventDefault(); setSearchOpen(true); } diff --git a/apps/web/src/lib/single-key-shortcuts.ts b/apps/web/src/lib/single-key-shortcuts.ts new file mode 100644 index 0000000..2c6895a --- /dev/null +++ b/apps/web/src/lib/single-key-shortcuts.ts @@ -0,0 +1,13 @@ +/** localStorage key for the "disable single-key shortcuts" preference + * (issue #170, WCAG 2.1.4): `e` (edit mode) and `/` (search) can misfire + * for speech-input users, so they must be switch-offable. */ +export const SINGLE_KEY_SHORTCUTS_KEY = 'ui.singleKeyShortcuts.disabled'; + +/** Read at keypress time so the toggle needs no cross-component state. */ +export function singleKeyShortcutsDisabled(): boolean { + try { + return window.localStorage.getItem(SINGLE_KEY_SHORTCUTS_KEY) === 'true'; + } catch { + return false; + } +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 955b9f1..85fde37 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -42,6 +42,7 @@ import { PluginBlockContext } from '../editor/plugin-block-context'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; import { useDocumentTitle } from '../lib/use-document-title'; +import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts'; import { pluginBlockOptions, sectionStyleOptions, @@ -461,7 +462,8 @@ export function PageEditorPage(): React.JSX.Element { !event.ctrlKey && !event.metaKey && !event.altKey && - !isTypingTarget(event.target) + !isTypingTarget(event.target) && + !singleKeyShortcutsDisabled() ) { event.preventDefault(); setMode('edit'); diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index d844019..2f92ff9 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -15,6 +15,8 @@ import { FeedTokensSection } from '../api-tokens/FeedTokensSection'; import { WatchesSection } from '../watches/WatchesSection'; import { useDocumentTitle } from '../lib/use-document-title'; +import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts'; +import { usePersistentState } from '../lib/use-persistent-state'; interface SessionView { id: string; createdAt: string; @@ -36,12 +38,34 @@ export function SettingsPage(): React.JSX.Element { + ); } +/** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel + * abschaltbar machen — lokale Geräte-Einstellung, kein Server-Zustand. */ +function InteractionSection(): React.JSX.Element { + const { t } = useTranslation(); + const [disabled, setDisabled] = usePersistentState(SINGLE_KEY_SHORTCUTS_KEY, false); + return ( +
+

{t('settings:interaction.title')}

+ +

{t('settings:interaction.disableSingleKeyHint')}

+
+ ); +} + function DataExportSection(): React.JSX.Element { const { t, i18n } = useTranslation(); const { status, expiresAt, request, download } = useDataExport(); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index fbfad2d..30456f2 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -97,6 +97,17 @@ button { flex: 1; } +/* Reduced motion (#170): collapse the few CSS transitions to instant. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + /* Skip link (#166, WCAG 2.4.1): first tab stop, visible only on focus. */ .skip-link { position: absolute; diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json index a3177a9..bc06e69 100644 --- a/packages/shared/i18n/de/settings.json +++ b/packages/shared/i18n/de/settings.json @@ -56,5 +56,10 @@ "label": "Startseiten-Inhalt (Markdown)", "save": "Startseite speichern", "saved": "Gespeichert." + }, + "interaction": { + "title": "Bedienung", + "disableSingleKey": "Einzeltasten-Kürzel deaktivieren", + "disableSingleKeyHint": "Schaltet die Kürzel „e“ (Bearbeiten) und „/“ (Suche) ab — hilfreich bei Spracheingabe. Gilt für dieses Gerät." } } diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json index 6f0db76..9d208dc 100644 --- a/packages/shared/i18n/en/settings.json +++ b/packages/shared/i18n/en/settings.json @@ -56,5 +56,10 @@ "label": "Landing page content (Markdown)", "save": "Save landing page", "saved": "Saved." + }, + "interaction": { + "title": "Interaction", + "disableSingleKey": "Disable single-key shortcuts", + "disableSingleKeyHint": "Turns off the “e” (edit) and “/” (search) shortcuts — helpful with speech input. Applies to this device." } }