A11y: Struktur/Kontraste/Formulare (#166-#168), Nicht-Text (#169), Verhalten (#170), CI-Schutz (#171) #174

Merged
stwaidele merged 6 commits from a11y-mittel into main 2026-07-21 16:29:26 +02:00
39 changed files with 347 additions and 45 deletions

View File

@ -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 \

View File

@ -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>`

View File

@ -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
View 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-001024) 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();
});

View File

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

View File

@ -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();

View File

@ -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}/.+`));

View File

@ -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",

View File

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

View File

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

View File

@ -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>(() => {});

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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>
) : (

View File

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

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 { 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()}

View File

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

View File

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

View File

@ -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. */

View File

@ -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). */

View File

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

View File

@ -1,6 +1,7 @@
{
"title": {
"placeholder": "Unbenannte Seite"
"placeholder": "Unbenannte Seite",
"label": "Seitentitel"
},
"mode": {
"edit": "Bearbeiten",

View File

@ -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."
}

View File

@ -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",

View File

@ -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."
}
}

View File

@ -1,6 +1,7 @@
{
"title": "Aufgabenübersicht",
"editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten",
"colDone": "Erledigt",
"colTask": "Aufgabe",
"colMentions": "Wer",
"colStart": "Start",

View File

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

View File

@ -1,6 +1,7 @@
{
"title": {
"placeholder": "Untitled page"
"placeholder": "Untitled page",
"label": "Page title"
},
"mode": {
"edit": "Edit",

View File

@ -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."
}

View File

@ -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",

View File

@ -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."
}
}

View File

@ -1,6 +1,7 @@
{
"title": "Task overview",
"editorCard": "Task overview — this page and its subpages",
"colDone": "Done",
"colTask": "Task",
"colMentions": "Who",
"colStart": "Start",

View File

@ -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', () => {

View File

@ -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
View File

@ -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):