// @vitest-environment node /** * „Gesperrt sieht gesperrt aus“ – auch ohne Farbe. * * **Der Anlass.** Die Projektregel `aria-disabled` statt `disabled` ist * richtig: Ein natives `disabled` nähme dem Element den Fokus, und der * erklärende Satz daneben würde nie vorgelesen. Der Zustand wurde aber * **ausschließlich über Farbtoken** gezeichnet – heller Text auf versetztem * Grund. Im eigenen Hochkontrast-Thema ist `--farbe-text-leise` mit * `--farbe-text` identisch (beides `#ffffff`) und `--farbe-grund-versetzt` * mit dem Grund (`#000000`): Ein gesperrter Knopf sah dort **pixelgleich** * aus wie ein aktiver. Unter erzwungenen Farben fallen alle beteiligten Token * ohnehin auf `CanvasText` und `Canvas` zusammen. * * Wer Hochkontrast braucht, drückte also auf scheinbar bereite Knöpfe, ohne * dass etwas geschah – genau die Verwirrung, die der Kommentar in `lernen.css` * zu verhindern verspricht. * * Geprüft wird hier am Stilblatt, nicht im Browser: Ob eine gestrichelte * Linie im echten Fenster auffällt, sagt keine Messung – das ist Sichtprüfung * H8. Was sich hier feststellen lässt, ist, dass der Zustand überhaupt an * etwas anderem hängt als an einer Farbe, die in zwei von vier Darstellungen * mit ihrem Gegenstück zusammenfällt. */ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const wurzel = fileURLToPath(new URL('..', import.meta.url)); function stil(datei: string): string { return readFileSync(join(wurzel, 'src', 'renderer', 'src', 'styles', datei), 'utf8'); } /** Alle Dateien mit dieser Endung unterhalb eines Verzeichnisses. */ function dateien(verzeichnis: string, endung: string): string[] { const gefunden: string[] = []; for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { const pfad = join(verzeichnis, eintrag.name); if (eintrag.isDirectory()) { gefunden.push(...dateien(pfad, endung)); } else if (eintrag.name.endsWith(endung)) { gefunden.push(pfad); } } return gefunden; } const LERNEN_CSS = stil('lernen.css'); const TOKENS_CSS = stil('tokens.css'); /** Der Regelblock ab einem Selektor bis zur schließenden Klammer. */ function block(css: string, selektor: string): string { const anfang = css.indexOf(selektor); expect(anfang, `Selektor nicht gefunden: ${selektor}`).toBeGreaterThan(-1); const auf = css.indexOf('{', anfang); const zu = css.indexOf('}', auf); return css.slice(auf, zu); } describe('Der Gesperrt-Zustand hängt nicht allein an der Farbe', () => { it('gibt gesperrten Knöpfen ein farbunabhängiges Merkmal', () => { const regel = block(LERNEN_CSS, ".knopf[aria-disabled='true']"); expect(regel).toMatch(/border-style:\s*dashed/u); }); it('belegt, warum die Farbe allein nicht trägt', () => { /* Die Gegenprobe zur Behebung: Wären die beiden Token im Hochkontrast-Thema verschieden, bräuchte es das zusätzliche Merkmal nicht – und dieser Test dürfte gelöscht werden. Solange sie zusammenfallen, ist er der Beleg für den Befund. */ const hochkontrast = block(TOKENS_CSS, ":root[data-thema='hochkontrast']"); const werte = new Map( [...hochkontrast.matchAll(/--([a-z-]+):\s*(#[0-9a-f]{6})\b/gu)].map((t) => [t[1], t[2]]), ); expect(werte.get('farbe-text-leise')).toBe(werte.get('farbe-text')); expect(werte.get('farbe-grund-versetzt')).toBe(werte.get('farbe-grund')); }); }); describe('Erzwungene Farben (Windows-Kontrastdesign)', () => { it('kennzeichnet gesperrte Knöpfe mit der Systemfarbe GrayText', () => { /* Die einzige Farbe, die unter erzwungenen Farben einen Zustand ausdrücken darf – und die einzige, die Windows-Nutzer dafür kennen. Sie muss ausdrücklich gesetzt werden: Die Token fallen dort alle auf CanvasText zusammen. */ const bereich = LERNEN_CSS.slice(LERNEN_CSS.indexOf('@media (forced-colors: active)')); const regel = block(bereich, ".knopf[aria-disabled='true']"); expect(regel).toMatch(/color:\s*GrayText/u); expect(regel).toMatch(/border-color:\s*GrayText/u); }); it('kennzeichnet auch gesperrte Schalter', () => { const bereich = LERNEN_CSS.slice(LERNEN_CSS.indexOf('@media (forced-colors: active)')); const regel = block(bereich, ".schalter__feld[aria-disabled='true'] + .schalter__beschriftung"); expect(regel).toMatch(/color:\s*GrayText/u); }); }); describe('Das gewählte Gesetz ist von den übrigen zu unterscheiden', () => { /* Befund der Prüfrunde zu 0.27.2. Der Kommentar über der Regel sagt zu: „Der Rahmen bleibt sichtbar, damit die Wahl auch im Windows-Kontrastdesign erkennbar ist.“ Die Regel setzte aber genau die zwei Werte, die `.knopf` ohnehin trägt — `border-width: var(--rahmenbreite-stark)` und `font-weight: var(--schriftstaerke-mittel)`. Zwischen dem gewählten Gesetz und den sechs anderen war deshalb kein einziger Bildpunkt Unterschied; getragen wurde die Auskunft allein von `aria-current`, also nur für Bildschirmleser. */ /** * Die Erklärungen eines Regelblocks als Paare. * * Die Kurzform `border` wird dabei aufgelöst. Ohne das ginge der Vergleich * ins Leere: `.knopf` schreibt `border: solid `, die * geprüfte Regel `border-width` — zwei Namen für denselben Wert, und die * Wache hätte den Befund nicht gesehen. */ function erklaerungen(regel: string): Map { const paare = new Map(); for (const teil of regel.replace('{', '').split(';')) { const stelle = teil.indexOf(':'); if (stelle < 0) { continue; } const name = teil.slice(0, stelle).trim(); const wert = teil.slice(stelle + 1).trim(); if (name === 'border') { const stuecke = wert.split(/\s+(?![^(]*\))/u); paare.set('border-width', stuecke[0] ?? ''); paare.set('border-style', stuecke[1] ?? ''); paare.set('border-color', stuecke[2] ?? ''); continue; } paare.set(name, wert); } return paare; } it('setzt mindestens einen Wert, den der Knopf nicht ohnehin hat', () => { const grund = erklaerungen(block(LERNEN_CSS, '.knopf {')); const aktiv = erklaerungen(block(LERNEN_CSS, '.gesetze__knopf--aktiv')); expect(aktiv.size, 'Die Regel ist leer').toBeGreaterThan(0); const wirksam = [...aktiv].filter(([name, wert]) => grund.get(name) !== wert); expect( wirksam.map(([name]) => name), 'Jede Erklärung wiederholt nur, was `.knopf` schon sagt', ).not.toHaveLength(0); }); it('trägt die Auskunft nicht allein über die Farbe (WCAG 1.4.1)', () => { /* Unter erzwungenen Farben kommt keine eigene Hintergrundfarbe durch. Es braucht deshalb ein Merkmal, das dort erhalten bleibt. */ const aktiv = block(LERNEN_CSS, '.gesetze__knopf--aktiv'); expect(aktiv).toMatch(/text-decoration:|font-weight:\s*var\(--schriftstaerke-fett\)/u); }); }); describe('Eine leere Live-Region bleibt im Barrierefreiheitsbaum', () => { /* Befund der Prüfrunde zu 0.27.2, zweimal gefunden. Über der ersten Regel stand: „Leer nimmt die Region keinen Platz weg; sie bleibt trotzdem im DOM, sonst verschluckt der Screenreader die erste Meldung.“ Darunter stand `display: none` — und ein ausgeblendetes Element ist für einen Bildschirmleser nicht vorhanden. Region und Inhalt entstehen dann im selben Aufbau, also genau der Fall, den der Kommentar ausschließen will. Betroffen waren fünf Regionen: die Ausgabe des Lernberichts, „Neu anfangen“, die Profilverwaltung, der Fehlermeldeblock und die Datensicherung. Beim Fehlermeldeblock traf es nicht nur die erste Meldung — dort wird die Region vor jedem Kopierversuch geleert. Geprüft wird nicht gegen eine gepflegte Liste, sondern gegen das Markup: Jede Klasse, die in einer `.tsx` an einem Knoten mit `role="status"` oder `aria-live` steht, darf in keinem Stilblatt eine Regel haben, die sie ausblendet. Eine neue Live-Region ist damit von selbst mitbewacht. */ /** Klassen aller Knoten, die eine Live-Region sind. */ function livebereiche(): Set { const klassen = new Set(); for (const datei of dateien(join(wurzel, 'src', 'renderer', 'src'), '.tsx')) { const inhalt = readFileSync(datei, 'utf8'); for (const [tag] of inhalt.matchAll(/<[a-zA-Z][^>]*>/gsu)) { if (!/role="status"|role="alert"|aria-live=/u.test(tag)) { continue; } const marke = /className="([^"{]*)"/u.exec(tag); for (const name of (marke?.[1] ?? '').split(/\s+/u)) { if (name !== '' && name !== 'nur-screenreader') { klassen.add(name); } } } } return klassen; } it('blendet keine Live-Region im leeren Zustand aus', () => { const klassen = livebereiche(); expect(klassen.size, 'Keine Live-Region gefunden – der Test misst nichts').toBeGreaterThan(5); const stilblaetter = dateien(join(wurzel, 'src', 'renderer', 'src', 'styles'), '.css').map( (datei) => readFileSync(datei, 'utf8'), ); const versteckt: string[] = []; for (const css of stilblaetter) { for (const [, selektor, inhalt] of css.matchAll(/([^{}]+)\{([^}]*)\}/gu)) { if (!/display:\s*none/u.test(inhalt ?? '')) { continue; } for (const klasse of klassen) { if ((selektor ?? '').includes(`.${klasse}`)) { versteckt.push(`${klasse} über „${(selektor ?? '').trim()}“`); } } } } expect(versteckt, 'Eine leere Live-Region darf nicht verschwinden').toEqual([]); }); });