@ -521,6 +521,18 @@ jobs:
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Two logins per run → reset first (see note above).
|
||||
- name: Reset login rate limit before a11y pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
# WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens.
|
||||
- name: Run a11y pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
|
||||
|
||||
- name: Run setup wizard pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \
|
||||
|
||||
@ -171,7 +171,8 @@ export class TasksService {
|
||||
}
|
||||
return (
|
||||
`<table class="dt-task-overview-table"><thead><tr>` +
|
||||
`<th></th><th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` +
|
||||
`<th><span class="visually-hidden">${escapeHtml(t('colDone'))}</span></th>` +
|
||||
`<th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` +
|
||||
`<th>${escapeHtml(t('colStart'))}</th><th>${escapeHtml(t('colDue'))}</th>` +
|
||||
`<th>${escapeHtml(t('colPage'))}</th>` +
|
||||
`</tr></thead><tbody>${rows.join('')}</tbody></table>`
|
||||
|
||||
@ -45,6 +45,16 @@ export function htmlDocument({
|
||||
pre { overflow-x: auto; }
|
||||
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
|
||||
font-size: 0.9rem; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden;
|
||||
clip-path: inset(50%); white-space: nowrap; }
|
||||
/* color-scheme allows dark UA rendering, so give it AA-checked colors
|
||||
(issue #167, WCAG 1.4.3): text 14.8:1, links 10.1:1, muted 8.5:1. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #10161d; color: #e2e8f0; }
|
||||
a { color: #93c5fd; }
|
||||
.public-page__pond, .public-footer { color: #a7b3c0; }
|
||||
.public-footer { border-top-color: #a7b3c0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
62
apps/web/e2e/a11y.spec.ts
Normal file
62
apps/web/e2e/a11y.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
|
||||
/**
|
||||
* A11y-Smoke-Pack (issue #171): axe-core-Scan der Kern-Oberflächen gegen
|
||||
* WCAG 2.1 A/AA. Regressionsschutz für das Audit vom 21.07.2026 (Bericht im
|
||||
* Workspace, Befunde A11Y-001…024) — die Screens hier waren nach den Fixes
|
||||
* der Issues #162–#170 verletzungsfrei; jede neue Verletzung bricht den
|
||||
* Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier
|
||||
* bewusst NICHT, nur normative WCAG-Kriterien.
|
||||
*/
|
||||
|
||||
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
|
||||
|
||||
/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */
|
||||
const ALLOWED_RULES: string[] = [];
|
||||
|
||||
async function expectClean(page: Page, label: string): Promise<void> {
|
||||
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||||
const violations = results.violations.filter((v) => !ALLOWED_RULES.includes(v.id));
|
||||
expect(
|
||||
violations.map((v) => ({
|
||||
rule: v.id,
|
||||
impact: v.impact,
|
||||
help: v.help,
|
||||
nodes: v.nodes.slice(0, 5).map((n) => n.target),
|
||||
})),
|
||||
`axe-Verletzungen auf ${label}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
test('login page passes the axe WCAG A/AA scan', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, '/login');
|
||||
});
|
||||
|
||||
test('reading and editing a page passes the axe WCAG A/AA scan', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.goto('/p/content-fixtures/every-element');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, 'Lesemodus every-element');
|
||||
|
||||
await page.locator('.editor-page__mode-toggle').click();
|
||||
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await expectClean(page, 'Editor every-element');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('user settings pass the axe WCAG A/AA scan', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, '/settings');
|
||||
await context.close();
|
||||
});
|
||||
@ -62,7 +62,10 @@ test('page lifecycle: create via the sidebar, rename, appears in the sidebar', a
|
||||
const page = await context.newPage();
|
||||
await page.goto(`/p/${pond.slug}`);
|
||||
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
||||
await page.getByLabel(/title|titel/i).fill(title);
|
||||
await page
|
||||
.locator('.sidebar')
|
||||
.getByLabel(/title|titel/i)
|
||||
.fill(title);
|
||||
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -110,7 +110,10 @@ test('new-page flow: button opens a title prompt and the editor opens on create'
|
||||
await page.goto(`/p/${pond.slug}`);
|
||||
|
||||
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
||||
await page.getByLabel(/title|titel/i).fill(title);
|
||||
await page
|
||||
.locator('.sidebar')
|
||||
.getByLabel(/title|titel/i)
|
||||
.fill(title);
|
||||
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
@ -182,7 +182,9 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
|
||||
<th>{t('list.lastUsed')}</th>
|
||||
<th>{t('list.expires')}</th>
|
||||
<th>{t('list.status')}</th>
|
||||
<th></th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@ -91,7 +91,9 @@ export function FeedTokensSection(): React.JSX.Element {
|
||||
<th>{t('fields.name')}</th>
|
||||
<th>{t('list.created')}</th>
|
||||
<th>{t('list.lastUsed')}</th>
|
||||
<th></th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@ -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<ShowToast>(() => {});
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { cloneElement, isValidElement, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ApiError } from '../lib/api';
|
||||
@ -20,13 +21,28 @@ export function Field({
|
||||
children: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const noteId = useId();
|
||||
// Tie the hint/error text to the control itself (#168, WCAG 3.3.1):
|
||||
// screen readers then repeat it when the field receives focus. Only a
|
||||
// single element child can be wired; fragments render unchanged.
|
||||
const wired =
|
||||
isValidElement(children) && (error || hint)
|
||||
? cloneElement(children as React.ReactElement<Record<string, unknown>>, {
|
||||
'aria-describedby': noteId,
|
||||
...(error ? { 'aria-invalid': true } : {}),
|
||||
})
|
||||
: children;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
{children}
|
||||
{hint && !error && <span className="field__hint">{hint}</span>}
|
||||
{wired}
|
||||
{hint && !error && (
|
||||
<span className="field__hint" id={noteId}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="field__error" role="alert">
|
||||
<span className="field__error" role="alert" id={noteId}>
|
||||
{t(`errors:${error}`, t('errors:bad_request'))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import type { NodeViewProps } from '@tiptap/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||
|
||||
/** `packages/shared`'s task_item.parseDOM does not read `data-checked` back
|
||||
* (issue #24) — checked state only ever comes from the node's own attrs, set
|
||||
* here via the checkbox, never re-parsed from HTML. */
|
||||
* here via the checkbox, never re-parsed from HTML.
|
||||
*
|
||||
* DOM shape (#169): the render host itself is the `<li>` (see the renderer
|
||||
* options below) so the `<ul>` has only list items as direct children —
|
||||
* TipTap's default extra `<div>` host broke the list semantics for
|
||||
* screen readers. The wrapper flattens away via display:contents. */
|
||||
function TaskItemView({ node, updateAttributes, editor }: NodeViewProps): React.JSX.Element {
|
||||
const { t } = useTranslation('tasks');
|
||||
const checked = Boolean(node.attrs.checked);
|
||||
return (
|
||||
<NodeViewWrapper as="li" data-type="task_item" data-checked={String(checked)}>
|
||||
<NodeViewWrapper as="div" style={{ display: 'contents' }}>
|
||||
<label contentEditable={false}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!editor.isEditable}
|
||||
aria-label={node.textContent || t('colTask')}
|
||||
onChange={(event) => updateAttributes({ checked: event.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
@ -34,7 +42,13 @@ export const TaskItem = Node.create({
|
||||
parseHTML: () => taskItemSpec.parseDOM,
|
||||
renderHTML: ({ node }) => taskItemSpec.toDOM!(node),
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TaskItemView);
|
||||
return ReactNodeViewRenderer(TaskItemView, {
|
||||
as: 'li',
|
||||
attrs: ({ node }) => ({
|
||||
'data-type': 'task_item',
|
||||
'data-checked': String(node.attrs.checked === true),
|
||||
}),
|
||||
});
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
|
||||
@ -86,7 +86,9 @@ function TaskOverviewView({ editor }: NodeViewProps): React.JSX.Element {
|
||||
<table className="dt-task-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('colDone')}</span>
|
||||
</th>
|
||||
<th>{t('colTask')}</th>
|
||||
<th>{t('colMentions')}</th>
|
||||
<th>{t('colStart')}</th>
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
type SimulationNodeDatum,
|
||||
} from 'd3-force';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Self-contained SVG force graph (issue #112). Only `d3-force` is bundled —
|
||||
@ -92,6 +93,7 @@ export function ForceGraph({
|
||||
onNodeClick?: (id: string) => void;
|
||||
settings?: ForceGraphSettings;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('graph');
|
||||
const [view, setView] = useState({ k: 1, tx: 0, ty: 0 });
|
||||
/** Last known positions — read by React renders, written by sim ticks. */
|
||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
@ -154,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 () => {
|
||||
@ -272,6 +284,7 @@ export function ForceGraph({
|
||||
className="force-graph"
|
||||
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={t('svgLabel', { nodes: nodes.length, edges: edges.length })}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
@ -10,6 +11,7 @@ import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './
|
||||
import { TopBar } from './TopBar';
|
||||
|
||||
export function AppLayout(): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
|
||||
const [sidebarWidth, setSidebarWidth] = usePersistentState(
|
||||
'ui.sidebar.width',
|
||||
@ -53,6 +55,10 @@ export function AppLayout(): React.JSX.Element {
|
||||
<SidebarChromeContext.Provider value={setForcedHidden}>
|
||||
<PageActionsSlotContext.Provider value={actionsSlot}>
|
||||
<div className="app">
|
||||
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
|
||||
<a className="skip-link" href="#main">
|
||||
{t('layout.skipToContent')}
|
||||
</a>
|
||||
<TopBar
|
||||
sidebarCollapsed={collapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
@ -61,13 +67,20 @@ export function AppLayout(): React.JSX.Element {
|
||||
className="app-body"
|
||||
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
||||
>
|
||||
<Sidebar collapsed={collapsed} />
|
||||
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
// Inside the nav landmark so no content sits outside landmarks
|
||||
// (#166); hidden with the sidebar as before.
|
||||
resizer={
|
||||
!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />
|
||||
}
|
||||
/>
|
||||
<div className="main-column">
|
||||
{/* tabIndex: the main area is the app's scroll container; on
|
||||
pages without focusable content (e.g. legal texts) keyboard
|
||||
users could otherwise not scroll it (#165, WCAG 2.1.1). */}
|
||||
<main className="main" tabIndex={0}>
|
||||
users could otherwise not scroll it (#165, WCAG 2.1.1).
|
||||
id: skip-link target (#166). */}
|
||||
<main className="main" id="main" tabIndex={0}>
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* Outside the scroll container: always visible at the bottom
|
||||
|
||||
@ -36,6 +36,8 @@ import { useCurrentPondRoute } from './use-pond-route';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
/** The resize handle, rendered inside the nav landmark (#166). */
|
||||
resizer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
|
||||
@ -50,7 +52,7 @@ const VIEW_MODES: SidebarViewMode[] = ['folders', 'labels'];
|
||||
* every user can override it locally. Collapse behavior and the layout
|
||||
* contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
||||
*/
|
||||
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { pondSlug } = useCurrentPondRoute();
|
||||
|
||||
@ -67,6 +69,7 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
inert={collapsed}
|
||||
aria-label={t('layout.sidebar.label')}
|
||||
>
|
||||
{resizer}
|
||||
{!pond.data ? (
|
||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||
) : (
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
13
apps/web/src/lib/single-key-shortcuts.ts
Normal file
13
apps/web/src/lib/single-key-shortcuts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
@ -560,11 +562,15 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
)}
|
||||
<div className="editor-page">
|
||||
<div className="editor-page__header">
|
||||
{/* The visible title is an input; give assistive tech the page
|
||||
heading it expects on an article view (#166). */}
|
||||
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
|
||||
<input
|
||||
type="text"
|
||||
className="editor-page__title"
|
||||
value={title}
|
||||
placeholder={t('title.placeholder')}
|
||||
aria-label={t('title.label')}
|
||||
disabled={mode !== 'edit'}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={() => void saveTitle()}
|
||||
|
||||
@ -63,6 +63,7 @@ export function QuotaManager(): React.JSX.Element {
|
||||
<select
|
||||
className="quota-manager__type"
|
||||
value={type}
|
||||
aria-label={t('overrides.typeLabel')}
|
||||
onChange={(e) => setType(e.target.value as QuotaSubject)}
|
||||
>
|
||||
<option value="user">{t('overrides.user')}</option>
|
||||
|
||||
@ -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 {
|
||||
<WatchesSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<InteractionSection />
|
||||
<DataExportSection />
|
||||
</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 {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { status, expiresAt, request, download } = useDataExport();
|
||||
@ -255,7 +279,9 @@ function SessionsSection(): React.JSX.Element {
|
||||
<th>{t('settings:sessions.device')}</th>
|
||||
<th>{t('settings:sessions.created')}</th>
|
||||
<th>{t('settings:sessions.lastSeen')}</th>
|
||||
<th></th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@ -97,6 +97,34 @@ 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;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 1200;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
border-radius: 0 0 var(--radius) 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skip-link:focus-visible {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Narrow viewports (issue #165, WCAG 1.4.10): the topbar wraps onto a
|
||||
second row instead of pushing the page wider than the viewport; every
|
||||
control stays visible and reachable. The search button drops to its
|
||||
@ -133,6 +161,8 @@ button {
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
/* Anchor for the absolutely positioned resize handle (#166). */
|
||||
position: relative;
|
||||
/* Column flex so the trash footer can pin to the bottom (margin-top: auto). */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -152,12 +182,16 @@ button {
|
||||
|
||||
/* Drag handle on the sidebar's right edge (issue #99). */
|
||||
.sidebar-resizer {
|
||||
flex-shrink: 0;
|
||||
/* Lives inside the nav landmark (#166); straddles the sidebar edge. */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
margin-left: -3px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.sidebar-resizer:hover,
|
||||
@ -665,7 +699,7 @@ button {
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
background: var(--color-bg);
|
||||
@ -1678,15 +1712,19 @@ ul[data-type='task_list'] li {
|
||||
robust form. `:first-of-type`/`:last-of-type` (NOT `:first-child`) because
|
||||
the `<input>`/`<label>` precedes the paragraph in the read-mode markup. */
|
||||
ul[data-type='task_list'] li > input[type='checkbox'],
|
||||
ul[data-type='task_list'] li > label {
|
||||
ul[data-type='task_list'] li > label,
|
||||
ul[data-type='task_list'] li > [data-node-view-wrapper] > label {
|
||||
flex: none;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
/* Editor/auth NodeView only (the bare-input shape has no label): its line
|
||||
metrics sit the checkbox ~3px lower than in the public view, so pull the
|
||||
label up by that much — tuned to Stefan's eye on the live stage (#137). */
|
||||
ul[data-type='task_list'] li > label {
|
||||
label up by that much — tuned to Stefan's eye on the live stage (#137).
|
||||
Since #169 the nodeview's label sits one display:contents wrapper deep
|
||||
(`li > [data-node-view-wrapper] > label`); the extra selector keeps the
|
||||
read/public shape (`li > label` never occurs there) untouched. */
|
||||
ul[data-type='task_list'] li > [data-node-view-wrapper] > label {
|
||||
margin-top: calc(0.25em - 3px);
|
||||
}
|
||||
|
||||
@ -2434,12 +2472,14 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
.wikilink {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
/* Permanently visible: color alone must not be the only link cue
|
||||
(issue #167, WCAG 1.4.1 — accent vs. text is only ~2.5:1). */
|
||||
border-bottom: 1px solid currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wikilink:hover {
|
||||
border-bottom-color: currentColor;
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
/* A link to a page that does not exist yet. */
|
||||
|
||||
@ -22,11 +22,16 @@
|
||||
* background entirely (#120). */
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #d9e2ec;
|
||||
/* Boundaries of interactive controls (inputs, selects) need 3:1 against
|
||||
both backgrounds (WCAG 1.4.11) — #d9e2ec is only ~1.3:1. */
|
||||
--color-border-input: #7d8a97;
|
||||
--color-accent: #2f6f4f;
|
||||
--color-accent-contrast: #ffffff;
|
||||
--color-danger: #ab091e;
|
||||
--color-ok: #14803c;
|
||||
/* Favorite stars and tree icons (issue #132) — a readable gold. */
|
||||
/* Favorite stars and tree icons (issue #132) — a readable gold. ICON
|
||||
use only: 3.25:1 passes the 3:1 non-text minimum but NOT the 4.5:1
|
||||
required for text (audit A11Y-024c). */
|
||||
--color-favorite: #b8860b;
|
||||
|
||||
/* Spacing scale (rem-based). */
|
||||
|
||||
@ -56,7 +56,8 @@
|
||||
},
|
||||
"user": {
|
||||
"anonymous": "Nicht angemeldet"
|
||||
}
|
||||
},
|
||||
"skipToContent": "Zum Inhalt springen"
|
||||
},
|
||||
"pondHome": {
|
||||
"empty": "Dieser Teich hat noch keine Seiten – lege eine über die Seitenleiste an."
|
||||
@ -95,5 +96,6 @@
|
||||
},
|
||||
"settingsNav": {
|
||||
"label": "Abschnitte"
|
||||
}
|
||||
},
|
||||
"tableActions": "Aktionen"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": {
|
||||
"placeholder": "Unbenannte Seite"
|
||||
"placeholder": "Unbenannte Seite",
|
||||
"label": "Seitentitel"
|
||||
},
|
||||
"mode": {
|
||||
"edit": "Bearbeiten",
|
||||
|
||||
@ -19,5 +19,6 @@
|
||||
"nodeRadius": "Knotengröße",
|
||||
"fontSize": "Schriftgröße",
|
||||
"reset": "Zurücksetzen"
|
||||
}
|
||||
},
|
||||
"svgLabel": "Wissensgraph: {{nodes}} Seiten, {{edges}} Verknüpfungen. Gleiche Verbindungen als Liste: Backlinks unter jeder Seite."
|
||||
}
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
"usage": "Nutzung",
|
||||
"set": "Setzen",
|
||||
"clear": "Löschen",
|
||||
"overQuota": "über Kontingent"
|
||||
"overQuota": "über Kontingent",
|
||||
"typeLabel": "Geltungsbereich"
|
||||
},
|
||||
"keys": {
|
||||
"editors_per_pond": "Bearbeiter pro Teich",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Aufgabenübersicht",
|
||||
"editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten",
|
||||
"colDone": "Erledigt",
|
||||
"colTask": "Aufgabe",
|
||||
"colMentions": "Wer",
|
||||
"colStart": "Start",
|
||||
|
||||
@ -56,7 +56,8 @@
|
||||
},
|
||||
"user": {
|
||||
"anonymous": "Not signed in"
|
||||
}
|
||||
},
|
||||
"skipToContent": "Skip to content"
|
||||
},
|
||||
"pondHome": {
|
||||
"empty": "This pond doesn't have any pages yet — create one from the sidebar."
|
||||
@ -95,5 +96,6 @@
|
||||
},
|
||||
"settingsNav": {
|
||||
"label": "Sections"
|
||||
}
|
||||
},
|
||||
"tableActions": "Actions"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": {
|
||||
"placeholder": "Untitled page"
|
||||
"placeholder": "Untitled page",
|
||||
"label": "Page title"
|
||||
},
|
||||
"mode": {
|
||||
"edit": "Edit",
|
||||
|
||||
@ -19,5 +19,6 @@
|
||||
"nodeRadius": "Node size",
|
||||
"fontSize": "Font size",
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"svgLabel": "Knowledge graph: {{nodes}} pages, {{edges}} links. The same connections are listed as backlinks on each page."
|
||||
}
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
"usage": "Usage",
|
||||
"set": "Set",
|
||||
"clear": "Clear",
|
||||
"overQuota": "over quota"
|
||||
"overQuota": "over quota",
|
||||
"typeLabel": "Applies to"
|
||||
},
|
||||
"keys": {
|
||||
"editors_per_pond": "Editors per pond",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Task overview",
|
||||
"editorCard": "Task overview — this page and its subpages",
|
||||
"colDone": "Done",
|
||||
"colTask": "Task",
|
||||
"colMentions": "Who",
|
||||
"colStart": "Start",
|
||||
|
||||
@ -61,7 +61,9 @@ describe('docToHtml (issue #24)', () => {
|
||||
const html = docToHtml(doc);
|
||||
expect(html).toContain('data-checked="false"');
|
||||
expect(html).toContain('data-checked="true"');
|
||||
expect(html).toContain('<input type="checkbox" disabled checked>');
|
||||
// The item text names the checkbox (#169, WCAG 4.1.2).
|
||||
expect(html).toContain('<input type="checkbox" disabled checked aria-label="Done">');
|
||||
expect(html).toContain('<input type="checkbox" disabled aria-label="Todo">');
|
||||
});
|
||||
|
||||
it('gives wikilinks a relative href so public/static HTML is clickable', () => {
|
||||
|
||||
@ -95,7 +95,9 @@ function renderListItems(node: Node): string {
|
||||
if (item.type.name === 'task_item') {
|
||||
const checked = item.attrs.checked === true;
|
||||
const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
|
||||
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
|
||||
// aria-label: the disabled checkbox needs a name (#169, WCAG 4.1.2);
|
||||
// the item text doubles as its label in the static rendering.
|
||||
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''} aria-label="${escapeHtml(item.textContent)}">${renderBlocks(item)}</li>`;
|
||||
} else {
|
||||
out += `<li>${renderBlocks(item)}</li>`;
|
||||
}
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -336,6 +336,9 @@ importers:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@axe-core/playwright':
|
||||
specifier: ^4.12.1
|
||||
version: 4.12.1(playwright-core@1.61.1)
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
@ -630,6 +633,11 @@ packages:
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
'@axe-core/playwright@4.12.1':
|
||||
resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==}
|
||||
peerDependencies:
|
||||
playwright-core: '>= 1.0.0'
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -3508,6 +3516,10 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axe-core@4.12.1:
|
||||
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
b4a@1.8.1:
|
||||
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
|
||||
peerDependencies:
|
||||
@ -7022,6 +7034,11 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
|
||||
'@axe-core/playwright@4.12.1(playwright-core@1.61.1)':
|
||||
dependencies:
|
||||
axe-core: 4.12.1
|
||||
playwright-core: 1.61.1
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@ -9855,6 +9872,8 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
axe-core@4.12.1: {}
|
||||
|
||||
b4a@1.8.1: {}
|
||||
|
||||
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user