#170: Statusmeldungen, Einzeltasten-Shortcuts, Bewegung

Toast-Standzeit 2,5s auf 6s (WCAG 2.2.1 — für Screenreader-/Zoom-Nutzer
kaum erfassbar). Neue Einstellungs-Sektion Bedienung mit dem Schalter
Einzeltasten-Kürzel deaktivieren (lokale Geräte-Einstellung); die
Handler von e und / prüfen sie beim Tastendruck (WCAG 2.1.4).
prefers-reduced-motion: CSS-Transitions kollabieren auf instant, die
Graph-Simulation rechnet ihr Layout synchron zu Ende statt zu animieren
(WCAG 2.2.2). settings-nav-Spec auf 8 Sektionen nachgeführt. Bewusst
KEIN zusätzliches role=status (legal.spec-Locator-Falle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
Claude Fable 5 2026-07-21 14:39:03 +02:00
parent 58c19abfdd
commit 31b59f0fb6
10 changed files with 85 additions and 6 deletions

View File

@ -28,7 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
await expect(nav).toBeVisible(); await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link'); const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, feed tokens, data export. // 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. // Jump to the last section: it scrolls into view and becomes active.
const last = links.last(); const last = links.last();

View File

@ -18,7 +18,9 @@ interface ToastItem {
variant: ToastVariant; 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<ShowToast>(() => {}); const ToastContext = createContext<ShowToast>(() => {});

View File

@ -156,8 +156,18 @@ export function ForceGraph({
// Settle noticeably faster than d3's default so e2e clicks and the // Settle noticeably faster than d3's default so e2e clicks and the
// reading eye get a resting layout within a couple of seconds. // reading eye get a resting layout within a couple of seconds.
.alphaDecay(0.04) .alphaDecay(0.04)
.alpha(previous.size > 0 ? 0.5 : 1) .alpha(previous.size > 0 ? 0.5 : 1);
.on('tick', applyPositions); // 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; simRef.current = simulation;
simNodesRef.current = new Map(simNodes.map((n) => [n.id, n])); simNodesRef.current = new Map(simNodes.map((n) => [n.id, n]));
return () => { return () => {

View File

@ -15,6 +15,7 @@ import { usePageActionsSlot } from './page-actions';
import { PondSwitcher } from './PondSwitcher'; import { PondSwitcher } from './PondSwitcher';
import { useCurrentPondRoute } from './use-pond-route'; import { useCurrentPondRoute } from './use-pond-route';
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
interface TopBarProps { interface TopBarProps {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
onToggleSidebar: () => void; onToggleSidebar: () => void;
@ -49,7 +50,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
useEffect(() => { useEffect(() => {
if (!user) return undefined; if (!user) return undefined;
const onKeyDown = (event: KeyboardEvent): void => { const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === '/' && !isTypingTarget(event.target) && !searchOpen) { if (
event.key === '/' &&
!isTypingTarget(event.target) &&
!searchOpen &&
!singleKeyShortcutsDisabled()
) {
event.preventDefault(); event.preventDefault();
setSearchOpen(true); setSearchOpen(true);
} }

View File

@ -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;
}
}

View File

@ -42,6 +42,7 @@ import { PluginBlockContext } from '../editor/plugin-block-context';
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
import { useDocumentTitle } from '../lib/use-document-title'; import { useDocumentTitle } from '../lib/use-document-title';
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
import { import {
pluginBlockOptions, pluginBlockOptions,
sectionStyleOptions, sectionStyleOptions,
@ -461,7 +462,8 @@ export function PageEditorPage(): React.JSX.Element {
!event.ctrlKey && !event.ctrlKey &&
!event.metaKey && !event.metaKey &&
!event.altKey && !event.altKey &&
!isTypingTarget(event.target) !isTypingTarget(event.target) &&
!singleKeyShortcutsDisabled()
) { ) {
event.preventDefault(); event.preventDefault();
setMode('edit'); setMode('edit');

View File

@ -15,6 +15,8 @@ import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
import { WatchesSection } from '../watches/WatchesSection'; import { WatchesSection } from '../watches/WatchesSection';
import { useDocumentTitle } from '../lib/use-document-title'; 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 { interface SessionView {
id: string; id: string;
createdAt: string; createdAt: string;
@ -36,12 +38,34 @@ export function SettingsPage(): React.JSX.Element {
<WatchesSection /> <WatchesSection />
<ApiTokensSection /> <ApiTokensSection />
<FeedTokensSection /> <FeedTokensSection />
<InteractionSection />
<DataExportSection /> <DataExportSection />
</SettingsLayout> </SettingsLayout>
</> </>
); );
} }
/** 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 (
<section className="settings-section">
<h2>{t('settings:interaction.title')}</h2>
<label className="settings-checkbox">
<input
type="checkbox"
checked={disabled}
onChange={(event) => setDisabled(event.target.checked)}
/>
{t('settings:interaction.disableSingleKey')}
</label>
<p className="field__hint">{t('settings:interaction.disableSingleKeyHint')}</p>
</section>
);
}
function DataExportSection(): React.JSX.Element { function DataExportSection(): React.JSX.Element {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { status, expiresAt, request, download } = useDataExport(); const { status, expiresAt, request, download } = useDataExport();

View File

@ -97,6 +97,17 @@ button {
flex: 1; 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 (#166, WCAG 2.4.1): first tab stop, visible only on focus. */
.skip-link { .skip-link {
position: absolute; position: absolute;

View File

@ -56,5 +56,10 @@
"label": "Startseiten-Inhalt (Markdown)", "label": "Startseiten-Inhalt (Markdown)",
"save": "Startseite speichern", "save": "Startseite speichern",
"saved": "Gespeichert." "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."
} }
} }

View File

@ -56,5 +56,10 @@
"label": "Landing page content (Markdown)", "label": "Landing page content (Markdown)",
"save": "Save landing page", "save": "Save landing page",
"saved": "Saved." "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."
} }
} }