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
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 |
|
true |
|
|
|
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>
<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:
- Vier der elf
noNoninteractiveElementInteractionssind ueberhaupt keine Klicks, sondernonError-Handler an<img>-Elementen (Ersatzweg fuer nicht ladende Profilbilder und Favoriten-Symbole):header.tsx:156,account-settings-form.tsx:161,favorites-widget.tsx:462und:474. Ein Ladefehler ist keine Bedienung; hier gibt es nichts in eine Schaltflaeche zu verwandeln. - Alle fuenf
useAriaPropsSupportedByRolehaben dieselbe Gestalt: einaria-labelsitzt auf einem schlichten<div>bzw.<span>ohne Rolle. Solche Elemente haben die Rollegeneric, 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>
(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.
(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.
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 ("
tgehoert 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 einfetchaus 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 dasselbefrom/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- undcalendar/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> |
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),useSemanticElementsunveraendert bei 0,noUselessSwitchCasebei 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
addEventListenerausdruecklich 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-check4/4,pnpm lint --force5/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>
- 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.
- 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 "
tist bei jedem Render frisch" bestaetigt oder korrigiert wurde. - Die ehrliche Einordnung der vier
aria-hidden-Ergaenzungen an den<img>-Elementen: richtige Auszeichnung, aber kein Gewinn fuer einen Menschen. - Die Vorher/Nachher-Zahlen aus der Biome-JSON-Ausgabe, nach Regel aufgeschluesselt.