docs(quick-260921-jt4): Plan fuer 30 a11y-Befunde und vier Restposten

29 der 30 Fundstellen werden behoben, eine bleibt begruendet stehen.
Loesungswege vorab empirisch an Biome 2.5.0 geprueft: der in D-01 als
Rueckfall genannte Weg (role+tabIndex) taeuscht -- er tauscht drei Befunde
gegen einen neuen useSemanticElements-Befund und wird deshalb nirgends
benutzt.

Zwei Annahmen der Auftragsbeschreibung beim Nachlesen korrigiert:
vier der elf Klick-Befunde sind onError-Handler an img-Elementen, und
alle fuenf ARIA-Befunde sind aria-label auf rollenlosen Elementen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 14:35:29 +02:00
parent 6cc0d02e97
commit 7557c9aafe
@@ -0,0 +1,720 @@
---
phase: quick-260921-jt4
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/settings/calendar-settings-panel.tsx
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
- apps/web/src/components/dashboard/widgets/note-widget.tsx
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
- apps/web/src/components/dashboard/widgets/note-task-list.tsx
- apps/web/src/components/dashboard/widgets/note-task-list.test.tsx
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
- apps/web/src/components/dashboard/widgets/calculator-widget.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/settings/account-settings-form.tsx
- apps/web/src/app/(auth)/login/page.tsx
- apps/web/src/app/(auth)/reset-password/page.tsx
- apps/web/src/app/(auth)/reset-password/[token]/page.tsx
- apps/web/src/app/(portal)/change-password/page.tsx
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
- apps/web/src/lib/translations-identity.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/api/src/tenders/tender-normalizer.service.ts
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "Jede Stelle, die heute nur mit der Maus bedienbar ist, ist danach auch mit der
Tastatur bedienbar und wird von einer Vorlesehilfe als Schaltflaeche angesagt —
belegt durch Komponententests, die ein Tastaturereignis ausloesen, nicht durch
Auszeichnungs-Behauptungen (D-01)."
- "Das Erscheinungsbild ist an keiner der 13 Dateien ein anderes als vorher: kein
neuer Rahmen, kein neuer Abstand, keine verschobene Kachel (D-04)."
- "Biome meldet nach Abschluss genau 399 Befunde, 0 davon der Stufe error, und genau
EINEN a11y-Befund — den bewusst stehengelassenen Tastatur-Handler des
Taschenrechner-Rahmens. Keine einzige Unterdrueckung per biome-ignore (D-07)."
- "Drei Druecke auf Weiter im Kalender-Widget holen die Quellenliste genau einmal
statt dreimal, und zwei aufeinanderfolgende Termin-Abrufe tragen nie denselben
from/to-Bereich — gemessen im Netzwerkprotokoll des Browsers, nicht per fetch aus
der Seite."
- "Der ZIP-Name des Zertifikat-Aufteilers kommt aus dem Uebersetzungskatalog und ist
auf einer Windows-Freigabe garantiert gueltig, weil eine gepruefte Schutzfunktion
jeden unzulaessigen Namen abfaengt — nicht, weil das deutsche Wort zufaellig
harmlos ist."
- "Jede neue Beschriftung steht in de.json UND en.json, deutsche Oberflaechentexte in
der Sie-Form; beide Kataloge haben danach dieselbe Schluesselmenge (D-05)."
- "Fuer jede der 30 Fundstellen steht in der SUMMARY, welcher der vier Wege gewaehlt
wurde und — bei der Rueckfallvariante oder beim Stehenlassen — warum der gerade
Weg dort nicht ging."
artifacts:
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
- apps/web/src/lib/translations-identity.test.tsx
key_links:
- "Hintergrundflaeche eines Dialogs -> echte Schaltflaeche -> onClose: beim
Loeschen-Dialog MUSS dieser Weg abbrechen, niemals bestaetigen."
- "note-widget -> previewOptions -> rehypeSanitize: die XSS-Schranke T-IEX-01 muss
den Umbau des Kaestchen-Handlers unveraendert ueberleben."
- "calendar-widget -> computeFetchWindow -> fetchEvents: der Sperrgriff darf den
5-Minuten-Auffrischer nicht mitsperren, sonst friert die Anzeige ein."
---
<objective>
Die 30 zurueckgestellten Barrierefreiheits-Befunde abarbeiten — mit den vom Orchestrator
getroffenen Bedienentscheidungen D-01 bis D-04 — und die vier namentlich vermerkten
Restposten aus den heutigen Vorgaengen bi2 und gof schliessen.
Purpose: Heute sind mehrere Bedienelemente ausschliesslich mit der Maus erreichbar. Wer
mit der Tastatur oder einer Vorlesehilfe arbeitet, kann sie nicht ausloesen — das ist der
eigentliche Schaden hinter diesen Befunden, kein Schoenheitsfehler der Zaehlung.
Output: 13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche, zwei
Uebersetzungskataloge im Gleichstand, fuenf neue bzw. erweiterte Testdateien, ein
gemessener Rueckgang von 429 auf 399 Befunde und zwei Kalender-Abrufe weniger pro
Monatswechsel.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/quick/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/260921-bi2-SUMMARY.md
@.planning/quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/260921-gof-VERIFICATION.md
</context>
<measured_baseline>
Vom Planer am 2026-09-21 unmittelbar vor dem Schreiben dieses Plans gemessen, nicht
uebernommen:
```
npx biome lint . --reporter=json
-> total 429 real 350 test 79 errors 0 a11y 30
```
Die Zahl 429 ist die **Gesamtzahl inklusive Testdateien** (350 echter Quelltext + 79
Testdateien), nicht der reine Quelltextanteil — die Auftragsbeschreibung bezeichnet sie
als "real source only", das ist eine Fehlbeschriftung der sonst korrekten Zahl. Alle 30
a11y-Befunde liegen in echtem Quelltext.
Die 30 Fundstellen, mit Zeilennummer zum Zeitpunkt der Planung (Zeilen verschieben sich
beim Umbau — arbeite nach Element, nicht nach Zeilennummer):
| Datei | Zeile | Regel |
|---|---|---|
| `app/(auth)/login/page.tsx` | 105 | noAutofocus |
| `app/(auth)/reset-password/[token]/page.tsx` | 139 | noAutofocus |
| `app/(auth)/reset-password/page.tsx` | 99 | noAutofocus |
| `app/(portal)/change-password/page.tsx` | 86 | noAutofocus |
| `marketplace/components/MarketplaceCard.tsx` | 116 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
| `components/dashboard/widget-catalog-modal.tsx` | 56 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
| `components/dashboard/widget-catalog-modal.tsx` | 64 | noNoninteractiveElementInteractions, useKeyWithClickEvents |
| `dashboard/widgets/calculator-widget.tsx` | 323 | noNoninteractiveElementInteractions |
| `dashboard/widgets/calculator-widget.tsx` | 345 | useAriaPropsSupportedByRole |
| `dashboard/widgets/calendar-widget.tsx` | 253 | noNoninteractiveElementInteractions, noStaticElementInteractions |
| `dashboard/widgets/favorites-widget.tsx` | 266 | useAriaPropsSupportedByRole |
| `dashboard/widgets/favorites-widget.tsx` | 462 | noNoninteractiveElementInteractions |
| `dashboard/widgets/favorites-widget.tsx` | 474 | noNoninteractiveElementInteractions |
| `dashboard/widgets/note-widget.tsx` | 184 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
| `components/layout/header.tsx` | 156 | noNoninteractiveElementInteractions |
| `components/settings/account-settings-form.tsx` | 161 | noNoninteractiveElementInteractions |
| `components/settings/calendar-settings-panel.tsx` | 159, 182, 199 | useAriaPropsSupportedByRole |
| `components/settings/calendar-settings-panel.tsx` | 373 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
**Zwei Befunde der Auftragsbeschreibung sind beim Nachlesen anders als angenommen** — der
Plan folgt dem gelesenen Quelltext, nicht der Annahme:
1. **Vier der elf `noNoninteractiveElementInteractions` sind ueberhaupt keine Klicks**,
sondern `onError`-Handler an `<img>`-Elementen (Ersatzweg fuer nicht ladende
Profilbilder und Favoriten-Symbole): `header.tsx:156`, `account-settings-form.tsx:161`,
`favorites-widget.tsx:462` und `:474`. Ein Ladefehler ist keine Bedienung; hier gibt es
nichts in eine Schaltflaeche zu verwandeln.
2. **Alle fuenf `useAriaPropsSupportedByRole` haben dieselbe Gestalt**: ein `aria-label`
sitzt auf einem schlichten `<div>` bzw. `<span>` ohne Rolle. Solche Elemente haben die
Rolle `generic`, die gar keine ARIA-Merkmale traegt — die Beschriftung wird von jeder
Vorlesehilfe **stillschweigend verworfen**. Das ist ein echter Mangel, kein Formfehler.
</measured_baseline>
<verified_probes>
Die folgenden Loesungswege wurden vom Planer **empirisch an Biome 2.5.0 mit der
Projektkonfiguration geprueft** (Probedatei unter `apps/web/src/__probe__/`, danach
geloescht). Das ist keine Vermutung — jede Zeile ist gemessen:
| Probe | Ergebnis |
|---|---|
| `<button type="button" className="fixed inset-0" aria-label=… onClick=…/>` | **sauber** — traegt den Dialog-Hintergrund |
| `<button … onMouseEnter onMouseLeave onFocus onBlur>` | **sauber** — traegt die Kalender-Tageszelle |
| Deckende Schaltflaeche als Geschwister neben der Aktionsschaltflaeche in einer Karte | **sauber** — traegt die Marktplatz-Karte |
| `<span role="img" aria-label=…><svg aria-hidden/></span>` | **sauber** — traegt die drei Statussymbole |
| `<div role="toolbar" aria-label=…>` ohne Handler | **sauber** — traegt die beiden Schaltflaechenreihen |
| `<img alt="" aria-hidden="true" onError=…/>` | a11y-Befund **verschwindet** (nur der vorbestehende `performance/noImgElement` bleibt) |
| `<div role="button" tabIndex={0} onClick onKeyDown>` | **loest `useSemanticElements` NEU aus** |
| `<div role="group" aria-label=…>` | **loest `useSemanticElements` NEU aus** (Vorschlag: `<fieldset>`) |
| `<div role="region" aria-label=…>` | **loest `useSemanticElements` NEU aus** (Vorschlag: `<section>`) |
| `<div role="application"/"group"/"toolbar" … onKeyDown>` | Befund bleibt in **allen drei** Varianten |
**Die wichtigste Erkenntnis daraus:** Der in D-01 als Rueckfall genannte Weg
(`role="button"` + `tabIndex={0}` + Tastaturhandler) bringt die Zaehlung NICHT auf null.
Er tauscht drei Befunde gegen einen neuen `useSemanticElements`-Befund — eine Regel, die
260921-bi2 gerade erst auf 0 gebracht hat. D-07 verbietet, dass die Zahl anderswo waechst.
**Der Rueckfall wird deshalb in diesem Vorgang an keiner einzigen Stelle benutzt**; wo
eine unmittelbare Umwandlung in `<button>` an der Verschachtelung scheitert (Marktplatz),
tritt stattdessen die geprueft saubere deckende Geschwister-Schaltflaeche an ihre Stelle.
</verified_probes>
<decisions_applied>
Zuordnung der 30 Fundstellen zu den gesperrten Entscheidungen. Diese Tabelle ist die
Vorgabe, nicht ein Vorschlag:
| # | Fundstelle | Weg | Entscheidung |
|---|---|---|---|
| 1-3 | MarketplaceCard.tsx (Karte) | echter Button (deckendes Geschwister) | D-01 |
| 4-6 | widget-catalog-modal.tsx (Hintergrund) | echter Button | D-01 |
| 7-8 | widget-catalog-modal.tsx (Dialogflaeche) | Handler entfaellt ersatzlos | D-01 |
| 9-11 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | echter Button | D-01 |
| 12-14 | note-widget.tsx (Vorschau) | Kaestchen uebernimmt seinen Handler selbst | D-01 |
| 15-16 | calendar-widget.tsx (Tageszelle) | echter Button + Fokus-Handler | D-01 |
| 17-20 | vier `noAutofocus`-Stellen | Attribut entfaellt (alle vier sind Seiten, kein Dialog) | D-02 |
| 21-23 | calendar-settings-panel.tsx (3 Statussymbole) | `role="img"` + uebersetzte Beschriftung | D-03 |
| 24 | calculator-widget.tsx (Speicherzeile) | `role="toolbar"` + uebersetzte Beschriftung | D-03 |
| 25 | favorites-widget.tsx (Ansichtsumschalter) | `role="toolbar"` + **richtige** Beschriftung | D-03 |
| 26-29 | vier `<img onError>` | `aria-hidden="true"` | D-03 |
| 30 | calculator-widget.tsx (Tastatur am Rahmen) | **bleibt stehen, bleibt gezaehlt** | D-07 |
**Zu D-02:** Keine der vier `autoFocus`-Stellen ist ein Dialog. Es sind vier
Seitenformulare (Anmeldung, Passwort-Zuruecksetzen anfordern, Passwort-Zuruecksetzen
einloesen, Passwort aendern). D-02 sagt fuer genau diesen Fall: Entfernen ist richtig,
weil Fokus-Klauen beim Seitenaufruf das ist, wogegen die Regel existiert. Der
ref+Effekt-Zweig von D-02 kommt in diesem Projekt also an keiner Stelle zum Zug — das ist
ein Ergebnis, kein Uebersehen. Besonderer Nebennutzen bei `change-password/page.tsx`:
unmittelbar ueber dem Formular steht der Hinweisbereich zum erzwungenen Wechsel; heute
springt der Fokus daran vorbei, eine Vorlesehilfe liest den Hinweis nie vor.
**Zu Fundstelle 30 (bleibt stehen):** Der Rahmen des Taschenrechners traegt
`role="application"` und einen `onKeyDown`, damit getippte Ziffern ankommen, sobald
irgendeine seiner Tasten den Fokus hat. Jede sichtbare Taste ist bereits ein echtes
`<button>` und selbst Teil der Tab-Reihenfolge. Der Handler **fuegt einen Tastaturweg
hinzu** — er ist das Gegenteil des Schadens, den die Regel beschreibt. Alle drei
Rollen-Alternativen wurden gemessen und aendern nichts (siehe Probentabelle).
**Ausdruecklich NICHT gewaehlt und auch spaeter nicht nachzuholen:** den Handler per
`addEventListener` in einem Effekt anzuhaengen. Das Verhalten waere identisch, nur die
Regel saehe ihn nicht mehr — das waere eine geschoente Zahl ohne Gegenwert, und D-07
verbietet genau das. Der Befund bleibt sichtbar in der Zaehlung stehen.
</decisions_applied>
<tasks>
<task type="auto" tdd="true">
<name>Aufgabe 1: Aus klickbaren Bereichen echte Schaltflaechen machen (16 Befunde, 5 Dateien)</name>
<files>apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx, apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-settings-panel.test.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/note-task-list.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx, apps/web/src/components/dashboard/widgets/note-task-list.test.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
Lies vor dem ersten Eingriff den Abschnitt `verified_probes` dieses Plans und die
Pitfall-Notiz aus `260921-bi2-SUMMARY.md` (Abschnitt "tech-stack -> patterns"): ein
a11y-Fix kann eine ANDERE Regel neu ausloesen, wenn ein Element seine interaktive
Einstufung verliert. Miss nach jedem einzelnen Teilumbau die vollstaendige a11y-Menge,
nicht nur die Zielregel.
</read_first>
<behavior>
- MarketplaceCard: Tastaturbedienung der Karte oeffnet die Moduldetails; die
Aktivieren-Schaltflaeche bleibt ein eigener, separat erreichbarer Tab-Stopp und
loest beim Ausloesen NICHT zusaetzlich das Oeffnen aus.
- MarketplaceCard, gesperrter Zustand: Tastaturbedienung ruft den Gesperrt-Hinweis auf.
- MarketplaceCard, nicht aktiviertes Modul: es gibt keine Kartenschaltflaeche.
- widget-catalog-modal: Ausloesen der Hintergrundflaeche schliesst; ein Klick im
Dialog schliesst nicht; Escape schliesst weiterhin.
- calendar-settings-panel, Loeschdialog: Ausloesen der Hintergrundflaeche **bricht ab**
(Datensatz bleibt bestehen), Loeschen geschieht ausschliesslich ueber die
Loeschen-Schaltflaeche.
- note-widget: Ein Aufgabenkaestchen laesst sich mit der Tastatur umschalten und die
zugehoerige Markdown-Zeile kippt; im Bearbeitungsmodus passiert nichts.
- calendar-widget: Ein Tag MIT Terminen ist mit der Tastatur fokussierbar und zeigt
beim Fokussieren dieselbe Termin-Einblendung wie beim Ueberfahren mit der Maus;
beim Verlassen verschwindet sie. Ein Tag OHNE Termine ist kein Tab-Stopp.
</behavior>
<action>
Fuenf Umbauten, jeder einzeln zu committen. Umsetzung von D-01 ueberall ohne den
Rueckfallweg, weil dieser messbar eine andere Regel neu ausloesen wuerde (siehe
`verified_probes`). D-04 gilt durchgehend: das Erscheinungsbild bleibt gleich.
**(1) MarketplaceCard.tsx — die Karte.** Der `onClick` sitzt heute auf der Karten-`<div>`,
und die Karte enthaelt im Fuss die Aktivieren/Deaktivieren-Schaltflaeche. Eine unmittelbare
Umwandlung der Karte in ein `<button>` ist deshalb unmoeglich (verschachtelte
Schaltflaechen sind ungueltige Auszeichnung — genau die Falle, in die 260921-gof bei
`DropZone.tsx` schon einmal gelaufen ist). Stattdessen: die Karten-`<div>` verliert ihren
`onClick` und bekommt `relative`; als Geschwisterelement kommt eine deckende
`<button type="button" className="absolute inset-0 rounded-lg …">` hinzu, die
`handleCardClick` traegt und nur gerendert wird, wenn `isActive` gilt (heute haengt der
Handler ebenfalls an `isActive`). Die Maus-Zeigerform der deckenden Schaltflaeche
uebernimmt die Fallunterscheidung der Karte (`cursor-not-allowed` bei `locked`, sonst
`cursor-pointer`), damit sich optisch nichts aendert. Der Fussbereich mit der
Aktivieren-Schaltflaeche bekommt `relative`, damit er ueber der deckenden Flaeche liegt und
weiterhin unmittelbar getroffen wird; das dortige `e.stopPropagation()` bleibt unangetastet.
Die deckende Schaltflaeche braucht einen Namen fuer die Vorlesehilfe — verwende den
vorhandenen Modulnamen ueber einen neuen Schluessel `marketplace.openDetail` (D-05).
Setze `<h3>`/Beschreibung NICHT in die Schaltflaeche hinein: die Ueberschrift kuerzt per
`truncate` (also `overflow-hidden`) und wuerde ein darin liegendes Deckelement beschneiden.
**(2) widget-catalog-modal.tsx — Hintergrund und Dialogflaeche.** Heute traegt die
aeussere Flaeche den `onClick={onClose}`, und die Dialogflaeche haelt mit einem
`stopPropagation` dagegen. Kehre das um: die aeussere Flaeche verliert ihren Handler
ersatzlos; die bereits vorhandene, bislang rein optische Hintergrund-`<div>`
(`fixed inset-0 bg-black/50`) wird zu
`<button type="button" className="fixed inset-0 bg-black/50" onClick={onClose}>` und
verliert dabei ihr `aria-hidden` — ein fokussierbares Element darf nicht vor der
Vorlesehilfe verborgen sein. Sie bekommt stattdessen einen echten Namen ueber einen neuen
Schluessel `widgets.catalogClose` (D-05). Weil der schliessende Handler danach kein
Vorfahr der Dialogflaeche mehr ist, sondern ihr Geschwister, ist das `stopPropagation`
auf der Dialogflaeche **toter Code und wird geloescht** — damit fallen die beiden Befunde
an dieser Stelle weg, ohne dass sich irgendein Verhalten aendert. Der Escape-Weg im
bestehenden Effekt bleibt unveraendert. Ersetze bei dieser Gelegenheit das fest
verdrahtete englische `aria-label="Close"` der Schliessen-Schaltflaeche durch
`common.close` (D-05) — es steht in der Datei, die du ohnehin umbaust.
**(3) calendar-settings-panel.tsx — der Loeschbestaetigungs-Dialog.** Dieselbe Gestalt,
andere Datei: die `fixed inset-0 … bg-black/50`-Flaeche ist zugleich Hintergrund UND
Zentrierbehaelter und prueft im Handler `e.target === e.currentTarget`. Trenne beides: der
Behaelter behaelt `fixed inset-0 z-50 flex items-center justify-center` und verliert jeden
Handler; die Hintergrundfarbe wandert auf eine neue
`<button type="button" className="fixed inset-0 bg-black/50" onClick={() => setDeletingId(null)}>`;
die Dialogkarte bekommt `relative`, damit sie weiterhin ueber dem Hintergrund liegt.
Beschriftung ueber einen neuen Schluessel `widgets.calendar.deleteDialogCancel`, dessen
Text das Abbrechen benennt — dieser Weg darf niemals loeschen (siehe threat_model
T-JT4-04). Ersetze ausserdem das fest verdrahtete englische `aria-label="Confirm deletion"`
des `role="alertdialog"` durch einen neuen Schluessel
`widgets.calendar.deleteDialogLabel` (D-05).
**(4) note-widget.tsx + note-task-list.tsx — das Aufgabenkaestchen bedient sich selbst.**
Heute faengt der Vorschau-Behaelter die Klicks ab (`handlePreviewClick`) und das von
`NoteCheckbox` gerenderte Kaestchen traegt `readOnly`. Dreh das um: `NoteCheckbox`
bekommt einen `onChange` und gibt darin sein eigenes DOM-Element an einen von aussen
gereichten Rueckruf weiter; `readOnly` entfaellt. Die **Index-Ermittlung bleibt Wort fuer
Wort die heutige** (alle Kaestchen im Vorschau-Behaelter einsammeln, `indexOf` auf dem
ausloesenden Element) — sie wandert lediglich vom Behaelter-Handler in eine Funktion des
Widgets, die den Behaelter ueber ein `ref` statt ueber `event.currentTarget` findet.
Fasse den bisher nur module-weit gueltigen `PREVIEW_OPTIONS`-Wert in ein `useMemo`, das
den Rueckruf ueber ein `useRef` erreicht, damit das Optionsobjekt **identitaetsstabil**
bleibt — der bestehende Kommentar begruendet genau das, und ein pro Tastendruck neu
erzeugtes Optionsobjekt liesse react-markdown bei jedem Zeichen neu abgleichen.
`rehypePlugins: [[rehypeSanitize]]` muss dabei unveraendert erhalten bleiben (XSS-Schranke
T-IEX-01, siehe threat_model T-JT4-03). Der `onClick` am Vorschau-Behaelter entfaellt
danach ersatzlos. Die Pruefung auf `isEditing` bleibt erhalten. Nebennutzen, den du in der
SUMMARY benennen sollst: `readOnly` war bisher nur da, um Reacts Warnung ueber ein
gesteuertes Feld ohne `onChange` zu unterdruecken — das Kaestchen bedient sich jetzt
tatsaechlich selbst, statt sich von seinem Behaelter bedienen zu lassen.
**(5) calendar-widget.tsx — die Tageszelle.** Heute traegt jede der 42 Zellen
`onMouseEnter`/`onMouseLeave` fuer die Termin-Einblendung. Wer nicht mit der Maus
arbeitet, bekommt die Termine eines Tages **gar nicht** zu sehen — das ist der echte
Mangel hinter diesem Befund. Rendere die Zelle als `<button type="button">`, **wenn und
nur wenn sie Termine hat** (`hasEvents`), sonst unveraendert als `<div>` ohne Handler.
Nur Tage mit Terminen werden so zu Tab-Stopps; ein Widget mit 42 neuen Tab-Stopps waere
eine Verschlechterung. Die Schaltflaechen-Variante traegt zusaetzlich `onFocus`/`onBlur`
mit demselben Rumpf wie `onMouseEnter`/`onMouseLeave`, damit die Einblendung fuer die
Tastatur genauso erscheint und verschwindet. Uebernimm `cellClass` unveraendert und
ergaenze nur das, was ein `<button>` braucht, um wie die bisherige `<div>` auszusehen
(Textausrichtung, volle Breite, kein geerbter Schaltflaechenrahmen) — `widgetNoDrag` muss
erhalten bleiben, sonst reisst react-grid-layout die Kachel beim Klicken mit.
Die Schaltflaeche bekommt einen Namen, der Datum und Terminzahl nennt; folge der im
Katalog bereits vorhandenen Mehrzahl-Konvention dieses Projekts mit zwei getrennten
Schluesseln (`widgets.calendar.dayEventsOne` / `widgets.calendar.dayEventsMany`, Vorbild
`configMaxEventsOne`/`configMaxEventsMany`), nicht mit ICU-Plural (D-05).
**Uebersetzungen:** Jeder neue Schluessel kommt in `de.json` UND `en.json`, deutsche Texte
in der Sie-Form (D-05). Keine neuen Pakete, keine Versionsanhebung, keine Umformatierung
fremder Dateien (D-06).
**Tests — Tastaturereignis schlaegt Auszeichnungsbehauptung.** Erweitere bzw. lege an:
`MarketplaceCard.test.tsx` (vorhanden), `widget-catalog-modal.test.tsx` (neu),
`calendar-settings-panel.test.tsx` (neu), `note-widget.test.tsx` und
`note-task-list.test.tsx` (vorhanden), `calendar-widget.test.tsx` (vorhanden).
`@testing-library/user-event` ist in `apps/web` verfuegbar (14.6.1) — nutze es, um nach
`.focus()` eine echte Tastaturbetaetigung auszuloesen, statt einen Klick zu senden und
Tastaturbedienung nur zu behaupten. Fuer das Notiz-Kaestchen ist die Tastaturbetaetigung
gerade der Punkt: ein fokussiertes Kaestchen muss sich mit der Leertaste kippen lassen.
Fuer den Loeschdialog muss ein Test belegen, dass der Hintergrundweg abbricht und die
Quelle **nicht** geloescht wird.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>/a11y\/(noNoninteractiveElementInteractions|noStaticElementInteractions|useKeyWithClickEvents)/.test(x.category));a.forEach(x=>console.log(' ',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('klick-regeln',a.length,'| semantic',d.filter(x=>x.category==='lint/a11y/useSemanticElements').length,'| a11y gesamt',d.filter(x=>x.category.includes('a11y')).length,'| gesamt',d.length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | tail -4</automated>
</verify>
<done>
Erste Pruefung meldet `klick-regeln 5` — genau die vier `<img onError>`-Stellen
(`header.tsx`, `account-settings-form.tsx`, `favorites-widget.tsx` zweimal) und der
Taschenrechner-Rahmen; `semantic 0` (die Regel ist NICHT gewachsen); `a11y gesamt 14`;
`errors 0`. `apps/web`-Tests gruen mit mindestens 69 Dateien und mindestens 484 Tests
(die neuen Faelle kommen obendrauf, die Zahl darf nur steigen). `pnpm type-check` 4/4.
Fuer jede der fuenf Umbauten steht fest, welcher Weg gewaehlt wurde; der Rueckfallweg aus
D-01 wurde an keiner Stelle benutzt.
</done>
</task>
<task type="auto">
<name>Aufgabe 2: Rollen, Beschriftungen, Autofokus — und zwei Restposten (14 Befunde)</name>
<files>apps/web/src/app/(auth)/login/page.tsx, apps/web/src/app/(auth)/reset-password/page.tsx, apps/web/src/app/(auth)/reset-password/[token]/page.tsx, apps/web/src/app/(portal)/change-password/page.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/dashboard/widgets/calculator-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/settings/account-settings-form.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts, apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<action>
Attributarbeit ohne Strukturumbau — deutlich geringeres Risiko als Aufgabe 1, deshalb
getrennt. Dazu zwei der vier vermerkten Restposten.
**(1) Vier `autoFocus` entfernen (D-02).** `login/page.tsx`, `reset-password/page.tsx`,
`reset-password/[token]/page.tsx`, `change-password/page.tsx`. Der Planer hat alle vier
gelesen: es sind Seitenformulare, keine Dialoge — D-02 verlangt hier das Entfernen und
ausdruecklich NICHT den Ersatz durch ref+Effekt. Ein Ersatz per Effekt waere dasselbe
Verhalten mit stillgelegter Regel. Entferne ausschliesslich das Attribut; `autoComplete`,
`required` und alles andere bleibt. Halte in der SUMMARY je Stelle fest, dass es eine
Seite und kein Dialog war.
**(2) Drei Statussymbole in `calendar-settings-panel.tsx` (D-03).** Die drei `<span>` mit
`aria-label="Sync error"` / `"Connection OK"` / `"Connection error"` haben die Rolle
`generic` und ihre Beschriftung wird stillschweigend verworfen. Die Beschriftung traegt
hier echte Bedeutung — sie ist der EINZIGE Text dieser Symbole (das `<svg>` darin ist
bereits `aria-hidden`). Also Ursache beheben, nicht Attribut streichen: `role="img"`
ergaenzen (geprueft sauber). Die drei englischen Texte in einer deutschen Oberflaeche
gehen dabei in den Katalog (D-05): fuer den Erfolgsfall ist der vorhandene Schluessel
`widgets.calendar.connectionSuccess` woertlich passend und wird wiederverwendet; fuer die
beiden Fehlerfaelle lege kurze eigene Schluessel an (`widgets.calendar.syncErrorLabel`,
`widgets.calendar.connectionFailedLabel`) — der vorhandene `connectionError` ist ein
ganzer Hinweissatz und als Symbolbeschriftung zu lang. Das `title`-Attribut mit dem
Rohfehler bleibt unangetastet.
**(3) Zwei Schaltflaechenreihen (D-03).** `calculator-widget.tsx`, Speicherzeile
(`aria-label="Speicherfunktionen"`) und `favorites-widget.tsx`, Ansichtsumschalter
(`aria-label={t('favorites.name')}`): beide sind schlichte `<div>`, die Beschriftung
verpufft. Ergaenze `role="toolbar"` (geprueft sauber — `role="group"` und `role="region"`
scheiden aus, sie loesen `useSemanticElements` neu aus). Beim Taschenrechner wandert der
fest verdrahtete deutsche Text in den Katalog (`widgets.calculator.memoryLabel`). Beim
Favoriten-Umschalter ist die heutige Beschriftung sachlich falsch — sie sagt "Favoriten"
ueber einem Umschalter zwischen Listen- und Kachelansicht; vergib einen neuen, zutreffenden
Schluessel `widgets.favorites.viewModeLabel`. Das ist der Fall, in dem D-03 das Streichen
erlauben wuerde (die Beschriftung trug keine echte Bedeutung); eine richtige Beschriftung
ist trotzdem besser als gar keine. Uebersetze im Taschenrechner bei dieser Gelegenheit die
beiden weiteren fest verdrahteten deutschen Beschriftungen derselben Ansicht (Anzeigefeld,
Rueckschritt-Taste) in Katalogschluessel (D-05) — Grenze der Ausweitung: nur
Beschriftungen in Dateien, die dieser Vorgang ohnehin aendert.
**(4) Vier `<img onError>` (D-03).** `header.tsx`, `account-settings-form.tsx` und
zweimal `favorites-widget.tsx`. Ergaenze `aria-hidden="true"`. **Sei in der SUMMARY
ehrlich darueber, was das leistet und was nicht:** alle vier tragen bereits `alt=""`, sind
also schon aus dem Zugaenglichkeitsbaum genommen; `aria-hidden` sagt dasselbe nur
ausdruecklich. Es ist richtige Auszeichnung, aber es verbessert fuer keinen Menschen
etwas — der Befund verschwindet, weil die Regel ein verborgenes Element nicht mehr
betrachtet. `onError` ist ein Ladefehler, keine Bedienung: hier gab es nie einen
Tastaturweg zu schaffen. Keine Unterdrueckung, kein `biome-ignore`.
**(5) Restposten 2 — `tender-normalizer.service.ts`, `noUselessSwitchCase`.** Nachgelesen:
die Fallmarke `case 'doe-opendata':` steht unmittelbar ueber `default:` und faellt in
denselben Zweig; der Kommentar darunter erklaert, warum der Standardzweig auf dem
DOE-Weg bleiben muss. 260921-bi2 hat sie stehen lassen, weil sie Absicht dokumentiert.
Diese Absicht laesst sich ohne die ueberfluessige Marke ausdruecken und wird dabei sogar
deutlicher: entferne die Fallmarke und erweitere den bestehenden Kommentar so, dass er
beide Aussagen traegt — dass die DOE-Quelle hier landet UND dass kuenftige additive
Mitglieder der SourceType-Vereinigung ebenfalls hier landen sollen, statt zu scheitern.
Kein Verhaltenswechsel: der Zweig, in den `'doe-opendata'` faellt, ist vorher wie nachher
derselbe. Belege das mit den vorhandenen `apps/api`-Tests.
**(6) Restposten 1 — ZIP-Name im Zertifikat-Aufteiler.** `downloadAllAsZip` in
`SplitTab.tsx` ist eine Funktion ausserhalb der Komponente und kann den
Uebersetzungs-Hook nicht aufrufen; reiche den Namen deshalb als Parameter herein und
uebergib an der Aufrufstelle `t('actions.zipFilename')`. Neuer Schluessel unter
`certManager.actions` in beiden Katalogen: deutsch `Zertifikate.zip`, englisch
`certificates.zip`.
Der Einwand aus 260921-bi2 war, ein uebersetzter Name koenne Umlaute auf eine
Windows-Freigabe tragen. Neuer Befund: das deutsche Wort fuer Zertifikate enthaelt keinen
Umlaut und kein von Windows verbotenes Zeichen, der Einwand trifft fuer diesen konkreten
Text also nicht zu. **Verlass dich aber nicht darauf, dass das Wort zufaellig harmlos
ist** — sonst haengt die Dateisystem-Sicherheit an einer kuenftigen
Uebersetzungsentscheidung. Lege `zip-filename.ts` mit einer kleinen, fuer sich pruefbaren
Schutzfunktion an, die einen Namen auf das fuer eine Windows-Freigabe Zulaessige
zurueckschneidet: die von Windows verbotenen Zeichen und Steuerzeichen ersetzen,
Nicht-ASCII ersetzen, abschliessende Punkte und Leerzeichen entfernen, die reservierten
Geraetenamen abfangen, bei leerem Ergebnis auf `certificates.zip` zurueckfallen und die
Endung sicherstellen. `SplitTab.tsx` schickt den uebersetzten Namen durch diese Funktion,
bevor er am Download landet. Decke die Funktion in `zip-filename.test.ts` ab: der
deutsche und der englische Katalogwert kommen unveraendert durch, ein Name mit Umlaut und
einer mit verbotenem Zeichen werden bereinigt, ein aussichtsloser Name faellt auf den
Ersatznamen zurueck. Die Namen der einzelnen Dateien IM Archiv stammen aus der API und
bleiben unangetastet.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>x.category.includes('a11y'));a.forEach(x=>console.log(' ',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('a11y',a.length,'| switch',d.filter(x=>x.category==='lint/complexity/noUselessSwitchCase').length,'| gesamt',d.length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const f=o=>Object.entries(o).flatMap(([k,v])=>v&&typeof v==='object'?f(v).map(x=>k+'.'+x):[k]);const de=f(require('./apps/web/src/messages/de.json')),en=f(require('./apps/web/src/messages/en.json'));const A=new Set(de),B=new Set(en);console.log('de',de.length,'en',en.length,'nurDe',de.filter(k=>!B.has(k)).join(',')||'-','nurEn',en.filter(k=>!A.has(k)).join(',')||'-');"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run zip-filename 2>&1 | tail -6</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run 2>&1 | tail -6</automated>
</verify>
<done>
Erste Pruefung meldet `a11y 1` und die einzige verbleibende Zeile nennt
`noNoninteractiveElementInteractions` in `calculator-widget.tsx`; `switch 0`;
`gesamt 399`; `errors 0`. Katalogpruefung: beide Kataloge gleich lang, weder `nurDe` noch
`nurEn` nennt einen Schluessel. `zip-filename`-Tests gruen. `apps/api`-Tests gruen mit
mindestens 72 Dateien und mindestens 1143 Tests. Weicht `gesamt` von 399 ab, ist das kein
stiller Durchlauf: nenne die Abweichung nach Regel aufgeschluesselt und ihre Ursache.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Verschwendete Abrufe — Kalender-Ladefenster und die vier t-Abhaengigkeiten</name>
<files>apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/lib/translations-identity.test.tsx</files>
<read_first>
`apps/web/src/components/dashboard/widgets/calendar-month.ts`, Funktion
`computeFetchWindow` samt Kommentar: das Ladefenster ist die **Vereinigung** aus
42-Tage-Raster und Vorschauzeitraum, und alle vier Zwischenwerte sind auf lokale
Tagesgrenzen gerundet, damit der Cache-Schluessel des Backends ueber den
5-Minuten-Auffrischer stabil bleibt. Diese Eigenschaft darf der Umbau nicht verlieren.
</read_first>
<behavior>
- Beim Aufbau der Kachel wird die Quellenliste genau einmal geholt.
- Ein Monatswechsel holt die Quellenliste NICHT erneut.
- Ein Monatswechsel, dessen berechnetes Ladefenster mit dem zuletzt geholten
uebereinstimmt, loest KEINEN Termin-Abruf aus.
- Ein Monatswechsel mit abweichendem Ladefenster loest genau einen Termin-Abruf aus.
- Der 5-Minuten-Auffrischer holt weiterhin beides, auch wenn sich nichts geaendert
hat — sonst friert die Anzeige ein.
</behavior>
<action>
**(1) Das doppelte Ladefenster (Restposten 3a).** Der Planer hat nachgerechnet, woher die
Beobachtung des Orchestrators kommt: `computeFetchWindow` nimmt `from` als den frueheren
von Rasteranfang und heutigem Tagesbeginn und `to` als den spaeteren von Rasterende und
Vorschauhorizont. Reicht der Vorschauhorizont ueber das Rasterende hinaus, ergeben **zwei
benachbarte kuenftige Monate exakt dasselbe Fenster** — bei der Vorschau-Einstellung
90 Tage trifft das fuer Oktober und November zu, bei der Voreinstellung 30 Tage nicht.
Das erklaert, warum der Effekt nur unter bestimmten Einstellungen sichtbar ist, und es ist
der Grund, warum die Browser-Messung unten ausdruecklich auf 90 Tage gestellt werden muss.
Die Vereinigung selbst ist richtig und bleibt — die Kachel zeigt Monatsraster UND
Terminvorschau. Verschwendet wird nur der erneute Abruf eines bereits geholten Bereichs.
Merke dir deshalb in einem `ref` das zuletzt tatsaechlich geholte `from`/`to`-Paar (als
die beiden ISO-Zeichenketten, die auch an die API gehen) und ueberspringe den
Termin-Abruf, wenn das neu berechnete Paar damit uebereinstimmt. Der Sperrgriff gilt
**nur fuer den durch den Monatswechsel ausgeloesten Lauf**; der 5-Minuten-Auffrischer
muss unbedingt weiter abrufen, auch bei gleichem Fenster, sonst veraltet die Anzeige
still. Gib `loadData` dazu einen Parameter, der das Erzwingen ausdrueckt, und uebergib ihn
aus dem Intervall. Achte darauf, dass der uebersprungene Lauf den Ladezustand trotzdem
sauber beendet und die bereits geladenen Termine nicht leert.
**(2) Die Quellenliste bei jedem Monatswechsel (Restposten 3b).** `loadData` ruft heute
bei jedem Lauf zuerst `fetchSources()` auf, obwohl die Quellenliste nicht vom angezeigten
Monat abhaengt. Halte das Ergebnis in einem `ref` fest und hole die Liste nur, wenn sie
noch unbekannt ist ODER der Lauf erzwungen wurde (also beim Aufbau und beim
5-Minuten-Auffrischer). Damit bemerkt die Kachel eine neu eingerichtete Quelle weiterhin
innerhalb von fuenf Minuten — das heutige Verhalten bleibt also erhalten, nur der
Monatswechsel hoert auf, unnoetig zu fragen. Der Sonderfall "gar keine Quellen
eingerichtet" muss sich genauso verhalten wie heute.
Beides sind keine Verhaltensdefekte, sondern Verschwendung — aber sie reicht durch die API
bis zu einem echten Exchange-Server durch (siehe threat_model T-JT4-02). Aendere nichts an
`computeFetchWindow` selbst und nichts an der Tagesgrenzen-Rundung.
**(3) Tests fuer beides.** `calendar-widget.test.tsx` ist vorhanden und hat die
Abruf-Attrappen bereits eingerichtet. Ergaenze Faelle, die die Aufrufe der Attrappen
**zaehlen**: Aufbau (je 1), Monatswechsel mit abweichendem Fenster (Termine +1, Quellen
+0), Monatswechsel mit identischem Fenster (beide +0), und ein erzwungener Lauf ueber den
Zeitgeber (beide +1) — fuer den letzten Fall die Zeitgeber-Attrappe von Vitest nutzen.
Das identische Fenster stellst du her, indem du die Kachel mit der Vorschau-Einstellung
90 Tage renderst und um einen Monat weiterschaltest.
**(4) Restposten 4 — die vier `t`-Abhaengigkeiten.** Der Planer hat die Annahme
ueberprueft, auf der 260921-gof beruhte ("`t` ist in diesem Projekt bei jedem Render eine
frische Funktion"), und sie im Quelltext der eingesetzten Fassung widerlegt: `use-intl`
4.13.0 erzeugt `t` in einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem
Intl-Kontext stammen, und der Anbieter steht in `app/layout.tsx`, also oberhalb aller
betroffenen Komponenten. Ein Zustandswechsel in einer dieser Komponenten rendert den
Anbieter nicht neu, also behaelt `t` seine Identitaet, also bleiben die davon abhaengigen
Rueckrufe stabil, also laeuft der Effekt nicht erneut. Dazu passt die bereits erbrachte
Messung aus dem gof-Nachtrag: der Marktplatz holt `modules/catalog` in 20 Sekunden genau
einmal.
**Belege das, statt es zu behaupten**, und zwar einmal an der Wurzel statt viermal an den
Symptomen: lege `translations-identity.test.tsx` an, das eine kleine Testkomponente unter
dem echten `NextIntlClientProvider` rendert, einen Zustandswechsel in der Komponente
ausloest und festhaelt, dass `t` vor und nach dem erneuten Render dasselbe Objekt ist.
Dieser eine Test entscheidet alle vier Stellen auf einmal, weil der Schadensmechanismus
ueberall derselbe ist.
Danach entscheide nach Messlage, nicht nach Gewohnheit:
- **Bleibt die Identitaet erhalten** (erwartet), dann aendere an den vier Stellen
**nichts**. Halte in der SUMMARY fest, dass die Faustregel aus gof ("`t` gehoert in
keine Abhaengigkeitsliste") als Konvention in Ordnung bleibt, die vier verbliebenen
Stellen aber nachweislich keinen zusaetzlichen Abruf ausloesen — und korrigiere dabei
ausdruecklich die zugrunde liegende Annahme, damit sie nicht ein drittes Mal
weitergetragen wird.
- **Faellt die Identitaet doch**, dann wende an den beiden Stellen, die in einem Effekt
landen (`marketplace/page.tsx`, `admin/users/page.tsx`), die aus gof bekannte Technik an
(uebersetzten Text vor dem Rueckruf in eine Konstante ziehen und von der Konstante
abhaengen). Die beiden anderen (`calendar-settings-panel.tsx`,
`calendar-source-form.tsx`) bleiben in jedem Fall unveraendert: ihre Rueckrufe haengen
an KEINEM Effekt, sondern werden als Ereignisbehandlung weitergereicht — eine wechselnde
Identitaet kostet dort nichts. Nenne diesen strukturellen Grund in der SUMMARY.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run calendar-widget translations-identity 2>&1 | tail -10</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];console.log('gesamt',d.length,'| a11y',d.filter(x=>x.category.includes('a11y')).length,'| exhaustive',d.filter(x=>x.category==='lint/correctness/useExhaustiveDependencies').length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8 && pnpm type-check 2>&1 | tail -4 && pnpm lint --force 2>&1 | tail -4</automated>
<human-check>
Browser-Messung durch den Orchestrator (der Ausfuehrende hat keinen Browser). Instrument
ist das Netzwerkprotokoll des Browsers ueber Playwright, **niemals ein `fetch` aus der
Seite heraus** (siehe Merkposten "Browser-Pruefung: fetch-Falle"). Vorbereitung: Stack aus
dem aktuellen Stand bauen, anmelden, Kalender-Kachel aufs Dashboard legen und ihre
Vorschau auf **90 Tage** stellen — bei der Voreinstellung 30 Tage tritt das doppelte
Ladefenster rechnerisch gar nicht auf, die Messung waere dann nichtssagend.
Messung: Netzwerkprotokoll leeren, dann dreimal "Weiter" druecken.
Erwartung nachher:
- `calendar/sources`: genau **1** (vorher 3).
- `calendar/events`: **weniger als 3**, und unter den abgesetzten Anfragen tragen keine
zwei dasselbe `from`/`to`-Paar in der Abfragezeichenkette (vorher 3, darunter ein
identisches Paar fuer zwei benachbarte Monate).
- Gegenprobe gegen Einfrieren: rund fuenf Minuten ruhen lassen; danach muss je ein
weiterer `calendar/events`- und `calendar/sources`-Abruf erscheinen.
Datenbank und Modul-Aktivierung so hinterlassen, wie sie vorgefunden wurden; Stack danach
stoppen.
</human-check>
</verify>
<done>
`calendar-widget`-Tests belegen durch Zaehlung: Aufbau je 1 Abruf, Monatswechsel mit
abweichendem Fenster +1 Termine/+0 Quellen, Monatswechsel mit identischem Fenster +0/+0,
erzwungener Lauf +1/+1. `translations-identity.test.tsx` ist gruen und haelt die
Identitaetsfrage fuer `t` fest. `gesamt 399`, `a11y 1`, `exhaustive 0`, `errors 0`.
`apps/web` mindestens 69 Dateien / 484 Tests, `pnpm type-check` 4/4, `pnpm lint --force`
5/5. Fuer jede der vier `t`-Stellen steht in der SUMMARY, ob sie geaendert wurde und
warum beziehungsweise warum nicht.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Mensch -> Oberflaeche | Bedienung per Tastatur bzw. Vorlesehilfe statt per Maus — die eigentliche Grenze dieses Vorgangs |
| Notizinhalt -> Markdown-Darstellung | Vom Nutzer eingegebener Text wird gerendert; `rehypeSanitize` ist die Schranke |
| Web -> API -> Exchange | Jeder Termin-Abruf der Kachel erreicht ueber die eigene API einen fremden Exchange-Server |
| Katalogtext -> Dateisystem der Gegenstelle | Ein uebersetzter Name landet als Dateiname auf einer Windows-Freigabe |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-JT4-01 | Denial of Service | alle 21 klickgebundenen Fundstellen | medium | mitigate | Bedienung, die es nur mit der Maus gibt, schliesst Tastatur- und Vorlesehilfe-Nutzung aus. Jede echte Bedienstelle wird ein `<button>`; belegt durch Komponententests, die eine Tastaturbetaetigung ausloesen, nicht durch Auszeichnungs-Behauptungen. |
| T-JT4-02 | Denial of Service | `calendar-widget.tsx` -> API -> Exchange | medium | mitigate | Der Sperrgriff darf nur den Monatswechsel sperren, nie den 5-Minuten-Auffrischer, sonst friert die Anzeige ein (Verfuegbarkeitsschaden statt Ersparnis). Vier zaehlende Testfaelle decken beide Richtungen ab; die Tagesgrenzen-Rundung von `computeFetchWindow` bleibt unangetastet, damit der Backend-Cache-Schluessel stabil bleibt. |
| T-JT4-03 | Tampering | `note-widget.tsx` -> `previewOptions` -> `rehypeSanitize` | high | mitigate | Der Umbau des Kaestchen-Handlers fasst das Optionsobjekt neu; faellt dabei `rehypePlugins: [[rehypeSanitize]]` heraus, kehrt die XSS-Luecke T-IEX-01 zurueck. Der Eintrag muss woertlich erhalten bleiben; ein Testfall muss belegen, dass eingebettete Auszeichnung im Vorschaumodus weiterhin entschaerft wird. |
| T-JT4-04 | Tampering | `calendar-settings-panel.tsx`, Loeschdialog | high | mitigate | Der Hintergrund des Loeschdialogs wird zu einer echten, fokussierbaren Schaltflaeche und rueckt damit in die Tab-Reihenfolge. Sie MUSS abbrechen und darf unter keinen Umstaenden loeschen; ihre Beschriftung benennt das Abbrechen. Ein Testfall belegt, dass nach diesem Weg die Quelle noch existiert. |
| T-JT4-05 | Tampering | `zip-filename.ts` -> Windows-Freigabe | low | mitigate | Ein uebersetzter Dateiname kann Zeichen tragen, die Windows verbietet. Eine fuer sich getestete Schutzfunktion schneidet jeden Namen auf das Zulaessige zurueck und faellt notfalls auf einen sicheren Ersatznamen zurueck — die Sicherheit haengt damit nicht an der Wortwahl einer kuenftigen Uebersetzung. |
| T-JT4-06 | Information Disclosure | `widget-catalog-modal.tsx` | low | accept | Die Hintergrundflaeche verliert ihr `aria-hidden`, weil ein fokussierbares Element nicht vor der Vorlesehilfe verborgen sein darf. Sie erhaelt dadurch eine angesagte Beschriftung — gewollt, kein Informationsabfluss: der Dialoginhalt war ohnehin sichtbar. |
| T-JT4-SC | Tampering | npm/pip/cargo-Installationen | n/a | accept | Dieser Vorgang installiert kein Paket und hebt keine Version an (D-06). Es gibt keine Installationsaufgabe, daher greift das Paket-Echtheitstor nicht; `pnpm-lock.yaml` und alle `package.json` duerfen im Diff nicht vorkommen. |
</threat_model>
<verification>
Abschliessend, nach allen drei Aufgaben:
```bash
cd /home/vicolab/projects/tessera-ctl
# 1) Befundstand nach Regel
npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>x.category.includes('a11y'));a.forEach(x=>console.log(' a11y',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('gesamt',d.length,'| a11y',a.length,'| errors',d.filter(x=>x.severity==='error').length);})"
# erwartet: gesamt 399 | a11y 1 | errors 0
# die eine a11y-Zeile nennt calculator-widget.tsx
# 2) Keine Unterdrueckung als Abkuerzung eingeschleust
git diff --unified=0 $(git rev-parse HEAD) -- . | grep -c '^+.*biome-ignore' || true
# erwartet: 0 (dieser Vorgang fuegt keine einzige neue Unterdrueckung hinzu)
# 3) Kataloge im Gleichstand
node -e "const f=o=>Object.entries(o).flatMap(([k,v])=>v&&typeof v==='object'?f(v).map(x=>k+'.'+x):[k]);const de=f(require('./apps/web/src/messages/de.json')),en=f(require('./apps/web/src/messages/en.json'));const A=new Set(de),B=new Set(en);console.log('de',de.length,'en',en.length,'nurDe',de.filter(k=>!B.has(k)).join(',')||'-','nurEn',en.filter(k=>!A.has(k)).join(',')||'-');"
# 4) Tore und Testbestand
pnpm -C apps/web exec vitest run 2>&1 | tail -6 # >= 69 Dateien, >= 484 Tests
pnpm -C apps/api exec vitest run 2>&1 | tail -6 # >= 72 Dateien, >= 1143 Tests
pnpm type-check 2>&1 | tail -4 # 4/4
pnpm lint --force 2>&1 | tail -4 # 5/5, 0 error
# 5) Kein Paket, keine Version, kein Fremdumbruch
git diff --stat $(git rev-parse HEAD) -- pnpm-lock.yaml '**/package.json'
# erwartet: leer
```
Die Zaehlungen stammen ausschliesslich aus dem Feld `category` der Biome-JSON-Ausgabe,
niemals aus einem Textgriff in den Quelltext — ein Kommentar, der den Namen einer Regel
nennt, wuerde eine Textzaehlung sonst selbst verfaelschen.
**Schritt des Orchestrators (nicht des Ausfuehrenden):** die Browser-Zaehlung aus dem
`human-check` in Aufgabe 3. Der Ausfuehrende hat keinen Browser und darf dieses Ergebnis
nicht behaupten.
</verification>
<success_criteria>
- Biome: 399 Befunde gesamt, 0 der Stufe error, genau 1 a11y-Befund
(`calculator-widget.tsx`, begruendet stehengelassen), `useSemanticElements` unveraendert
bei 0, `noUselessSwitchCase` bei 0.
- Keine einzige neue `biome-ignore`-Zeile im gesamten Diff dieses Vorgangs.
- 29 der 30 a11y-Fundstellen behoben, jede mit protokolliertem Weg; fuer die eine
stehengelassene ist begruendet, warum sie bleibt und warum die Umgehung per
`addEventListener` ausdruecklich nicht gewaehlt wurde.
- Tastaturbedienung ist durch Tests mit echten Tastaturereignissen belegt, nicht durch
Auszeichnungsbehauptungen.
- Kalender-Kachel: Quellenliste 1 statt 3 pro drei Monatswechseln, kein Termin-Abruf mit
wiederholtem `from`/`to`-Paar, 5-Minuten-Auffrischer nachweislich unberuehrt.
- ZIP-Name uebersetzt UND durch eine getestete Schutzfunktion auf Windows-Freigaben
gueltig.
- Die vier `t`-Abhaengigkeiten sind durch Messung entschieden, nicht durch Gewohnheit; die
zugrunde liegende Annahme aus gof ist ausdruecklich korrigiert oder bestaetigt.
- `apps/web` >= 69 Dateien / >= 484 Tests, `apps/api` >= 72 / >= 1143, `pnpm type-check`
4/4, `pnpm lint --force` 5/5.
- Keine neue Abhaengigkeit, keine Versionsanhebung, kein repo-weiter Umbruch (D-06).
</success_criteria>
<commit_hygiene>
Ein Commit je Teilumbau, nicht ein Sammelcommit je Aufgabe — das ist der Grund, warum die
Umbauten oben einzeln nummeriert sind.
**Fallstrick, der heute viermal zugeschlagen hat:** Das Write-Werkzeug wandelt Folgen der
Form `\uXXXX` im Inhalt still in das tatsaechliche Zeichen um. So sind rohe Steuerzeichen
in einen Plan, in Commit-Nachrichten und in eine SUMMARY geraten, woraufhin `git commit`
die Annahme verweigert hat. Erzeuge solche Folgen ueber `python3` mit `chr(92)` und pruefe
die Rohbytes danach nach, bevor du committest.
</commit_hygiene>
<output>
Erstelle `.planning/quick/260921-jt4-barrierefreiheit-mit-bedienentscheidunge/260921-jt4-SUMMARY.md`,
wenn alle drei Aufgaben abgeschlossen sind. Die SUMMARY muss enthalten:
1. Eine Tabelle aller 30 a11y-Fundstellen mit gewaehltem Weg (echter Button /
role+Tastatur / Fokus-auf-Oeffnen / Rolle korrigiert / stehengelassen) und je einer
Zeile Begruendung, wo der gerade Weg nicht ging.
2. Je einen Abschnitt zu den vier Restposten mit dem Ergebnis — einschliesslich der
ausdruecklichen Aussage, ob der alte Einwand gegen den uebersetzten ZIP-Namen noch
traegt, und ob die Annahme "`t` ist bei jedem Render frisch" bestaetigt oder korrigiert
wurde.
3. Die ehrliche Einordnung der vier `aria-hidden`-Ergaenzungen an den `<img>`-Elementen:
richtige Auszeichnung, aber kein Gewinn fuer einen Menschen.
4. Die Vorher/Nachher-Zahlen aus der Biome-JSON-Ausgabe, nach Regel aufgeschluesselt.
</output>