Files
schalli 7557c9aafe 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
2026-09-21 14:35:29 +02:00

51 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
quick-260921-jt4 01 execute 1
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
true
D-01
D-02
D-03
D-04
D-05
D-06
D-07
tokens raw_tokens tasks confidence
150000 150000 3 low
truths artifacts key_links
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.
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
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.
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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

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

Aufgabe 1: Aus klickbaren Bereichen echte Schaltflaechen machen (16 Befunde, 5 Dateien) 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 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. - 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. 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. 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);})" cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8 cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | tail -4 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.

Aufgabe 2: Rollen, Beschriftungen, Autofokus — und zwei Restposten (14 Befunde) 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 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. 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);})" 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(',')||'-');" cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run zip-filename 2>&1 | tail -6 cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run 2>&1 | tail -6 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.

Aufgabe 3: Verschwendete Abrufe — Kalender-Ladefenster und die vier t-Abhaengigkeiten 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 `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. - 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. **(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. cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run calendar-widget translations-identity 2>&1 | tail -10 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);})" 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 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. 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.

<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>
Abschliessend, nach allen drei Aufgaben:
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.

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

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.