From 7c992661e930b026405d659f938bdae861877a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:37:13 +0200 Subject: [PATCH 1/4] feat(ui): add portable appbar renderer and bounded status contract --- .gitattributes | 6 + docs/TEAMPLAN.md | 12 +- docs/contracts/appbar-v1.md | 167 ++++++++++++++++++ .../e05-portable-appbar-20261020/SHA256SUMS | 10 ++ .../e05-portable-appbar-20261020/STATUS.md | 79 +++++++++ .../e05-portable-appbar-20261020/checks.json | 19 ++ .../ui/static/networkpunkt-appbar-SHA256SUMS | 9 + .../ui/static/networkpunkt-appbar-assets.json | 29 +++ .../ui/static/networkpunkt-appbar-bundle.json | 17 ++ .../ui/static/networkpunkt-appbar-catalog.js | 11 ++ .../ui/static/networkpunkt-appbar-state.js | 82 +++++++++ .../ui/static/networkpunkt-appbar.css | 19 ++ .../ui/static/networkpunkt-appbar.js | 134 ++++++++++++++ .../ui/static/np-appbar-4allpass-dark-48.png | Bin 0 -> 3548 bytes .../static/np-appbar-threaddesk-dark-48.png | Bin 0 -> 3334 bytes tests/appbar_state.test.mjs | 63 +++++++ tests/fixtures/appbar.html | 21 +++ tests/test_appbar.py | 167 ++++++++++++++++++ tests/test_ui_assets.py | 1 + 19 files changed, 845 insertions(+), 1 deletion(-) create mode 100644 docs/contracts/appbar-v1.md create mode 100644 docs/evidence/e05-portable-appbar-20261020/SHA256SUMS create mode 100644 docs/evidence/e05-portable-appbar-20261020/STATUS.md create mode 100644 docs/evidence/e05-portable-appbar-20261020/checks.json create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar-assets.json create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar-bundle.json create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar-catalog.js create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar-state.js create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar.css create mode 100644 src/threaddesk/ui/static/networkpunkt-appbar.js create mode 100644 src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png create mode 100644 src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png create mode 100644 tests/appbar_state.test.mjs create mode 100644 tests/fixtures/appbar.html create mode 100644 tests/test_appbar.py diff --git a/.gitattributes b/.gitattributes index 3c17acd..b1585fe 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,9 @@ src/threaddesk/ui/static/htmx.min.js -text src/threaddesk/ui/static/alpine.min.js -text src/threaddesk/ui/static/ui-vendor.json text eol=lf src/threaddesk/ui/static/UI-VENDOR-LICENSES.txt text eol=lf +# Portable appbar copies pin exact source/asset bytes across host repositories. +src/threaddesk/ui/static/networkpunkt-appbar*.js text eol=lf +src/threaddesk/ui/static/networkpunkt-appbar*.css text eol=lf +src/threaddesk/ui/static/networkpunkt-appbar*.json text eol=lf +src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS text eol=lf +src/threaddesk/ui/static/np-appbar-*.png -text diff --git a/docs/TEAMPLAN.md b/docs/TEAMPLAN.md index b3fb825..a4a3973 100644 --- a/docs/TEAMPLAN.md +++ b/docs/TEAMPLAN.md @@ -111,7 +111,7 @@ an gemeinsamen Dateien werden vorab eindeutig einem Bearbeiter zugeteilt. | E02.3–4 | AP-01/AP-02/F4 | Quellumfänge abgeschlossen: AP01 PR #229 (`ab1aba3`) als `7a9416d`, AP02 PR #230 als `262917f` auf main; jeweils grüne PR-CI. Grok `ship` beider Umfänge, Ergebnis `9784c6e6-3258-42ad-8295-a30ea114ee27`, eigener Body-Lauf11/11. SQLite156+1 expliziter Skip; echter Postgres-CI-Lauf157 bestanden. Kein neues 4AllPass-Paket daraus abgeleitet. | | E03 | P01–P05, R02–R07/R12, AP-04 | Offen: Kryptografie, Snapshot, Konto/Sitzung, Geräte, Recovery und belastbarer Identitätsvertrag. Vorhandene Tests ersetzen keine Bereichsabnahme. | | E04 | P06–P08/P11–P12, R08–R11/R15–R17, AP-03 | Offen: Browser-/Desktop-/native Nutzerwege und Paketnachweise am tatsächlichen Kandidaten. | -| E05 | UI-01, zugehöriger TD-08-Umfang | Offen: gemeinsame kompakte App-Leiste, vorhandene Icons, bestätigte frische Verbindungen; nur 4AllPass bei bestätigtem Unlock sehr dezenter Puls. | +| E05 | UI-01, zugehöriger TD-08-Umfang | Reine portable DOM/CSS-/Kataloggrundlage1.0.0 in Arbeit, synthetische Browser-/Vertragsprüfung; keine Host-Einbindung oder echte Peerabnahme. KiraBridge zuerst, danach TD/AP; KB-Icon und authentisierte frische Status-/Openadapter weiter offen. Nutzerfeedback zur Icongröße noch ohne Oberflächenzuordnung. | | E06 | TD-03–TD-06/TD-08, AP-04 | Offen: Projektrollen, atomarer Transfer, Team-Sync, projekt-/thread-/revisions-/laufgebundene Agentenaufträge. | | E07 | AP-05–AP-07, P09/R13–R14 | Offen: begrenzte Freigaben, tatsächliche Hostgrenze und genau eine freigegebene reale Anbieteroperation; kein synthetischer Test als Produktionsbeweis. | | E08 | TD-07 | E08.1 in Prüfung: optionaler lokaler Titel-/Beschreibung-/Notizschnitt mit FTS5, begrenztem JSON-/No-FTS-Ersatz und `td search` auf main `88a5b4d`; default aus, expliziter Aufbau, ehrlicher Teilstand und frische lokale Policy. [Vertrag und Grenzen](LOCAL-SEARCH.md), [Quell-/Test-/Messnachweis](evidence/e08-local-search-20261020/STATUS.md). Echte 10k-/100k-Textmessung und unabhängige Abnahme werden getrennt nachgeführt. Projekt-/Teamrechte bleiben E06; optionaler Bridge-Dateiindex und Produktfreigabe weiter offen. | @@ -257,3 +257,13 @@ Nächste unabhängige Arbeit: E03-IndexedDB-Commit/Abort und H1-Kontoregistrieru in getrennten bestehenden 4AllPass-Worktrees; Grok spezifiziert AP04 und holt vorhandene native Reviewresultate ab. E08/TD07 wird als lokales optionales Suchmodul vorbereitet. E03–E09 sind damit nicht insgesamt abgeschlossen. + +### E05 / UI-01: reine Renderer-Grundlage (20.10.2026) + +Versioniertes NP-Modul, feste Katalog-/Adaptermetadaten, TTL/Generation/Peer- +zuordnung und getrennt erlaubter AP4-Nutzer-/Tresorstatus. Kein Ring aus bloßem +Start/Transport/PID und kein Bridge-Aktivitätspuls. Vertrag/Übernahme: +`docs/contracts/appbar-v1.md`. Bestehende freigegebene TD/AP4-Pixel bytegleich, +kein KB-Ersatzlogo. Noch keine TD/AP/KB-Template-/Appintegration; KB-Mount +bleibt gemäß Gesamtplan zuerst. Unabhängiger Source-/Browserreview und echte +Öffnungs-/Status-/Hostabnahmen folgen getrennt. diff --git a/docs/contracts/appbar-v1.md b/docs/contracts/appbar-v1.md new file mode 100644 index 0000000..5a4ec3c --- /dev/null +++ b/docs/contracts/appbar-v1.md @@ -0,0 +1,167 @@ +# UI-01 · portabler Renderer 1.0.0 + +Dies ist die reine gemeinsame Grundlage des bestehenden E05-Auftrags, keine +fertige Verbindung oder App-Einbindung. Quelle ist vorläufig ThreadDesk wie beim +NP-Kern (§19 GOLDENRULES). **Zuerst folgt KiraBridge; ThreadDesk/4AllPass sind +noch nicht eingebunden.** Kein bestehender Header, Markenname oder Markenfarbwert +wird durch diese Dateien verändert. Es gibt keinen verpflichtenden ThreadDesk-Hub. + +## Versionierte lokale Übernahme + +`src/threaddesk/ui/static/networkpunkt-appbar-bundle.json` nennt den kleinen +Dateisatz; `networkpunkt-appbar-SHA256SUMS` enthält vollständige Fingerprints. +Die benötigten Dateien einer konkreten Git-Version bewusst bytegleich lokal +kopieren, Hashes prüfen und den Quellpin im jeweiligen Host festhalten. Keine +CDN, automatische Repo-Aktualisierung, neue Dependency, Framework- oder +Pluginplattform. Der bestehende NP-CSS-Kern0.1 ist die Gestaltungsabhängigkeit; +seine vorhandenen Marken-/Schriftregeln werden nicht umgeschrieben. + +Der Katalog1.0.0 enthält feste App-IDs, DE/EN-Namen, vorhandene Bilddateien, +eigene Toolfarbe und den Weg `host-adapter`. Die Installation liefert den +zugehörigen erlaubten Status-/Öffnungsadapter. Statuswerte können weder Icon, +Farbe, Reihenfolge noch ein Startziel bestimmen. Nur bekannte Suite-IDs sind +zugelassen; weitere Fachmodule sind keine zusätzlichen App-Icons. + +- ThreadDesk: freigegebener48px-Dark-Export aus assets/design, bytegleich. + Ringfarbe #b99cff aus bestehendem brand/README. +- 4AllPass: aktueller freigegebener AP4-Dark-Export aus brand/ap4, bytegleich; + keine historische Elster. Kein belegter aktueller Ring-Brandtoken in + brand/README.md, brand/ap4/containers.json oder frontend/src/tokens.css; + Pixelheuristiken werden nicht als Markenentscheidung übernommen. Der + Standardkatalog hält die Farbe deshalb null und die Statusanzeige neutral. +- KiraBridge: weiterhin kein zugeordnetes freigegebenes Bild. Kein Buchstabe, + Fremdicon oder neu gezeichnetes Ersatzlogo. Deshalb noch kein Katalogeintrag. +- TollGate: Asset noch nicht diesem Modul zugeordnet; kein behauptetes Fehlen + im Gesamtbestand. Später fester Eintrag plus erlaubter Adapter. + +## Hostslot und Darstellung + +Der Host reserviert später denselben Headerplatz nach seiner Produktmarke und +vor den eigenen Aktionen. Die Grundlage montiert **keinen** Host selbst. +Vorbereitete Fixturemaße:24px-Bild aus48px-Datei,32px-Ring,40px-NP-Bedienfläche, +4px-Abstand. Das ist ein technischer Prüfstand, keine sichtbare Nutzerabnahme; +Daniels aktuelles Feedback zur Icongröße ist noch einer Oberfläche zuzuordnen. +Eine daraus folgende Maßänderung wird zentral vorgenommen, nicht pro Host +anders erfunden. Keine automatische Touch-/Mobilgestaltung entgegen der +aktuellen NP-Präzisierung. Auch im schmalen Fixtureausschnitt bleibt der Zugang +erreichbar; dies ist keine Abnahme tatsächlicher Hostheader. + +Die Katalogreihenfolge KiraBridge/ThreadDesk/4AllPass/TollGate ist unabhängig vom +Status; nur verfügbare konfigurierte Einträge erscheinen. Nativer Button mit +Textname/Status/Öffnungsaktion als aria-label, Tab/ShiftTab/Enter/Space, sichtbarem +Fokus und Tooltip bei Fokus/Hover, mit Escape schließbar. Kein Zusatzschloss. +Die Live-Region meldet semantische Änderungen, nicht jeden lokalen250ms-Tick. +Nur der AP4-Ring darf bei erlaubtem aktuellem Unlock langsam pulsieren: +12s, Deckkraft1.00…0.90, Glow10% Toolfarbe. Bild/Hitarea werden nicht animiert. +Reduced motion schaltet die Ringanimation aus; der Text bleibt eindeutig. + +## Öffentliche API + +ES-Module, ohne React/DOM-Bibliothek: + +```js +import { mountAppBar } from "./networkpunkt-appbar.js"; +const bar = mountAppBar(reservedElement, { + catalog, locale: "de", peers: trustedExpectedBindings, + colors: trustedNamedToolColors, // {appId:{value:"#RRGGBB",sourceRef:"belegter-Token"}} + onOpen: allowedHostOpenAdapter +}); +const ticket = bar.begin("4allpass"); +// Erst nach Prüfung durch den vorhandenen authentisierten Hostadapter: +bar.receive("4allpass", ticket, verifiedMetadata); +// Alternativ bar.fail(appId, ticket, "error" oder "unknown"). +// bar.reset(appId, newTrustedExpectedBinding) invalidiert alte Instanz/Generation. +// bar.setLocale("en"); bar.destroy(); +``` + +Kein Netzwerk, Storage, PID-/Portscan, allgemeiner Shellaufruf, Fensteröffnen +oder fachlicher Auftrag im Renderer. `clock`/`wallClock` sind injizierbare lokale +Fixtureuhren; normale Hosts verwenden performance.now und Date.now. + +## Statusadaptervertrag + +Erwartete Bindung aus vertrauenswürdiger lokaler Installation: + +``` +{appId, connectionRef, peerRef, peerEpoch, + unlockScope?: {userRef, vaultRef}} +``` + +`peerRef` muss das erwartete Gegenüber des vorhandenen authentisierten Kanals +bezeichnen, `peerEpoch` dessen bestätigte Instanz. Öffentliche IDs/Fingerprints +allein authentisieren nichts. Insbesondere darf der Adapter weder PID, +Transport-live, erfolgreichen Start noch health200 in `confirmed` übersetzen. +Wo der Kanal fehlt, bleibt der Status unknown. Ein frei gepostetes +`authenticated:true` ist kein Beweis und kein Feld dieses Vertrags. + +Verifizierte, geheimnisfreie Metadaten aus diesem Adapter: + +``` +{v:1, appId, connectionRef, peerRef, peerEpoch, seq, + state:"confirmed"|"connecting"|"disconnected"|"unknown"|"error", + ttlMs:1..15000, + vault?: {userRef,vaultRef,allowed:true, + state:"locked"|"unlocked"|"unknown",ttlMs:1..connectionTTL}} +``` + +Der Renderer prüft Form/Zuordnung/Frische, **nicht** Authentisierung oder +Gerätebesitz. Der Hostadapter muss die echte Antwort an die angefragte +Verbindung und aktuelle erlaubte Nutzer-/Tresorzuordnung binden. Ein Boolean +ersetzt diese Prüfung nicht. Keine neuen Schlüssel, Secrets, Konten oder +Berechtigungen für die Darstellung. Keine Tresorobjekte, Inhalte, Tokens, +Passwörter, Grants, Budgets oder Aktivitätsdaten in diesem DTO. + +Eine fehlende Toolfarbe muss aus dokumentierter Gestaltung kommen, benannt +durch den vertrauenswürdigen lokalen Hostadapter (`colors` mit sourceRef). +Statusantworten dürfen sie nicht liefern. Ohne diese Konfiguration bleibt die +Anzeige neutral; die Browserfixture verwendet ausdrücklich eine synthetische +Testfarbe, keine freigegebene AP4-Marke. + +Pro Peer höchstens ein laufender lokaler Check; `begin` liefert dafür ein +unveränderliches lokales Ticket, kein Auth-/Netzprotokoll. Deadline2s; TTL maximal +15s und konservativ ab lokalem Checkbeginn, nicht ab einer verspäteten Antwort. +Der Host begrenzt seine tatsächlichen Abfragen auf vorhandenen4s- oder5s-Takt; +der reine Renderer fragt nichts ab. Gleiche/alte Sequenzen, falsche App/Verbindung/ +Peer/Instanz, fremde Tickets, Zeitüberschreitung und ungültige Metadaten werden +verworfen. Reset/Verstecken/pagehide invalidieren die Generation. Lokale monotone +und Wanduhr werden gemeinsam konservativ bewertet; Schlaf oder Uhr-Rücksprung +darf kein altes Grün verlängern. Es wird kein Status aus Storage restauriert. + +`confirmed` mit gültiger Verbindung erzeugt den ruhigen eigenen Ring. Nur AP4 +mit explizitem allowed:true und exakt erwartetem userRef/vaultRef darf zusätzlich +unlocked anzeigen. Eigene kürzere Vault-TTL endet ohne falsches locked; der +Verbindungsring darf dabei ruhig weiterleuchten. Locked ist ausdrücklich +gesperrt, unknown ausdrücklich unbekannt. Andere Apps erhalten niemals einen +Aktivitäts-/Unlockpuls. Ein fehlgeschlagener Check neutralisiert sofort. + +## Fester Öffnungsadapter + +`onOpen({appId,actionId,signal})` erhält nur feste Katalog-ID, lokalen Vorgangsbezug +und Abbruchsignal. Kein freies Ziel/Argument aus Projekt-/Thread-/Teamdaten. +Der Host mappt diese ID auf bereits erlaubtes festes Ziel und schützt Fokus/ +Start sowie begrenzte Idempotenz selbst. Antwort: +`{actionId,outcome:"focused"|"opened"|"failed"|"unknown"}`. + +Parallel-/Doppelklicks werden zusammengeführt (mindestens600ms). Deadline2s; +unklarer Ausgang bleibt gegen weiteren Start gesperrt und hat keinen +automatischen Retry. Erst nach eigener Zielprüfung darf der Host +`clearOpen(appId)` aufrufen. Späte Ergebnisse ersetzen keinen neueren Vorgang. +Destroy signalisiert Abbruch; ein unkooperativer Adapter kann nicht vom +Renderer zwangsweise beendet werden, verändert aber dessen DOM nicht mehr. +Fehlendes Ziel/Fehler führen zu festen DE/EN-Handlungen, nie rohen Responses. +Ein Öffnungserfolg bestätigt **niemals** eine Verbindung oder Tresorfreigabe. + +## Geprüfter und weiterhin offener Umfang + +`python -m pytest -q tests/test_appbar.py tests/test_ui_assets.py` führt die +32 reinen Node-Zustandsprüfungen sowie echtes Chromium-DOM/CSS aus. Die +Browserfixture liefert nur freigegebene lokale Assets über abgefangene +Fixture-Requests; keine echte App, Verbindung, Konten, Datenbank oder Startziele. +DE/EN/Keyboard, Scope/TTL/Fehler/Wiederverbindung, Ring-/Bildgeometrie, reduzierte +Bewegung, Timerabbau und keine doppelten synthetischen Starts werden gemessen. +Das ist Renderer-/Accessibility-Verhalten, keine vollständige Screenreader-, +Nutzer-, Native- oder Verbundabnahme. Keine Vorlage wird produktiv importiert. + +Offen bleiben KiraBridge-Asset, belegter AP4-Ringfarbwert, erste KB-Einbindung, abgesicherte echte +Öffnungs-/Statusadapter, danach TD/AP-Mounts und jeweilige native/Host-Abnahme. +Ein neuer Adapter erweitert nicht Tresor-, Projekt- oder Auftragsrechte. diff --git a/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS new file mode 100644 index 0000000..06f8cfe --- /dev/null +++ b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS @@ -0,0 +1,10 @@ +98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e src/threaddesk/ui/static/networkpunkt.css +452497b9e08ce88937b2e9a598662da54d232b868e4c9d0eeaab0a96320bd693 src/threaddesk/ui/static/networkpunkt-appbar.js +4c4ff0170d37a74167d52487018abb6499cefbfc4b322ebba62694d7468d4156 src/threaddesk/ui/static/networkpunkt-appbar-state.js +2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 src/threaddesk/ui/static/networkpunkt-appbar-catalog.js +6699dc6f1dcbea1c8ae063d6a39e9fea4345250d04ab89f98983e76d14b67700 src/threaddesk/ui/static/networkpunkt-appbar.css +e36c6bd46dbe6744efec07ce2ab73e1996208d99fdf63ed21879da2a4d2cc6ad src/threaddesk/ui/static/networkpunkt-appbar-assets.json +6add7033bb8ec5f0efa0c09ddf136c892352f4536b14b774f1ae8dcd57ee9ba2 src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png +1074241a92275ae4b4a0d3ac00482ab9a253bcd5f60b569d96f84e95ae8ff336 src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png +13862406a14a212fd6e0646616be04604e77b9bb3a6225e8d18b470dae44225b src/threaddesk/ui/static/networkpunkt-appbar-bundle.json +59758e4259997b4ed953d7e61aede8e057f676a89ca44ddc8e01b0b793d37435 src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS diff --git a/docs/evidence/e05-portable-appbar-20261020/STATUS.md b/docs/evidence/e05-portable-appbar-20261020/STATUS.md new file mode 100644 index 0000000..0aa1748 --- /dev/null +++ b/docs/evidence/e05-portable-appbar-20261020/STATUS.md @@ -0,0 +1,79 @@ +# E05 / UI-01 — reine portable Grundlage1.0.0 + +20.10.2026, Basis ThreadDesk-main b63fbd2. Autor: plan_contract_matrix; +unabhängiger Source-/Browserreview und PR-CI folgen. Vollplan§13 bleibt +maßgeblich: KiraBridge zuerst einbinden, dann TD/AP. Keine Templates oder +produktiven Imports/Mounts, keine Liveinstallation. Die separate belegte +Wordmarkkorrektur ist PR81 und gehört nicht in diesen Renderer-Diff. + +## Tatsächlicher Umfang + +Drei kleine native ES-Module/CSS plus versionierter fester Katalog/Bytebundle; +kein React/Framework/Pluginbus und kein Netz-/Storage-/Hostöffnen im Renderer. +Der bestehende NP-Kern bleibt unverändert. Eigene Header/Wordmarkfarben werden +nicht ersetzt. Die Font-/Formenwächter erhalten genau eine begründete Ausnahme +für den ausdrücklich verlangten runden Statusring, nicht für Bedienflächen. + +Nur passende, frische Metadaten vom bereits authentisierenden Hostadapter +dürfen bestätigen: erwartete App/Verbindung/Peer/Instanz, aktuelle lokale +Generation, steigende Sequenz,2s-Checkdeadline und maximal15s ab Checkbeginn. +AP4 zusätzlich exakt erlaubter Nutzer/Tresor und eigene Vault-TTL. Renderer +prüft Zuordnung/Form/Frische, ist selbst KEIN Auth-/Gerätebesitznachweis. +Offene/fehlende Adapter bleiben unknown; Start/PID/Transport reichen nicht. +Nur AP4 darf pulsieren, nie Bridgearbeit. Ablauf stoppt den Puls ohne falsches +locked. Öffnen ist ein separater fester Hostadaptervertrag mit Dedupe, Deadline, +Vorgangszuordnung und ohne automatischen Retry; es setzt nie connected. + +## Assets/Farben und Nutzerfeedback + +Vorhandene freigegebene TD/AP4-48px-PNGs unverändert kopiert; Herkunft/Gitpins +in networkpunkt-appbar-assets.json, vollständige Bundlefingerprints separat. +KiraBridge-Bild weiterhin nicht zugeordnet, kein Ersatzlogo. TollGate nur noch +nicht zugeordnet, kein pauschaler Fehlbestandsbefund. TD-Ringfarbe aus +brand/README #b99cff. Keine dokumentierte aktuelle AP4-Ringfarbe in AP4- +brand/README, containers.json oder tokens.css nachweisbar; Pixelheuristik +wurde verworfen. Standardkatalog AP4farbe=null hält neutral. Erst ein +vertrauenswürdig konfigurierter Host darf eine dokumentierte benannte +Toolfarbe liefern; synthetische Browserfarbe ist KEINE Markenfreigabe. + +Maße24/32/40px mit4px Abstand sind ausschließlich der vorläufige Fixturestand. +Daniels aktuelle Icongrößenmeldung ist noch keiner Oberfläche zugeordnet, +daher weder Größenänderung auf Verdacht noch Nutzerabnahme behauptet. +NP-Regeln/Einbindungsvertrag: docs/contracts/appbar-v1.md. + +## Wirklich ausgeführte Prüfungen + +- 32 reine Node-Zustandsfälle grün: unknown/connecting/disconnected/error, + falsche Bindungen/Typen/TTL/Sequenzen/Generationen/Instanzen, Timeout/ + Uhr-Rücksprung/Invalidate, AP4 allowed Nutzer/Tresor und separate Vault-TTL, + Sperren/unknown, Ausfall/Wiederverbindung; KB/TD/TollGate nie Puls. +- 9 echte Chromium-Rendererfixturefälle grün: DE/EN, tatsächliche Tastatur/ + Tooltip-Escape, Inflight- und schneller Doppelklick, Open setzt keinen Ring, + unklarer Ausgang mit später Antwort, periodischer TTL-Tick ohne manuelle + Aktualisierung, Remount genau ein Timer/Destroy null Timer, keine Wiederbelebung, + fehlender Farbtoken neutral, feste Bounds auch bei320px und reduced motion. + Nur Ring-Pseudoelement animiert12s/1.0..0.9; Originalbildopacity bleibt1. +- Bundle-/Assetintegrität und17 vorhandene UI-Assetregeln grün. Gemeinsamer + pytest-Aufruf28/28; er enthält den Wrapper für die32 Nodefälle, keine + Doppelzählung als vollständige Produktabnahme. Keine neuen Dependencies. + +Kommando: `.venv/bin/python -m pytest -q tests/test_appbar.py tests/test_ui_assets.py`. +Private Logs im ursprünglichen Fortsetzungsordner: e05-pure-state.log und +e05-renderer-review-candidate.log. Die Browserfixture fängt ausschließlich +synthetische lokale Assetrequests ab, blockiert sonstiges Netz, startet einen +eigenen temporären Browserkontext und beendet ihn. Keine echte TD-/AP-/KB- +Verbindung, Nutzerkonten, Datenbank, Appstarts oder Userbrowserprofile beteiligt. +Keine Screenreader-/native/Host-/Verbund-/Voll-E05-Abnahme. + +Früherer Autorfixturelauf7/8: ein Test versuchte einen absichtlich +aria-disabled-gesperrten erneuten Klick und lief in Playwrights korrekten +Enable-Wächter. Als erzwungenes Event prüft er nun tatsächlich den Dedupeguard; +dies war kein Produktfehler oder grüner Beleg. Danach alle oben genannten +Fälle mit aktuellem unverändertem Stand frisch grün. + +## Noch offen + +KiraBridge-Icon, belegter AP4-Brandfarbwert, zugeordnete Icongrößenmeldung, +KiraBridge-Ersteinbindung, echte abgesicherte Peer-/Status-/Openadapter, +danach TD/AP-Hosts und deren echte Nutzer-/Nativewege. Diese Grenzen blockieren +die reine Rendererprüfung nicht und werden durch sie nicht erledigt. diff --git a/docs/evidence/e05-portable-appbar-20261020/checks.json b/docs/evidence/e05-portable-appbar-20261020/checks.json new file mode 100644 index 0000000..20cd519 --- /dev/null +++ b/docs/evidence/e05-portable-appbar-20261020/checks.json @@ -0,0 +1,19 @@ +{ + "version": "1.0.0", + "base": "b63fbd24743140fed68dc2a8eca7dcd9a7ca6c61", + "pureStateTestsPassed": 32, + "pytestTestsPassed": 28, + "realChromiumFixtureCases": 9, + "existingUiAssetTestsPassed": 17, + "browserFixtureUsesRealAppConnection": false, + "hostIntegrationsMounted": [], + "liveInstalledAppChanged": false, + "newDependencies": false, + "newSecretsPermissions": false, + "sourceSha256File": "SHA256SUMS", + "approvedKiraBridgeIconAssigned": false, + "documentedAp4RingColorAssigned": false, + "ap4FixtureColorIsSynthetic": true, + "iconDimensionsUserAccepted": false, + "independentReview": "pending" +} diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS new file mode 100644 index 0000000..bcbd4b1 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS @@ -0,0 +1,9 @@ +98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e networkpunkt.css +452497b9e08ce88937b2e9a598662da54d232b868e4c9d0eeaab0a96320bd693 networkpunkt-appbar.js +4c4ff0170d37a74167d52487018abb6499cefbfc4b322ebba62694d7468d4156 networkpunkt-appbar-state.js +2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 networkpunkt-appbar-catalog.js +6699dc6f1dcbea1c8ae063d6a39e9fea4345250d04ab89f98983e76d14b67700 networkpunkt-appbar.css +e36c6bd46dbe6744efec07ce2ab73e1996208d99fdf63ed21879da2a4d2cc6ad networkpunkt-appbar-assets.json +6add7033bb8ec5f0efa0c09ddf136c892352f4536b14b774f1ae8dcd57ee9ba2 np-appbar-threaddesk-dark-48.png +1074241a92275ae4b4a0d3ac00482ab9a253bcd5f60b569d96f84e95ae8ff336 np-appbar-4allpass-dark-48.png +13862406a14a212fd6e0646616be04604e77b9bb3a6225e8d18b470dae44225b networkpunkt-appbar-bundle.json diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-assets.json b/src/threaddesk/ui/static/networkpunkt-appbar-assets.json new file mode 100644 index 0000000..676e493 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-assets.json @@ -0,0 +1,29 @@ +{ + "v": 1, + "version": "1.0.0", + "files": [ + { + "appId": "threaddesk", + "file": "np-appbar-threaddesk-dark-48.png", + "source": "assets/design/icons/dark/ThreadDesk-dark-48.png", + "sourceRepository": "threaddesk", + "sourcePin": "b63fbd24743140fed68dc2a8eca7dcd9a7ca6c61", + "approval": "assets/design/README.md" + }, + { + "appId": "4allpass", + "file": "np-appbar-4allpass-dark-48.png", + "source": "brand/ap4/dark/4allpass-dark-48.png", + "sourceRepository": "4allpass", + "sourcePin": "2bfe0c677ceda1352db98201dc568067600dccfe", + "approval": "brand/README.md" + } + ], + "blocked": { + "kirabridge": "approved-icon-missing", + "tollgate": "asset-not-assigned" + }, + "colorBlockers": { + "4allpass": "documented-brand-color-unassigned" + } +} diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-bundle.json b/src/threaddesk/ui/static/networkpunkt-appbar-bundle.json new file mode 100644 index 0000000..a7a7afa --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-bundle.json @@ -0,0 +1,17 @@ +{ + "v": 1, + "version": "1.0.0", + "designCoreVersion": "0.1", + "files": [ + "networkpunkt.css", + "networkpunkt-appbar.js", + "networkpunkt-appbar-state.js", + "networkpunkt-appbar-catalog.js", + "networkpunkt-appbar.css", + "networkpunkt-appbar-assets.json", + "np-appbar-threaddesk-dark-48.png", + "np-appbar-4allpass-dark-48.png" + ], + "sourceOfTruth": "ThreadDesk; pinned local copies only", + "integrationStatus": "renderer-only; KiraBridge integration first" +} diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-catalog.js b/src/threaddesk/ui/static/networkpunkt-appbar-catalog.js new file mode 100644 index 0000000..2be7bf3 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-catalog.js @@ -0,0 +1,11 @@ +/* Versioned installation catalog. Approved existing pixels; no runtime discovery. */ +export const CATALOG_VERSION = "1.0.0"; +export const APP_CATALOG = Object.freeze([ + Object.freeze({ id: "threaddesk", name: Object.freeze({ de: "ThreadDesk", en: "ThreadDesk" }), + icon: "np-appbar-threaddesk-dark-48.png", color: "#b99cff", colorSource: "ThreadDesk:brand/README.md", routes: Object.freeze(["host-adapter"]) }), + Object.freeze({ id: "4allpass", name: Object.freeze({ de: "4AllPass", en: "4AllPass" }), + icon: "np-appbar-4allpass-dark-48.png", color: null, colorSource: null, routes: Object.freeze(["host-adapter"]) }), +]); +// No placeholder logo: these entries require actual approved asset assignment. +export const ASSET_BLOCKERS = Object.freeze({ kirabridge: "approved-icon-missing", tollgate: "asset-not-assigned" }); +export const COLOR_BLOCKERS = Object.freeze({ "4allpass": "documented-brand-color-unassigned" }); diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-state.js b/src/threaddesk/ui/static/networkpunkt-appbar-state.js new file mode 100644 index 0000000..c546416 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-state.js @@ -0,0 +1,82 @@ +/* NetzwerkPunkt appbar 1.0.0 — metadata projection, not authentication. */ +export const APPBAR_VERSION = "1.0.0"; +export const APP_ORDER = Object.freeze(["kirabridge", "threaddesk", "4allpass", "tollgate"]); +const STATES = ["confirmed", "connecting", "disconnected", "unknown", "error"]; +const ref = value => typeof value === "string" && /^[\w.:-]{1,128}$/.test(value); +const ttl = value => Number.isFinite(value) && value > 0 && value <= 15000; +const keys = (value, allowed) => value && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).every(key => allowed.includes(key)); + +export function createPeerState(initial, { clock = () => performance.now(), wallClock = () => Date.now() } = {}) { + let expected, generation = 0, serial = 0, pending = null, accepted = null, seq = -1, neutral = "unknown"; + function reset(next = expected) { + if (!next || !APP_ORDER.includes(next.appId) || ![next.connectionRef, next.peerRef, next.peerEpoch].every(ref) + || (next.unlockScope && ![next.unlockScope.userRef, next.unlockScope.vaultRef].every(ref))) { + throw new TypeError("Invalid expected peer binding"); + } + expected = Object.freeze({ appId: next.appId, connectionRef: next.connectionRef, + peerRef: next.peerRef, peerEpoch: next.peerEpoch, + unlockScope: next.unlockScope ? Object.freeze({ userRef: next.unlockScope.userRef, vaultRef: next.unlockScope.vaultRef }) : null }); + generation++; pending = null; accepted = null; seq = -1; neutral = "unknown"; + } + reset(initial); + const age = started => { + const mono = clock() - started.mono, wall = wallClock() - started.wall; + return mono < 0 || wall < 0 ? Infinity : Math.max(mono, wall); + }; + function snapshot() { + if (pending && age(pending) >= 2000) { + pending = null; accepted = null; neutral = "unknown"; + } + if (accepted && age(accepted.started) >= accepted.ttlMs) { + accepted = null; neutral = "unknown"; + } + if (!accepted) return { state: neutral, vault: "unknown", pulse: false }; + const state = accepted.state; + const vault = state === "confirmed" && accepted.vault && age(accepted.started) < accepted.vault.ttlMs + ? accepted.vault.state : "unknown"; + return { state, vault, pulse: expected.appId === "4allpass" && state === "confirmed" && vault === "unlocked" }; + } + function begin() { + snapshot(); + if (pending) return pending.ticket; // at most one outstanding check per peer + if (accepted?.state !== "confirmed") accepted = null; + const ticket = Object.freeze({ generation, check: ++serial }); + pending = { ticket, mono: clock(), wall: wallClock() }; + if (!accepted) neutral = "connecting"; + return ticket; + } + function current(ticket) { + snapshot(); + return pending && pending.ticket === ticket && ticket.generation === generation; + } + function fail(ticket, state = "unknown") { + if (!current(ticket)) return false; + pending = null; accepted = null; + neutral = state === "error" ? "error" : "unknown"; + return true; + } + function receive(ticket, value) { + if (!current(ticket)) return false; + const started = { mono: pending.mono, wall: pending.wall }; + const valid = keys(value, ["v", "appId", "connectionRef", "peerRef", "peerEpoch", "seq", "state", "ttlMs", "vault"]) + && value.v === 1 && value.appId === expected.appId && value.connectionRef === expected.connectionRef + && value.peerRef === expected.peerRef && value.peerEpoch === expected.peerEpoch + && Number.isSafeInteger(value.seq) && value.seq > seq && STATES.includes(value.state) && ttl(value.ttlMs); + if (!valid) { fail(ticket); return false; } + let vault = null; + if (expected.appId === "4allpass" && value.state === "confirmed" && expected.unlockScope) { + const scope = value.vault; + if (keys(scope, ["userRef", "vaultRef", "allowed", "state", "ttlMs"]) && scope.allowed === true + && scope.userRef === expected.unlockScope.userRef && scope.vaultRef === expected.unlockScope.vaultRef + && ["locked", "unlocked", "unknown"].includes(scope.state) && ttl(scope.ttlMs) && scope.ttlMs <= value.ttlMs) { + vault = { state: scope.state, ttlMs: scope.ttlMs }; + } + } + seq = value.seq; pending = null; + accepted = { state: value.state, ttlMs: value.ttlMs, started, vault }; + return true; + } + function invalidate() { generation++; pending = null; accepted = null; neutral = "unknown"; } + return Object.freeze({ begin, receive, fail, snapshot, reset, invalidate }); +} diff --git a/src/threaddesk/ui/static/networkpunkt-appbar.css b/src/threaddesk/ui/static/networkpunkt-appbar.css new file mode 100644 index 0000000..a44987a --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar.css @@ -0,0 +1,19 @@ +/* NetzwerkPunkt appbar 1.0.0; NP desktop controls, one icon and one ring. */ +.np-appbar { display:flex; align-items:center; gap:var(--np-space-1,4px); flex-shrink:0; width:max-content; max-width:100%; font:400 var(--np-meta)/1.4 var(--np-font); color:var(--np-text,#edf0f4); } +.np-appbar-item { position:relative; display:inline-flex; flex:none; } +.np-appbar-button { display:inline-flex; align-items:center; justify-content:center; width:var(--np-control,40px); height:var(--np-control,40px); padding:0; border:0; border-radius:0; background:transparent; color:inherit; cursor:pointer; } +.np-appbar-button:hover { background:var(--np-hover,#25282e); } +.np-appbar-button[aria-disabled="true"] { cursor:default; } +.np-appbar-button:focus-visible { outline:2px solid var(--np-focus,#edf0f4); outline-offset:1px; } +.np-appbar-ring { position:relative; display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box; width:32px; height:32px; } +.np-appbar-ring::before { content:""; position:absolute; inset:0; border:1px solid transparent; border-radius:50%; pointer-events:none; } +.np-appbar-ring img { display:block; width:24px; height:24px; object-fit:contain; } +.np-appbar-ring[data-connected="true"]::before { border-color:var(--np-appbar-color); box-shadow:0 0 4px color-mix(in srgb,var(--np-appbar-color) 10%,transparent); } +.np-appbar-ring[data-pulse="true"]::before { animation:np-appbar-unlocked 12s ease-in-out infinite; } +@keyframes np-appbar-unlocked { 0%,100% { opacity:1; } 50% { opacity:.9; } } +.np-appbar-button[data-error="true"] { outline:1px solid var(--np-danger,#f49b9b); outline-offset:-1px; } +.np-appbar-button[data-error="true"]:focus-visible { outline:2px solid var(--np-focus,#edf0f4); outline-offset:1px; } +.np-appbar-tooltip { position:absolute; top:100%; left:0; z-index:20; width:max-content; max-width:280px; padding:var(--np-space-2,8px); background:var(--np-surface,#1a1c20); border:1px solid var(--np-control-border,#808996); pointer-events:none; } +.np-appbar [hidden] { display:none; } +.np-appbar-sr { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } +@media (prefers-reduced-motion:reduce) { .np-appbar-ring[data-pulse="true"]::before { animation:none; } } diff --git a/src/threaddesk/ui/static/networkpunkt-appbar.js b/src/threaddesk/ui/static/networkpunkt-appbar.js new file mode 100644 index 0000000..e5b3a63 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar.js @@ -0,0 +1,134 @@ +/* NetzwerkPunkt appbar 1.0.0 — portable DOM renderer, no transport or host launch. */ +import { APPBAR_VERSION, APP_ORDER, createPeerState } from "./networkpunkt-appbar-state.js"; +import { APP_CATALOG } from "./networkpunkt-appbar-catalog.js"; +export { APPBAR_VERSION }; +const mounts = new WeakMap(); +let mounted = 0; +const COPY = { + de: { bar: "Verbundene Apps", open: "Öffnen oder fokussieren", confirmed: "Verbunden", + connecting: "Verbindet …", disconnected: "Nicht verbunden", unknown: "Status unbekannt", + error: "Verbindungsfehler · Verbindung prüfen", unlocked: "Tresor entsperrt", locked: "Tresor gesperrt", + vaultUnknown: "Tresorstatus unbekannt", opening: "App wird geöffnet …", + opened: "App geöffnet, Verbindung fehlt", focused: "App fokussiert, Verbindung unbestätigt", + failed: "Öffnen fehlgeschlagen · Ziel prüfen und erneut versuchen", + missing: "Öffnen nicht verfügbar · App-Ziel einrichten", + uncertain: "Öffnung unklar · App prüfen, nicht erneut starten", unconfigured: "Statusanzeige nicht eingerichtet" }, + en: { bar: "Connected apps", open: "Open or focus", confirmed: "Connected", + connecting: "Connecting …", disconnected: "Not connected", unknown: "Status unknown", + error: "Connection error · Check connection", unlocked: "Vault unlocked", locked: "Vault locked", + vaultUnknown: "Vault status unknown", opening: "Opening app …", + opened: "App opened, connection missing", focused: "App focused, connection unconfirmed", + failed: "Opening failed · Check target and retry", missing: "Opening unavailable · Configure app target", + uncertain: "Opening outcome unknown · Check app, do not restart", unconfigured: "Status display not configured" }, +}; +function catalogEntries(catalog) { + if (!Array.isArray(catalog) || catalog.length > APP_ORDER.length) throw new TypeError("Invalid app catalog"); + const seen = new Set(); + return catalog.map(entry => { + if (!APP_ORDER.includes(entry.id) || seen.has(entry.id) || !/^[a-z0-9-]+\.png$/.test(entry.icon) + || (entry.color !== null && !/^#[0-9a-f]{6}$/i.test(entry.color)) || ![entry.name?.de, entry.name?.en].every(x => typeof x === "string" && x.length > 0 && x.length <= 80) + || !Array.isArray(entry.routes) || !entry.routes.every(x => x === "host-adapter")) throw new TypeError("Invalid app catalog entry"); + seen.add(entry.id); + return { id: entry.id, name: { de: entry.name.de, en: entry.name.en }, icon: entry.icon, color: entry.color }; + }).sort((a, b) => APP_ORDER.indexOf(a.id) - APP_ORDER.indexOf(b.id)); +} + +export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors = {}, locale = "de", onOpen, + clock = () => performance.now(), wallClock = () => Date.now() } = {}) { + mounts.get(element)?.destroy(); + const doc = element.ownerDocument, win = doc.defaultView, mountId = ++mounted; + const entries = catalogEntries(catalog), items = new Map(); + let destroyed = false, language = locale === "en" ? "en" : "de", lastAnnouncement = ""; + const root = doc.createElement("nav"); root.className = "np-appbar"; + root.dataset.version = APPBAR_VERSION; + const live = doc.createElement("span"); live.className = "np-appbar-sr"; + live.setAttribute("role", "status"); live.setAttribute("aria-live", "polite"); live.setAttribute("aria-atomic", "true"); + for (const entry of entries) { + const configured = colors[entry.id]; + if (configured && (!/^#[0-9a-f]{6}$/i.test(configured.value) || typeof configured.sourceRef !== "string" + || configured.sourceRef.length < 1 || configured.sourceRef.length > 256)) throw new TypeError("Invalid configured tool color"); + const color = configured?.value ?? entry.color; + if (peers[entry.id] && peers[entry.id].appId !== entry.id) throw new TypeError("Peer app mismatch"); + const peer = peers[entry.id] ? createPeerState(peers[entry.id], { clock, wallClock }) : null; + const wrapper = doc.createElement("span"); wrapper.className = "np-appbar-item"; + const button = doc.createElement("button"); button.type = "button"; button.className = "np-appbar-button"; + button.dataset.appId = entry.id; + const ring = doc.createElement("span"); ring.className = "np-appbar-ring"; + if (color) ring.style.setProperty("--np-appbar-color", color); + const image = doc.createElement("img"); image.alt = ""; image.width = image.height = 24; + image.src = new URL(entry.icon, import.meta.url).href; + ring.append(image); button.append(ring); + const tooltip = doc.createElement("span"); tooltip.className = "np-appbar-tooltip"; + tooltip.id = `np-appbar-tip-${mountId}-${entry.id}`; tooltip.setAttribute("role", "tooltip"); tooltip.hidden = true; + button.setAttribute("aria-describedby", tooltip.id); + const show = () => { tooltip.hidden = false; }; + const hide = () => { tooltip.hidden = true; }; + button.addEventListener("focus", show); button.addEventListener("blur", hide); + button.addEventListener("mouseenter", show); wrapper.addEventListener("mouseleave", hide); + button.addEventListener("keydown", event => { if (event.key === "Escape") { hide(); event.preventDefault(); } }); + const item = { entry, peer, color, wrapper, button, ring, tooltip, open: null, lastOpen: -Infinity, abort: null, serial: 0 }; + button.addEventListener("click", () => open(item)); + wrapper.append(button, tooltip); root.append(wrapper); items.set(entry.id, item); + } + root.append(live); element.append(root); + function paint() { + if (destroyed) return; + const text = COPY[language], announcements = []; + root.setAttribute("aria-label", text.bar); root.lang = language; + for (const item of items.values()) { + const observed = item.peer?.snapshot(); + const state = item.color && observed ? observed : { state: "unknown", vault: "unknown", pulse: false }; + if (item.open?.state === "opening" && clock() - item.open.started >= 2000) { + item.open.state = "uncertain"; item.abort?.abort(); + } + item.ring.dataset.connected = String(state.state === "confirmed"); + item.ring.dataset.pulse = String(state.pulse); + const vault = item.entry.id === "4allpass" && state.state === "confirmed" + ? ` · ${text[state.vault === "unknown" ? "vaultUnknown" : state.vault]}` : ""; + let status = text[state.state] + vault; + if (!item.color) status += ` · ${text.unconfigured}`; + if (typeof onOpen !== "function") status += ` · ${text.missing}`; + else if (item.open && !(["opened", "focused"].includes(item.open.state) && state.state === "confirmed")) status += ` · ${text[item.open.state]}`; + const label = `${item.entry.name[language]} · ${status}`; + item.tooltip.textContent = label; + item.button.setAttribute("aria-label", `${label} · ${text.open}`); + item.button.setAttribute("aria-disabled", String(typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state))); + item.button.dataset.error = String(state.state === "error" || ["failed", "uncertain"].includes(item.open?.state)); + announcements.push(label); + } + const announcement = announcements.join("; "); + if (announcement !== lastAnnouncement) { live.textContent = announcement; lastAnnouncement = announcement; } + } + async function open(item) { + paint(); + if (destroyed || typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state) || clock() - item.lastOpen < 600) return; + item.lastOpen = clock(); item.abort = new AbortController(); + const actionId = `np-appbar-${Date.now()}-${mountId}-${++item.serial}`; + item.open = { state: "opening", started: clock(), actionId }; paint(); + try { + const result = await onOpen({ appId: item.entry.id, actionId, signal: item.abort.signal }); + paint(); // enforce the deadline before accepting a late completion + if (destroyed || item.open?.actionId !== actionId || item.open.state !== "opening") return; + item.open.state = result?.actionId === actionId && ["opened", "focused", "failed"].includes(result.outcome) ? result.outcome : "uncertain"; + } catch { if (!destroyed && item.open?.actionId === actionId && item.open.state === "opening") item.open.state = "uncertain"; } + paint(); + } + function invalidate() { for (const item of items.values()) item.peer?.invalidate(); paint(); } + doc.addEventListener("visibilitychange", invalidate); win.addEventListener("pagehide", invalidate); + const timer = win.setInterval(paint, 250); + const api = Object.freeze({ + begin(appId) { if (destroyed) return null; const ticket = items.get(appId)?.peer?.begin() ?? null; paint(); return ticket; }, + receive(appId, ticket, observation) { if (destroyed) return false; const accepted = items.get(appId)?.peer?.receive(ticket, observation) ?? false; paint(); return accepted; }, + fail(appId, ticket, state) { if (destroyed) return false; const accepted = items.get(appId)?.peer?.fail(ticket, state) ?? false; paint(); return accepted; }, + reset(appId, expected) { if (destroyed) return; if (expected.appId !== appId) throw new TypeError("Peer app mismatch"); + const item = items.get(appId); if (item) { if (item.peer) item.peer.reset(expected); else item.peer = createPeerState(expected, {clock,wallClock}); } paint(); }, + // Only after the host checked an unknown opening outcome; never automatic retry. + clearOpen(appId) { const item = items.get(appId); if (item) { item.abort?.abort(); item.open = null; paint(); } }, + setLocale(value) { language = value === "en" ? "en" : "de"; paint(); }, + refresh: paint, + destroy() { if (destroyed) return; destroyed = true; win.clearInterval(timer); doc.removeEventListener("visibilitychange", invalidate); + win.removeEventListener("pagehide", invalidate); for (const item of items.values()) { item.peer?.invalidate(); item.abort?.abort(); } + root.remove(); mounts.delete(element); }, + }); + mounts.set(element, api); paint(); return api; +} diff --git a/src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png b/src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png new file mode 100644 index 0000000000000000000000000000000000000000..d88ea8b3bd882c1328d24d84c2f5f92e63fb6ec6 GIT binary patch literal 3548 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, wallClock:()=>wall }); + const observation = patch => ({ v:1,appId,connectionRef:"fixture-link",peerRef:"fixture-peer",peerEpoch:"epoch-1", + seq:0,state:"confirmed",ttlMs:15000,...patch }); + return {peer, observation, advance(ms){time+=ms;wall+=ms;}, rollback(){wall--;}}; +} +const unlocked = {userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:1000}; +test("unknown and connecting are distinct; only a fresh matching reply confirms",()=>{ + const x=setup();assert.equal(x.peer.snapshot().state,"unknown");const t=x.peer.begin(); + assert.equal(x.peer.begin(),t);assert.equal(x.peer.snapshot().state,"connecting"); + assert.equal(x.peer.receive(t,x.observation()),true);assert.equal(x.peer.snapshot().state,"confirmed"); + x.advance(15000);assert.deepEqual(x.peer.snapshot(),{state:"unknown",vault:"unknown",pulse:false}); +}); +for(const [key,value] of [["v",2],["appId","threaddesk"],["connectionRef","wrong"],["peerRef","wrong"],["peerEpoch","old"], + ["seq",-1],["seq",1.1],["seq",Number.MAX_SAFE_INTEGER+1],["state","live"],["ttlMs",15001],["ttlMs",0],["ttlMs",NaN],["ttlMs",Infinity],["ttlMs","1000"],["authenticated",true],["pid",123]]) { + test(`invalid or mismatched ${key}=${value} stays neutral`,()=>{const x=setup();assert.equal(x.peer.receive(x.peer.begin(),x.observation({[key]:value})),false);assert.equal(x.peer.snapshot().state,"unknown");}); +} +test("deadline, local wall-clock rollback and delayed replies fail closed",()=>{ + const x=setup();let t=x.peer.begin();x.advance(2000);assert.equal(x.peer.receive(t,x.observation()),false); + t=x.peer.begin();assert(x.peer.receive(t,x.observation({vault:unlocked})));x.rollback();assert.equal(x.peer.snapshot().pulse,false); +}); +test("a new check preserves a fresh ring, timeout removes it",()=>{ + const x=setup();x.peer.receive(x.peer.begin(),x.observation());x.peer.begin();assert.equal(x.peer.snapshot().state,"confirmed"); + x.advance(2000);assert.equal(x.peer.snapshot().state,"unknown"); +}); +test("old generation, epoch, sequence and visibility invalidation cannot reconnect",()=>{ + const x=setup();const old=x.peer.begin();x.peer.reset({...expected,peerEpoch:"epoch-2"}); + assert.equal(x.peer.receive(old,x.observation()),false); + assert.equal(x.peer.receive(x.peer.begin(),x.observation()),false); + assert(x.peer.receive(x.peer.begin(),x.observation({peerEpoch:"epoch-2",seq:10}))); + assert.equal(x.peer.receive(x.peer.begin(),x.observation({peerEpoch:"epoch-2",seq:9})),false); + const t=x.peer.begin();x.peer.invalidate();assert.equal(x.peer.receive(t,x.observation({peerEpoch:"epoch-2",seq:11})),false); +}); +test("AP4 unlock has separate shorter freshness; locked and unknown remain distinct",()=>{ + const x=setup();x.peer.receive(x.peer.begin(),x.observation({vault:unlocked}));assert.equal(x.peer.snapshot().pulse,true); + x.advance(1000);assert.deepEqual(x.peer.snapshot(),{state:"confirmed",vault:"unknown",pulse:false}); + x.peer.receive(x.peer.begin(),x.observation({seq:1,vault:{...unlocked,state:"locked"}})); + assert.deepEqual(x.peer.snapshot(),{state:"confirmed",vault:"locked",pulse:false}); +}); +for(const patch of [{allowed:false},{allowed:"true"},{userRef:"another-user"},{vaultRef:"another-vault"},{ttlMs:20000},{state:"active"},{secret:"not-accepted"}]) { + test(`unapproved or malformed unlock scope ${Object.keys(patch)} never pulses`,()=>{ + const x=setup();x.peer.receive(x.peer.begin(),x.observation({vault:{...unlocked,...patch}})); + assert.deepEqual(x.peer.snapshot(),{state:"confirmed",vault:"unknown",pulse:false}); + }); +} +for(const appId of ["kirabridge","threaddesk","tollgate"]) test(`${appId} never has an activity/unlock pulse`,()=>{ + const x=setup(appId);x.peer.receive(x.peer.begin(),x.observation({vault:unlocked}));assert.equal(x.peer.snapshot().pulse,false); +}); +test("failure and explicit disconnect remove ring; reconnect needs another accepted reply",()=>{ + const x=setup();x.peer.receive(x.peer.begin(),x.observation({vault:unlocked}));x.peer.fail(x.peer.begin(),"error"); + assert.equal(x.peer.snapshot().state,"error");assert.equal(x.peer.snapshot().pulse,false); + x.peer.receive(x.peer.begin(),x.observation({seq:1,state:"disconnected"}));assert.equal(x.peer.snapshot().state,"disconnected"); + x.peer.begin();assert.equal(x.peer.snapshot().state,"connecting"); + x.peer.receive(x.peer.begin(),x.observation({seq:2}));assert.equal(x.peer.snapshot().state,"confirmed"); +}); diff --git a/tests/fixtures/appbar.html b/tests/fixtures/appbar.html new file mode 100644 index 0000000..2846abd --- /dev/null +++ b/tests/fixtures/appbar.html @@ -0,0 +1,21 @@ +Synthetic renderer fixture + + +
Fixture
+ + diff --git a/tests/test_appbar.py b/tests/test_appbar.py new file mode 100644 index 0000000..7418a15 --- /dev/null +++ b/tests/test_appbar.py @@ -0,0 +1,167 @@ +"""Portable renderer sources and real browser fixture; no app/peer admission.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess + +import pytest +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "src/threaddesk/ui/static" + + +def test_pure_state_contract(): + subprocess.run(["node", "--test", str(ROOT / "tests/appbar_state.test.mjs")], cwd=ROOT, check=True) + + +def test_approved_asset_hashes_and_no_bridge_substitute(): + manifest = json.loads((STATIC / "networkpunkt-appbar-assets.json").read_text()) + assert manifest["blocked"]["kirabridge"] == "approved-icon-missing" + assert [x["appId"] for x in manifest["files"]] == ["threaddesk", "4allpass"] + for line in (STATIC / "networkpunkt-appbar-SHA256SUMS").read_text().splitlines(): + digest, name = line.split(" ", 1) + assert hashlib.sha256((STATIC / name).read_bytes()).hexdigest() == digest + assert not list(STATIC.glob("np-appbar-kirabridge*")) + + +@pytest.fixture() +def appbar(): + with sync_playwright() as play: + installed = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") + executable = os.environ.get("THREADDESK_CHROMIUM") or (str(installed) if installed.exists() else None) + browser = play.chromium.launch(executable_path=executable) + page = browser.new_page(viewport={"width": 1440, "height": 900}) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + + def route(request): + path = request.request.url.split(".invalid", 1)[-1] + if path == "/": + request.fulfill(path=str(ROOT / "tests/fixtures/appbar.html"), content_type="text/html") + elif path.startswith("/static/") and "/" not in path[len("/static/"):]: + file = STATIC / path[len("/static/"):] + assert file.name.startswith(("networkpunkt", "np-appbar")) and file.is_file() + content_type = "text/javascript" if file.suffix == ".js" else "text/css" if file.suffix == ".css" else "image/png" + request.fulfill(path=str(file), content_type=content_type) + else: + request.abort() + + page.route("https://appbar-fixture.invalid/**", route) + page.route("**/*", lambda route: route.fallback() if route.request.url.startswith("https://appbar-fixture.invalid/") else route.abort()) + page.goto("https://appbar-fixture.invalid/") + page.wait_for_function("window.ready === true") + try: + yield page + assert not errors + finally: + page.close() + browser.close() + + +def ring(page, app="4allpass"): + return page.locator(f'[data-app-id="{app}"] .np-appbar-ring') + + +def test_real_dom_states_scope_staleness_and_reconnect(appbar): + page = appbar + assert "Status unbekannt" in page.locator('[data-app-id="4allpass"]').get_attribute("aria-label") + page.evaluate('check("4allpass", {vault:{userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:1000}})') + assert ring(page).get_attribute("data-pulse") == "true" + page.evaluate("advance(1000)") + assert ring(page).get_attribute("data-connected") == "true" + assert ring(page).get_attribute("data-pulse") == "false" + assert "Tresorstatus unbekannt" in page.locator('[data-app-id="4allpass"]').get_attribute("aria-label") + page.evaluate('bar.fail("4allpass", bar.begin("4allpass"), "error")') + assert ring(page).get_attribute("data-connected") == "false" + page.evaluate('check("4allpass")') + assert ring(page).get_attribute("data-connected") == "true" + page.evaluate('document.dispatchEvent(new Event("visibilitychange"))') + assert ring(page).get_attribute("data-connected") == "false" + + +@pytest.mark.parametrize("locale", ["de", "en"]) +def test_keyboard_tooltip_open_dedup_and_no_false_connection(appbar, locale): + page = appbar + page.evaluate("locale => bar.setLocale(locale)", locale) + button = page.locator('[data-app-id="4allpass"]') + button.focus() + assert page.locator("#np-appbar-tip-1-4allpass").is_visible() + page.keyboard.press("Escape") + assert not page.locator("#np-appbar-tip-1-4allpass").is_visible() + page.keyboard.press("Enter") + page.keyboard.press("Space") + button.dispatch_event("click") + assert page.evaluate("openCalls.length") == 1 + assert button.get_attribute("aria-disabled") == "true" + page.evaluate('resolvers[0]("opened")') + page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"]").getAttribute("aria-disabled") === "false"') + assert ring(page).get_attribute("data-connected") == "false" + label = button.get_attribute("aria-label") + assert ("App geöffnet, Verbindung fehlt" if locale == "de" else "App opened, connection missing") in label + page.keyboard.press("Tab") + assert page.locator("#after").evaluate("e => e === document.activeElement") + + +def test_unknown_open_result_late_response_and_remount_are_safe(appbar): + page = appbar + page.locator('[data-app-id="threaddesk"]').click() + page.evaluate("advance(2000)") + page.evaluate('resolvers[0]("opened")') + page.locator('[data-app-id="threaddesk"]').dispatch_event("click") + assert page.evaluate("openCalls.length") == 1 + assert "Öffnung unklar" in page.locator('[data-app-id="threaddesk"]').get_attribute("aria-label") + page.evaluate("mountAgain(); mountAgain()") + assert page.locator(".np-appbar").count() == 1 + assert page.evaluate("activeIntervals.size") == 1 + page.evaluate("bar.destroy(); bar.destroy()") + assert page.locator(".np-appbar").count() == 0 + assert page.evaluate("activeIntervals.size") == 0 + assert page.evaluate('bar.begin("4allpass")') is None + assert page.evaluate('bar.receive("4allpass", {}, observation("4allpass"))') is False + + +def test_actual_periodic_expiry_and_failed_open_message(appbar): + page = appbar + page.evaluate('check("4allpass", {ttlMs:500})') + page.evaluate("time+=500;wall+=500") + page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"] .np-appbar-ring").dataset.connected === "false"') + page.locator('[data-app-id="4allpass"]').click() + page.evaluate('resolvers[0]("failed")') + page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"]").dataset.error === "true"') + assert "Öffnen fehlgeschlagen" in page.locator('[data-app-id="4allpass"]').get_attribute("aria-label") + assert ring(page).get_attribute("data-connected") == "false" + + +def test_fast_success_double_click_still_opens_once(appbar): + page = appbar + page.evaluate('options.onOpen=async args=>{openCalls.push({appId:args.appId,actionId:args.actionId});return {actionId:args.actionId,outcome:"opened"};};mountAgain()') + page.locator('[data-app-id="4allpass"]').dblclick() + assert page.evaluate("openCalls.length") == 1 + assert ring(page).get_attribute("data-connected") == "false" + + +def test_missing_approved_tool_color_keeps_default_catalog_neutral(appbar): + page = appbar + page.evaluate('options.colors={};mountAgain();check("4allpass", {vault:{userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:1000}})') + assert ring(page).get_attribute("data-connected") == "false" + assert ring(page).get_attribute("data-pulse") == "false" + assert "Statusanzeige nicht eingerichtet" in page.locator('[data-app-id="4allpass"]').get_attribute("aria-label") + + +@pytest.mark.parametrize("width", [1440, 320]) +def test_fixed_icon_geometry_and_only_ring_motion(appbar, width): + page = appbar + page.set_viewport_size({"width": width, "height": 760}) + before = page.locator(".np-appbar-button").evaluate_all("es => es.map(e => {const r=e.getBoundingClientRect();return [r.x,r.y,r.width,r.height]})") + page.evaluate('check("4allpass", {vault:{userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:15000}})') + after = page.locator(".np-appbar-button").evaluate_all("es => es.map(e => {const r=e.getBoundingClientRect();return [r.x,r.y,r.width,r.height]})") + assert before == after + assert all(r[2:] == [40, 40] and r[0] >= 0 and r[0] + r[2] <= width for r in after) + assert after[1][0] - after[0][0] == 44 + assert ring(page).evaluate('e => getComputedStyle(e,"::before").animationDuration') == "12s" + assert ring(page).locator("img").evaluate("e => getComputedStyle(e).opacity") == "1" + page.emulate_media(reduced_motion="reduce") + assert ring(page).evaluate('e => getComputedStyle(e,"::before").animationName') == "none" + assert "Tresor entsperrt" in page.locator('[data-app-id="4allpass"]').get_attribute("aria-label") diff --git a/tests/test_ui_assets.py b/tests/test_ui_assets.py index 0b3de56..27c5935 100644 --- a/tests/test_ui_assets.py +++ b/tests/test_ui_assets.py @@ -71,6 +71,7 @@ def test_public_pages_never_link_to_a_feature_branch(page: Path) -> None: # "999px ist ok" jedem Knopf, wieder rund zu werden — genau das ist beim # Schreiben dieses Tests passiert und erst durch die Probe aufgefallen. SHAPE_EXCEPTIONS = { + ".np-appbar-ring::before": "UI-01 verlangt genau einen runden Verbindungsring um das vorhandene App-Icon; kein gerundetes Bedienelement.", ".node-shape": "Kreis statt Ecke: die Form bedeutet auf der Karte die Knotenart.", ".map-legend .key-circle": "Zeigt dieselbe Kartenform in der Legende.", ".map-legend .key-tone": "Zeigt dieselbe Kartenform in der Legende.", From 2e9fb1561082e238084abedabe63ee08fc2afa32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:49:42 +0200 Subject: [PATCH 2/4] fix(ui): expire appbar clocks conservatively and contain tooltips --- docs/TEAMPLAN.md | 7 +++ docs/contracts/appbar-v1.md | 16 +++++-- .../e05-portable-appbar-20261020/SHA256SUMS | 8 ++-- .../e05-portable-appbar-20261020/STATUS.md | 44 ++++++++++++++++--- .../e05-portable-appbar-20261020/checks.json | 14 ++++-- .../ui/static/networkpunkt-appbar-SHA256SUMS | 6 +-- .../ui/static/networkpunkt-appbar-state.js | 29 +++++++----- .../ui/static/networkpunkt-appbar.css | 2 +- .../ui/static/networkpunkt-appbar.js | 24 +++++++--- tests/appbar_state.test.mjs | 20 ++++++++- tests/test_appbar.py | 38 ++++++++++++++++ 11 files changed, 169 insertions(+), 39 deletions(-) diff --git a/docs/TEAMPLAN.md b/docs/TEAMPLAN.md index a4a3973..244a1ae 100644 --- a/docs/TEAMPLAN.md +++ b/docs/TEAMPLAN.md @@ -267,3 +267,10 @@ Start/Transport/PID und kein Bridge-Aktivitätspuls. Vertrag/Übernahme: kein KB-Ersatzlogo. Noch keine TD/AP/KB-Template-/Appintegration; KB-Mount bleibt gemäß Gesamtplan zuerst. Unabhängiger Source-/Browserreview und echte Öffnungs-/Status-/Hostabnahmen folgen getrennt. + +E05-Renderer-Nachbesserung nach unabhängigem b36-Review: beobachtete partielle +Uhr-Rücksprünge neutralisieren die Statuslease; Öffnungsdeadline bewertet beide +Uhren und hält unklare Starts bis Hostprüfung gesperrt. Tooltips bleiben auch +im schmalen Ausschnitt sichtbar.35 Nodefälle/31 gezielte Repo-Prüfungen grün, +unveränderte Reviewerproben vom Autor5 Node/6 Browser grün; unabhängige +Nachabnahme folgt, echte Verbindungen/Hostintegration weiterhin offen. diff --git a/docs/contracts/appbar-v1.md b/docs/contracts/appbar-v1.md index 5a4ec3c..fbabaae 100644 --- a/docs/contracts/appbar-v1.md +++ b/docs/contracts/appbar-v1.md @@ -49,7 +49,9 @@ erreichbar; dies ist keine Abnahme tatsächlicher Hostheader. Die Katalogreihenfolge KiraBridge/ThreadDesk/4AllPass/TollGate ist unabhängig vom Status; nur verfügbare konfigurierte Einträge erscheinen. Nativer Button mit Textname/Status/Öffnungsaktion als aria-label, Tab/ShiftTab/Enter/Space, sichtbarem -Fokus und Tooltip bei Fokus/Hover, mit Escape schließbar. Kein Zusatzschloss. +Fokus und Tooltip bei Fokus/Hover, mit Escape schließbar. Der Tooltip wird +bei Anzeige, Text- und Viewportänderung innerhalb der sichtbaren Breite gehalten. +Kein Zusatzschloss. Die Live-Region meldet semantische Änderungen, nicht jeden lokalen250ms-Tick. Nur der AP4-Ring darf bei erlaubtem aktuellem Unlock langsam pulsieren: 12s, Deckkraft1.00…0.90, Glow10% Toolfarbe. Bild/Hitarea werden nicht animiert. @@ -125,7 +127,10 @@ der reine Renderer fragt nichts ab. Gleiche/alte Sequenzen, falsche App/Verbindu Peer/Instanz, fremde Tickets, Zeitüberschreitung und ungültige Metadaten werden verworfen. Reset/Verstecken/pagehide invalidieren die Generation. Lokale monotone und Wanduhr werden gemeinsam konservativ bewertet; Schlaf oder Uhr-Rücksprung -darf kein altes Grün verlängern. Es wird kein Status aus Storage restauriert. +darf kein altes Grün verlängern. Das je Vorgang beobachtete Alter sinkt nie; +ein beobachteter Rücksprung einer der beiden Uhren macht dessen Lease dauerhaft +ungültig, auch wenn die Uhr noch nach dem ursprünglichen Checkbeginn liegt. +Erst ein neuer Check kann wieder bestätigen. Es wird kein Status aus Storage restauriert. `confirmed` mit gültiger Verbindung erzeugt den ruhigen eigenen Ring. Nur AP4 mit explizitem allowed:true und exakt erwartetem userRef/vaultRef darf zusätzlich @@ -142,7 +147,10 @@ Der Host mappt diese ID auf bereits erlaubtes festes Ziel und schützt Fokus/ Start sowie begrenzte Idempotenz selbst. Antwort: `{actionId,outcome:"focused"|"opened"|"failed"|"unknown"}`. -Parallel-/Doppelklicks werden zusammengeführt (mindestens600ms). Deadline2s; +Parallel-/Doppelklicks werden zusammengeführt (mindestens600ms). Deadline2s +mit derselben konservativen monotonen/Wanduhrmessung wie beim Status: Schlaf, +Wanduhrfortschritt bei stehender monotoner Uhr oder beobachteter Rücksprung +können einen verspäteten Erfolg nicht nachträglich akzeptieren; unklarer Ausgang bleibt gegen weiteren Start gesperrt und hat keinen automatischen Retry. Erst nach eigener Zielprüfung darf der Host `clearOpen(appId)` aufrufen. Späte Ergebnisse ersetzen keinen neueren Vorgang. @@ -154,7 +162,7 @@ Ein Öffnungserfolg bestätigt **niemals** eine Verbindung oder Tresorfreigabe. ## Geprüfter und weiterhin offener Umfang `python -m pytest -q tests/test_appbar.py tests/test_ui_assets.py` führt die -32 reinen Node-Zustandsprüfungen sowie echtes Chromium-DOM/CSS aus. Die +35 reinen Node-Zustandsprüfungen sowie echtes Chromium-DOM/CSS aus. Die Browserfixture liefert nur freigegebene lokale Assets über abgefangene Fixture-Requests; keine echte App, Verbindung, Konten, Datenbank oder Startziele. DE/EN/Keyboard, Scope/TTL/Fehler/Wiederverbindung, Ring-/Bildgeometrie, reduzierte diff --git a/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS index 06f8cfe..064106e 100644 --- a/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS +++ b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS @@ -1,10 +1,10 @@ 98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e src/threaddesk/ui/static/networkpunkt.css -452497b9e08ce88937b2e9a598662da54d232b868e4c9d0eeaab0a96320bd693 src/threaddesk/ui/static/networkpunkt-appbar.js -4c4ff0170d37a74167d52487018abb6499cefbfc4b322ebba62694d7468d4156 src/threaddesk/ui/static/networkpunkt-appbar-state.js +6f7c2322c1428cd5b5de6b967ccf9e2d356b6a17f25184b7ce8946b16aa9c018 src/threaddesk/ui/static/networkpunkt-appbar.js +c97ef3d092a2df9687629a2d877672b133df8aeda8175f3bfdf2a7698758d7f8 src/threaddesk/ui/static/networkpunkt-appbar-state.js 2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 src/threaddesk/ui/static/networkpunkt-appbar-catalog.js -6699dc6f1dcbea1c8ae063d6a39e9fea4345250d04ab89f98983e76d14b67700 src/threaddesk/ui/static/networkpunkt-appbar.css +b8626e2dc7d0f34a4065fc217e11cd0b52ce1d4dea0df802b4a642e7fec57b46 src/threaddesk/ui/static/networkpunkt-appbar.css e36c6bd46dbe6744efec07ce2ab73e1996208d99fdf63ed21879da2a4d2cc6ad src/threaddesk/ui/static/networkpunkt-appbar-assets.json 6add7033bb8ec5f0efa0c09ddf136c892352f4536b14b774f1ae8dcd57ee9ba2 src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png 1074241a92275ae4b4a0d3ac00482ab9a253bcd5f60b569d96f84e95ae8ff336 src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png 13862406a14a212fd6e0646616be04604e77b9bb3a6225e8d18b470dae44225b src/threaddesk/ui/static/networkpunkt-appbar-bundle.json -59758e4259997b4ed953d7e61aede8e057f676a89ca44ddc8e01b0b793d37435 src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS +04d7c542aa8b326cd27fe9100fd83a573a3911dae9b58c60f8f9c131a1cc68e5 src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS diff --git a/docs/evidence/e05-portable-appbar-20261020/STATUS.md b/docs/evidence/e05-portable-appbar-20261020/STATUS.md index 0aa1748..da773c4 100644 --- a/docs/evidence/e05-portable-appbar-20261020/STATUS.md +++ b/docs/evidence/e05-portable-appbar-20261020/STATUS.md @@ -43,23 +43,25 @@ NP-Regeln/Einbindungsvertrag: docs/contracts/appbar-v1.md. ## Wirklich ausgeführte Prüfungen -- 32 reine Node-Zustandsfälle grün: unknown/connecting/disconnected/error, +- 35 reine Node-Zustandsfälle grün: unknown/connecting/disconnected/error, falsche Bindungen/Typen/TTL/Sequenzen/Generationen/Instanzen, Timeout/ Uhr-Rücksprung/Invalidate, AP4 allowed Nutzer/Tresor und separate Vault-TTL, Sperren/unknown, Ausfall/Wiederverbindung; KB/TD/TollGate nie Puls. -- 9 echte Chromium-Rendererfixturefälle grün: DE/EN, tatsächliche Tastatur/ +- 12 echte Chromium-Rendererfixturefälle grün: DE/EN, tatsächliche Tastatur/ Tooltip-Escape, Inflight- und schneller Doppelklick, Open setzt keinen Ring, unklarer Ausgang mit später Antwort, periodischer TTL-Tick ohne manuelle Aktualisierung, Remount genau ein Timer/Destroy null Timer, keine Wiederbelebung, fehlender Farbtoken neutral, feste Bounds auch bei320px und reduced motion. Nur Ring-Pseudoelement animiert12s/1.0..0.9; Originalbildopacity bleibt1. - Bundle-/Assetintegrität und17 vorhandene UI-Assetregeln grün. Gemeinsamer - pytest-Aufruf28/28; er enthält den Wrapper für die32 Nodefälle, keine + pytest-Aufruf31/31; er enthält den Wrapper für die35 Nodefälle, keine Doppelzählung als vollständige Produktabnahme. Keine neuen Dependencies. Kommando: `.venv/bin/python -m pytest -q tests/test_appbar.py tests/test_ui_assets.py`. -Private Logs im ursprünglichen Fortsetzungsordner: e05-pure-state.log und -e05-renderer-review-candidate.log. Die Browserfixture fängt ausschließlich +Aktuelle private Logs im ursprünglichen Fortsetzungsordner: +e05-author-regressions-green-node.log und e05-author-regressions-green.log. +Die ursprünglichen b36-Logs e05-pure-state.log und +e05-renderer-review-candidate.log bleiben als historische32/28-Prüfstände erhalten. Die Browserfixture fängt ausschließlich synthetische lokale Assetrequests ab, blockiert sonstiges Netz, startet einen eigenen temporären Browserkontext und beendet ihn. Keine echte TD-/AP-/KB- Verbindung, Nutzerkonten, Datenbank, Appstarts oder Userbrowserprofile beteiligt. @@ -77,3 +79,35 @@ KiraBridge-Icon, belegter AP4-Brandfarbwert, zugeordnete Icongrößenmeldung, KiraBridge-Ersteinbindung, echte abgesicherte Peer-/Status-/Openadapter, danach TD/AP-Hosts und deren echte Nutzer-/Nativewege. Diese Grenzen blockieren die reine Rendererprüfung nicht und werden durch sie nicht erledigt. + +## Korrektur nach unabhängigem b36-Review + +Der unabhängige Review von b36af8563f47f0d488d0f885a36b8ed99c33032b +fand zwei mittlere Zeitgrenzfehler und einen kleinen Tooltipfehler. Die alten +Quellen/Belege bleiben über refs/repairs/e05-reviewed-b36af85 und die privaten +Reviewdateien erhalten. Dieser Stand ist noch keine unabhängige Nachabnahme. + +- F1: Nach beobachteten14s konnte ein partieller Wanduhr-Rücksprung das Alter + auf1s verkürzen und Ring/Puls erhalten. Der gemeinsame je Vorgang verwendete + Elapsedsampler hält Alter nicht sinkend und lässt jeden beobachteten Rücksprung + von monotoner Uhr oder Wanduhr dauerhaft ablaufen. Pendingchecks und die vom + Checkbeginn übernommene Verbindung/Vault-Lease verwenden denselben Sampler. + Neue echte Antworten können erst über einen neuen Check wieder bestätigen. +- F2: Die Öffnungsdeadline verwendete zuvor nur die monotone Uhr. Wanduhr+5s + bei stehender monotoner Uhr akzeptierte dadurch einen verspäteten opened- + Ausgang. Jetzt gelten beide Uhren samt Rücksprungschutz für2s-Deadline und + 600ms-Dedupe. Ein unklarer Ausgang bleibt gesperrt, auch nach späterem Callback; + ausschließlich explizites clearOpen nach Hostprüfung ermöglicht die Erholung. +- N1: Tooltips werden mit fester äußerer Maximalbreite und DOM-Bounds beim + Anzeigen, Text- und Viewportwechsel horizontal geklemmt. DE/EN und beide + Einträge bleiben bei320px erreichbar; unveränderte Icon-/Ring-/Buttonmaße. + +Eigene neue Regressionen auf altem Produktstand: Node3rot/32grün und die fünf +gezielt gewählten Browserfälle rot. Nach Fix Node35/35, gesamter gezielter +Repo-Aufruf31/31. Darin12 echte Browserfälle und17 vorhandene UI-Assetregeln. +Zusätzlich dieselben unveränderten privaten Reviewerproben erneut ausgeführt: +Node5/5 und Chromium6/6. Dies sind Autor-Gegenproben, kein unabhängiger Review. +Private Logs: e05-author-regressions-red-node.log, -red-browser.log, +-green-node.log und -green.log; e05-reviewer-probes-green-node.log und +-green-browser.log. Unveränderte Reviewerprobendateien separat SHA256-erfasst +in e05-fix-reviewer-probe-SHA256SUMS. Keine Rechte-/Adapter-/Liveausweitung. diff --git a/docs/evidence/e05-portable-appbar-20261020/checks.json b/docs/evidence/e05-portable-appbar-20261020/checks.json index 20cd519..06d6e84 100644 --- a/docs/evidence/e05-portable-appbar-20261020/checks.json +++ b/docs/evidence/e05-portable-appbar-20261020/checks.json @@ -1,9 +1,9 @@ { "version": "1.0.0", "base": "b63fbd24743140fed68dc2a8eca7dcd9a7ca6c61", - "pureStateTestsPassed": 32, - "pytestTestsPassed": 28, - "realChromiumFixtureCases": 9, + "pureStateTestsPassed": 35, + "pytestTestsPassed": 31, + "realChromiumFixtureCases": 12, "existingUiAssetTestsPassed": 17, "browserFixtureUsesRealAppConnection": false, "hostIntegrationsMounted": [], @@ -15,5 +15,11 @@ "documentedAp4RingColorAssigned": false, "ap4FixtureColorIsSynthetic": true, "iconDimensionsUserAccepted": false, - "independentReview": "pending" + "independentReview": "b36 request_changes; bounded fixes awaiting independent re-review", + "authorReranUnchangedReviewerNodeCasesPassed": 5, + "authorReranUnchangedReviewerBrowserCasesPassed": 6, + "reviewedOriginalCommit": "b36af8563f47f0d488d0f885a36b8ed99c33032b", + "clockRollbackFailClosed": true, + "openDeadlineUsesWallAndMonotonic": true, + "tooltipViewportClamped": true } diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS index bcbd4b1..392f296 100644 --- a/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS +++ b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS @@ -1,8 +1,8 @@ 98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e networkpunkt.css -452497b9e08ce88937b2e9a598662da54d232b868e4c9d0eeaab0a96320bd693 networkpunkt-appbar.js -4c4ff0170d37a74167d52487018abb6499cefbfc4b322ebba62694d7468d4156 networkpunkt-appbar-state.js +6f7c2322c1428cd5b5de6b967ccf9e2d356b6a17f25184b7ce8946b16aa9c018 networkpunkt-appbar.js +c97ef3d092a2df9687629a2d877672b133df8aeda8175f3bfdf2a7698758d7f8 networkpunkt-appbar-state.js 2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 networkpunkt-appbar-catalog.js -6699dc6f1dcbea1c8ae063d6a39e9fea4345250d04ab89f98983e76d14b67700 networkpunkt-appbar.css +b8626e2dc7d0f34a4065fc217e11cd0b52ce1d4dea0df802b4a642e7fec57b46 networkpunkt-appbar.css e36c6bd46dbe6744efec07ce2ab73e1996208d99fdf63ed21879da2a4d2cc6ad networkpunkt-appbar-assets.json 6add7033bb8ec5f0efa0c09ddf136c892352f4536b14b774f1ae8dcd57ee9ba2 np-appbar-threaddesk-dark-48.png 1074241a92275ae4b4a0d3ac00482ab9a253bcd5f60b569d96f84e95ae8ff336 np-appbar-4allpass-dark-48.png diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-state.js b/src/threaddesk/ui/static/networkpunkt-appbar-state.js index c546416..d247198 100644 --- a/src/threaddesk/ui/static/networkpunkt-appbar-state.js +++ b/src/threaddesk/ui/static/networkpunkt-appbar-state.js @@ -7,6 +7,19 @@ const ttl = value => Number.isFinite(value) && value > 0 && value <= 15000; const keys = (value, allowed) => value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every(key => allowed.includes(key)); +// Per-operation age never gets younger; an observed clock rollback stays expired. +export function createElapsed(clock, wallClock) { + const monoStart = clock(), wallStart = wallClock(); + let lastMono = monoStart, lastWall = wallStart, elapsed = 0; + return () => { + const mono = clock(), wall = wallClock(); + elapsed = ![monoStart, wallStart, mono, wall].every(Number.isFinite) || mono < lastMono || wall < lastWall + ? Infinity : Math.max(elapsed, mono - monoStart, wall - wallStart); + lastMono = mono; lastWall = wall; + return elapsed; + }; +} + export function createPeerState(initial, { clock = () => performance.now(), wallClock = () => Date.now() } = {}) { let expected, generation = 0, serial = 0, pending = null, accepted = null, seq = -1, neutral = "unknown"; function reset(next = expected) { @@ -20,20 +33,16 @@ export function createPeerState(initial, { clock = () => performance.now(), wall generation++; pending = null; accepted = null; seq = -1; neutral = "unknown"; } reset(initial); - const age = started => { - const mono = clock() - started.mono, wall = wallClock() - started.wall; - return mono < 0 || wall < 0 ? Infinity : Math.max(mono, wall); - }; function snapshot() { - if (pending && age(pending) >= 2000) { + if (pending && pending.age() >= 2000) { pending = null; accepted = null; neutral = "unknown"; } - if (accepted && age(accepted.started) >= accepted.ttlMs) { + if (accepted && accepted.age() >= accepted.ttlMs) { accepted = null; neutral = "unknown"; } if (!accepted) return { state: neutral, vault: "unknown", pulse: false }; const state = accepted.state; - const vault = state === "confirmed" && accepted.vault && age(accepted.started) < accepted.vault.ttlMs + const vault = state === "confirmed" && accepted.vault && accepted.age() < accepted.vault.ttlMs ? accepted.vault.state : "unknown"; return { state, vault, pulse: expected.appId === "4allpass" && state === "confirmed" && vault === "unlocked" }; } @@ -42,7 +51,7 @@ export function createPeerState(initial, { clock = () => performance.now(), wall if (pending) return pending.ticket; // at most one outstanding check per peer if (accepted?.state !== "confirmed") accepted = null; const ticket = Object.freeze({ generation, check: ++serial }); - pending = { ticket, mono: clock(), wall: wallClock() }; + pending = { ticket, age: createElapsed(clock, wallClock) }; if (!accepted) neutral = "connecting"; return ticket; } @@ -58,7 +67,7 @@ export function createPeerState(initial, { clock = () => performance.now(), wall } function receive(ticket, value) { if (!current(ticket)) return false; - const started = { mono: pending.mono, wall: pending.wall }; + const age = pending.age; const valid = keys(value, ["v", "appId", "connectionRef", "peerRef", "peerEpoch", "seq", "state", "ttlMs", "vault"]) && value.v === 1 && value.appId === expected.appId && value.connectionRef === expected.connectionRef && value.peerRef === expected.peerRef && value.peerEpoch === expected.peerEpoch @@ -74,7 +83,7 @@ export function createPeerState(initial, { clock = () => performance.now(), wall } } seq = value.seq; pending = null; - accepted = { state: value.state, ttlMs: value.ttlMs, started, vault }; + accepted = { state: value.state, ttlMs: value.ttlMs, age, vault }; return true; } function invalidate() { generation++; pending = null; accepted = null; neutral = "unknown"; } diff --git a/src/threaddesk/ui/static/networkpunkt-appbar.css b/src/threaddesk/ui/static/networkpunkt-appbar.css index a44987a..dbc1b3e 100644 --- a/src/threaddesk/ui/static/networkpunkt-appbar.css +++ b/src/threaddesk/ui/static/networkpunkt-appbar.css @@ -13,7 +13,7 @@ @keyframes np-appbar-unlocked { 0%,100% { opacity:1; } 50% { opacity:.9; } } .np-appbar-button[data-error="true"] { outline:1px solid var(--np-danger,#f49b9b); outline-offset:-1px; } .np-appbar-button[data-error="true"]:focus-visible { outline:2px solid var(--np-focus,#edf0f4); outline-offset:1px; } -.np-appbar-tooltip { position:absolute; top:100%; left:0; z-index:20; width:max-content; max-width:280px; padding:var(--np-space-2,8px); background:var(--np-surface,#1a1c20); border:1px solid var(--np-control-border,#808996); pointer-events:none; } +.np-appbar-tooltip { position:absolute; top:100%; left:0; z-index:20; box-sizing:border-box; width:max-content; max-width:min(280px,calc(100vw - 8px)); overflow-wrap:anywhere; padding:var(--np-space-2,8px); background:var(--np-surface,#1a1c20); border:1px solid var(--np-control-border,#808996); pointer-events:none; } .np-appbar [hidden] { display:none; } .np-appbar-sr { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } @media (prefers-reduced-motion:reduce) { .np-appbar-ring[data-pulse="true"]::before { animation:none; } } diff --git a/src/threaddesk/ui/static/networkpunkt-appbar.js b/src/threaddesk/ui/static/networkpunkt-appbar.js index e5b3a63..a520ede 100644 --- a/src/threaddesk/ui/static/networkpunkt-appbar.js +++ b/src/threaddesk/ui/static/networkpunkt-appbar.js @@ -1,5 +1,5 @@ /* NetzwerkPunkt appbar 1.0.0 — portable DOM renderer, no transport or host launch. */ -import { APPBAR_VERSION, APP_ORDER, createPeerState } from "./networkpunkt-appbar-state.js"; +import { APPBAR_VERSION, APP_ORDER, createElapsed, createPeerState } from "./networkpunkt-appbar-state.js"; import { APP_CATALOG } from "./networkpunkt-appbar-catalog.js"; export { APPBAR_VERSION }; const mounts = new WeakMap(); @@ -61,16 +61,23 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors const tooltip = doc.createElement("span"); tooltip.className = "np-appbar-tooltip"; tooltip.id = `np-appbar-tip-${mountId}-${entry.id}`; tooltip.setAttribute("role", "tooltip"); tooltip.hidden = true; button.setAttribute("aria-describedby", tooltip.id); - const show = () => { tooltip.hidden = false; }; + const show = () => { tooltip.hidden = false; positionTooltip(tooltip); }; const hide = () => { tooltip.hidden = true; }; button.addEventListener("focus", show); button.addEventListener("blur", hide); button.addEventListener("mouseenter", show); wrapper.addEventListener("mouseleave", hide); button.addEventListener("keydown", event => { if (event.key === "Escape") { hide(); event.preventDefault(); } }); - const item = { entry, peer, color, wrapper, button, ring, tooltip, open: null, lastOpen: -Infinity, abort: null, serial: 0 }; + const item = { entry, peer, color, wrapper, button, ring, tooltip, open: null, lastOpen: null, abort: null, serial: 0 }; button.addEventListener("click", () => open(item)); wrapper.append(button, tooltip); root.append(wrapper); items.set(entry.id, item); } root.append(live); element.append(root); + function positionTooltip(tooltip) { + if (tooltip.hidden) return; + tooltip.style.left = "0px"; + const bounds = tooltip.getBoundingClientRect(), width = doc.documentElement.clientWidth; + const left = Math.max(4, Math.min(bounds.left, width - bounds.width - 4)); + tooltip.style.left = `${left - bounds.left}px`; + } function paint() { if (destroyed) return; const text = COPY[language], announcements = []; @@ -78,7 +85,7 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors for (const item of items.values()) { const observed = item.peer?.snapshot(); const state = item.color && observed ? observed : { state: "unknown", vault: "unknown", pulse: false }; - if (item.open?.state === "opening" && clock() - item.open.started >= 2000) { + if (item.open?.state === "opening" && item.open.age() >= 2000) { item.open.state = "uncertain"; item.abort?.abort(); } item.ring.dataset.connected = String(state.state === "confirmed"); @@ -91,6 +98,7 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors else if (item.open && !(["opened", "focused"].includes(item.open.state) && state.state === "confirmed")) status += ` · ${text[item.open.state]}`; const label = `${item.entry.name[language]} · ${status}`; item.tooltip.textContent = label; + positionTooltip(item.tooltip); item.button.setAttribute("aria-label", `${label} · ${text.open}`); item.button.setAttribute("aria-disabled", String(typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state))); item.button.dataset.error = String(state.state === "error" || ["failed", "uncertain"].includes(item.open?.state)); @@ -101,10 +109,10 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors } async function open(item) { paint(); - if (destroyed || typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state) || clock() - item.lastOpen < 600) return; - item.lastOpen = clock(); item.abort = new AbortController(); + if (destroyed || typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state) || (item.lastOpen && item.lastOpen() < 600)) return; + item.lastOpen = createElapsed(clock, wallClock); item.abort = new AbortController(); const actionId = `np-appbar-${Date.now()}-${mountId}-${++item.serial}`; - item.open = { state: "opening", started: clock(), actionId }; paint(); + item.open = { state: "opening", age: item.lastOpen, actionId }; paint(); try { const result = await onOpen({ appId: item.entry.id, actionId, signal: item.abort.signal }); paint(); // enforce the deadline before accepting a late completion @@ -115,6 +123,7 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors } function invalidate() { for (const item of items.values()) item.peer?.invalidate(); paint(); } doc.addEventListener("visibilitychange", invalidate); win.addEventListener("pagehide", invalidate); + win.addEventListener("resize", paint); const timer = win.setInterval(paint, 250); const api = Object.freeze({ begin(appId) { if (destroyed) return null; const ticket = items.get(appId)?.peer?.begin() ?? null; paint(); return ticket; }, @@ -127,6 +136,7 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors setLocale(value) { language = value === "en" ? "en" : "de"; paint(); }, refresh: paint, destroy() { if (destroyed) return; destroyed = true; win.clearInterval(timer); doc.removeEventListener("visibilitychange", invalidate); + win.removeEventListener("resize", paint); win.removeEventListener("pagehide", invalidate); for (const item of items.values()) { item.peer?.invalidate(); item.abort?.abort(); } root.remove(); mounts.delete(element); }, }); diff --git a/tests/appbar_state.test.mjs b/tests/appbar_state.test.mjs index 70e9661..12a58b3 100644 --- a/tests/appbar_state.test.mjs +++ b/tests/appbar_state.test.mjs @@ -10,7 +10,7 @@ function setup(appId="4allpass") { const peer=createPeerState({ ...expected, appId }, { clock:()=>time, wallClock:()=>wall }); const observation = patch => ({ v:1,appId,connectionRef:"fixture-link",peerRef:"fixture-peer",peerEpoch:"epoch-1", seq:0,state:"confirmed",ttlMs:15000,...patch }); - return {peer, observation, advance(ms){time+=ms;wall+=ms;}, rollback(){wall--;}}; + return {peer, observation, advance(ms){time+=ms;wall+=ms;}, rollback(){wall--;}, set(m,w){time=m;wall=w;}}; } const unlocked = {userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:1000}; test("unknown and connecting are distinct; only a fresh matching reply confirms",()=>{ @@ -61,3 +61,21 @@ test("failure and explicit disconnect remove ring; reconnect needs another accep x.peer.begin();assert.equal(x.peer.snapshot().state,"connecting"); x.peer.receive(x.peer.begin(),x.observation({seq:2}));assert.equal(x.peer.snapshot().state,"confirmed"); }); +test("observed partial wall rollback invalidates an old ring and unlock lease",()=>{ + const x=setup();x.peer.receive(x.peer.begin(),x.observation({vault:{...unlocked,ttlMs:15000}})); + x.set(100,114000);assert.equal(x.peer.snapshot().pulse,true); + x.set(200,101000);assert.deepEqual(x.peer.snapshot(),{state:"unknown",vault:"unknown",pulse:false}); + x.set(300,114100);assert.equal(x.peer.snapshot().state,"unknown"); + assert(x.peer.receive(x.peer.begin(),x.observation({seq:1}))); + assert.equal(x.peer.snapshot().state,"confirmed"); +}); +test("observed monotonic rollback also invalidates a lease above its starting clock",()=>{ + const x=setup();x.peer.receive(x.peer.begin(),x.observation()); + x.set(1000,100100);assert.equal(x.peer.snapshot().state,"confirmed"); + x.set(100,100200);assert.equal(x.peer.snapshot().state,"unknown"); +}); +test("a partial rollback during a pending check rejects its reply",()=>{ + const x=setup(),t=x.peer.begin();x.set(100,101000);assert.equal(x.peer.snapshot().state,"connecting"); + x.set(200,100500);assert.equal(x.peer.receive(t,x.observation()),false); + assert.equal(x.peer.snapshot().state,"unknown"); +}); diff --git a/tests/test_appbar.py b/tests/test_appbar.py index 7418a15..d486290 100644 --- a/tests/test_appbar.py +++ b/tests/test_appbar.py @@ -142,6 +142,34 @@ def test_fast_success_double_click_still_opens_once(appbar): assert ring(page).get_attribute("data-connected") == "false" +def test_partial_clock_rollback_removes_rendered_ring_and_unlock_pulse(appbar): + page = appbar + page.evaluate('check("4allpass", {vault:{userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:15000}})') + page.evaluate("time=100;wall=114000;bar.refresh()") + assert ring(page).get_attribute("data-pulse") == "true" + page.evaluate("time=200;wall=101000;bar.refresh()") + assert ring(page).get_attribute("data-connected") == "false" + assert ring(page).get_attribute("data-pulse") == "false" + + +@pytest.mark.parametrize("clock_change", ["wall+=5000", "time=100;wall+=1000;bar.refresh();time=200;wall-=500"]) +def test_open_clock_stall_or_rollback_stays_uncertain_until_host_recovery(appbar, clock_change): + page = appbar + button = page.locator('[data-app-id="4allpass"]') + button.click() + page.evaluate(f'{clock_change};bar.refresh();resolvers[0]("opened")') + page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"]").getAttribute("aria-label").includes("Öffnung unklar")', timeout=1000) + page.evaluate("advance(700)") + button.dispatch_event("click") + assert page.evaluate("openCalls.length") == 1 + assert button.get_attribute("aria-disabled") == "true" + page.evaluate('bar.clearOpen("4allpass")') + button.click() + assert page.evaluate("openCalls.length") == 2 + page.evaluate('resolvers[1]("focused")') + page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"]").getAttribute("aria-disabled") === "false"') + + def test_missing_approved_tool_color_keeps_default_catalog_neutral(appbar): page = appbar page.evaluate('options.colors={};mountAgain();check("4allpass", {vault:{userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:1000}})') @@ -165,3 +193,13 @@ def test_fixed_icon_geometry_and_only_ring_motion(appbar, width): page.emulate_media(reduced_motion="reduce") assert ring(page).evaluate('e => getComputedStyle(e,"::before").animationName') == "none" assert "Tresor entsperrt" in page.locator('[data-app-id="4allpass"]').get_attribute("aria-label") + for locale in ["de", "en"]: + page.evaluate("locale => bar.setLocale(locale)", locale) + for app in ["threaddesk", "4allpass"]: + button = page.locator(f'[data-app-id="{app}"]') + button.focus() + box = page.locator(f'#{button.get_attribute("aria-describedby")}').bounding_box() + assert box["x"] >= 0 and box["x"] + box["width"] <= width + page.set_viewport_size({"width": 320, "height": 760}) + box = page.locator(f'#{button.get_attribute("aria-describedby")}').bounding_box() + assert box["x"] >= 0 and box["x"] + box["width"] <= 320 From 168c54cf136b613f6223e852fce9cb39a00a9760 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:54:48 +0200 Subject: [PATCH 3/4] fix(ui): fail closed on invalid appbar open cooldown --- docs/TEAMPLAN.md | 5 +++ docs/contracts/appbar-v1.md | 6 +++- .../e05-portable-appbar-20261020/SHA256SUMS | 4 +-- .../e05-portable-appbar-20261020/STATUS.md | 33 +++++++++++++++++-- .../e05-portable-appbar-20261020/checks.json | 13 +++++--- .../ui/static/networkpunkt-appbar-SHA256SUMS | 2 +- .../ui/static/networkpunkt-appbar.js | 14 ++++++-- tests/test_appbar.py | 22 +++++++++++++ 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docs/TEAMPLAN.md b/docs/TEAMPLAN.md index 244a1ae..053dd5e 100644 --- a/docs/TEAMPLAN.md +++ b/docs/TEAMPLAN.md @@ -274,3 +274,8 @@ Uhren und hält unklare Starts bis Hostprüfung gesperrt. Tooltips bleiben auch im schmalen Ausschnitt sichtbar.35 Nodefälle/31 gezielte Repo-Prüfungen grün, unveränderte Reviewerproben vom Autor5 Node/6 Browser grün; unabhängige Nachabnahme folgt, echte Verbindungen/Hostintegration weiterhin offen. + +E05-F3-Nachbesserung: Ein ungültiges Uhralter erfüllt die600ms-Mindestwartezeit +nicht; nach Hostprüfung beginnt sie vollständig neu.32 gezielte Repofälle +grün, unveränderte Reviewerproben vom Autor5 Node/7 Browser grün; unabhängige +Nachprüfung und alle echten Host-/Verbindungsabnahmen bleiben getrennt offen. diff --git a/docs/contracts/appbar-v1.md b/docs/contracts/appbar-v1.md index fbabaae..b17cb99 100644 --- a/docs/contracts/appbar-v1.md +++ b/docs/contracts/appbar-v1.md @@ -153,7 +153,11 @@ Wanduhrfortschritt bei stehender monotoner Uhr oder beobachteter Rücksprung können einen verspäteten Erfolg nicht nachträglich akzeptieren; unklarer Ausgang bleibt gegen weiteren Start gesperrt und hat keinen automatischen Retry. Erst nach eigener Zielprüfung darf der Host -`clearOpen(appId)` aufrufen. Späte Ergebnisse ersetzen keinen neueren Vorgang. +`clearOpen(appId)` aufrufen. Ein ungültiger Uhrwert erfüllt niemals die +600ms-Mindestwartezeit. Ein Rücksprung während dieser Wartezeit sperrt auch +nach einem sofortigen opened-Ergebnis; nach Hostprüfung beginnt eine neue volle +600ms-Wartezeit. Erst ein endliches geprüftes Alter darf die Wartezeit beenden. +Späte Ergebnisse ersetzen keinen neueren Vorgang. Destroy signalisiert Abbruch; ein unkooperativer Adapter kann nicht vom Renderer zwangsweise beendet werden, verändert aber dessen DOM nicht mehr. Fehlendes Ziel/Fehler führen zu festen DE/EN-Handlungen, nie rohen Responses. diff --git a/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS index 064106e..bce7167 100644 --- a/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS +++ b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS @@ -1,5 +1,5 @@ 98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e src/threaddesk/ui/static/networkpunkt.css -6f7c2322c1428cd5b5de6b967ccf9e2d356b6a17f25184b7ce8946b16aa9c018 src/threaddesk/ui/static/networkpunkt-appbar.js +7a4ef4699cf5e07f84f57279f86f73b9dbd85e1e9272e4329a5e430b977ad60d src/threaddesk/ui/static/networkpunkt-appbar.js c97ef3d092a2df9687629a2d877672b133df8aeda8175f3bfdf2a7698758d7f8 src/threaddesk/ui/static/networkpunkt-appbar-state.js 2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 src/threaddesk/ui/static/networkpunkt-appbar-catalog.js b8626e2dc7d0f34a4065fc217e11cd0b52ce1d4dea0df802b4a642e7fec57b46 src/threaddesk/ui/static/networkpunkt-appbar.css @@ -7,4 +7,4 @@ e36c6bd46dbe6744efec07ce2ab73e1996208d99fdf63ed21879da2a4d2cc6ad src/threaddesk 6add7033bb8ec5f0efa0c09ddf136c892352f4536b14b774f1ae8dcd57ee9ba2 src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png 1074241a92275ae4b4a0d3ac00482ab9a253bcd5f60b569d96f84e95ae8ff336 src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png 13862406a14a212fd6e0646616be04604e77b9bb3a6225e8d18b470dae44225b src/threaddesk/ui/static/networkpunkt-appbar-bundle.json -04d7c542aa8b326cd27fe9100fd83a573a3911dae9b58c60f8f9c131a1cc68e5 src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS +a2a174ff869340edd65024824b45fe8fe228909dbb84aee29fb82fd474fdc67e src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS diff --git a/docs/evidence/e05-portable-appbar-20261020/STATUS.md b/docs/evidence/e05-portable-appbar-20261020/STATUS.md index da773c4..a44ce52 100644 --- a/docs/evidence/e05-portable-appbar-20261020/STATUS.md +++ b/docs/evidence/e05-portable-appbar-20261020/STATUS.md @@ -47,19 +47,21 @@ NP-Regeln/Einbindungsvertrag: docs/contracts/appbar-v1.md. falsche Bindungen/Typen/TTL/Sequenzen/Generationen/Instanzen, Timeout/ Uhr-Rücksprung/Invalidate, AP4 allowed Nutzer/Tresor und separate Vault-TTL, Sperren/unknown, Ausfall/Wiederverbindung; KB/TD/TollGate nie Puls. -- 12 echte Chromium-Rendererfixturefälle grün: DE/EN, tatsächliche Tastatur/ +- 13 echte Chromium-Rendererfixturefälle grün: DE/EN, tatsächliche Tastatur/ Tooltip-Escape, Inflight- und schneller Doppelklick, Open setzt keinen Ring, unklarer Ausgang mit später Antwort, periodischer TTL-Tick ohne manuelle Aktualisierung, Remount genau ein Timer/Destroy null Timer, keine Wiederbelebung, fehlender Farbtoken neutral, feste Bounds auch bei320px und reduced motion. Nur Ring-Pseudoelement animiert12s/1.0..0.9; Originalbildopacity bleibt1. - Bundle-/Assetintegrität und17 vorhandene UI-Assetregeln grün. Gemeinsamer - pytest-Aufruf31/31; er enthält den Wrapper für die35 Nodefälle, keine + pytest-Aufruf32/32; er enthält den Wrapper für die35 Nodefälle, keine Doppelzählung als vollständige Produktabnahme. Keine neuen Dependencies. Kommando: `.venv/bin/python -m pytest -q tests/test_appbar.py tests/test_ui_assets.py`. Aktuelle private Logs im ursprünglichen Fortsetzungsordner: -e05-author-regressions-green-node.log und e05-author-regressions-green.log. +e05-f3-author-green.log (enthält den35-Node-Wrapper). +Die vorigen Fixläufe bleiben als e05-author-regressions-green-node.log und +e05-author-regressions-green.log erhalten. Die ursprünglichen b36-Logs e05-pure-state.log und e05-renderer-review-candidate.log bleiben als historische32/28-Prüfstände erhalten. Die Browserfixture fängt ausschließlich synthetische lokale Assetrequests ab, blockiert sonstiges Netz, startet einen @@ -111,3 +113,28 @@ Private Logs: e05-author-regressions-red-node.log, -red-browser.log, -green-node.log und -green.log; e05-reviewer-probes-green-node.log und -green-browser.log. Unveränderte Reviewerprobendateien separat SHA256-erfasst in e05-fix-reviewer-probe-SHA256SUMS. Keine Rechte-/Adapter-/Liveausweitung. + +## Enge F3-Nachbesserung nach ef286-Nachreview + +Der unabhängige Nachreview schloss F1/F2/N1, fand aber einen neuen mittleren +Fehler beim Verwenden desselben Samplers für die Mindestwartezeit: Nach einem +sofortigen opened und Wanduhr-Rücksprung bei mono+100ms lieferte age Infinity; +Infinity<600 war false und erlaubte einen zweiten Start. Der alte Stand +ef286c2edd60131dbb541a2280f252f0223f1dc7 bleibt unter +refs/repairs/e05-reviewed-ef286c2 sowie den bisherigen Logs erhalten. + +Jetzt endet die aktive600ms-Wartezeit ausschließlich nach endlichem geprüftem +Alter>=600. Ein ungültiger Wert sperrt uncertain, auch nach sofortigem Erfolg; +Zeitfortschritt allein entsperrt nicht. Nach expliziter Hostprüfung/clearOpen +beginnt bei ungültiger Messung eine volle neue600ms-Wartezeit. Die2s-Deadline +bleibt unverändert fail closed. Keine Adapterrechte oder Liveflächen geändert. + +Neue eigene echte Browserregression am unveränderten ef286:1rot (zweiter Start +nach100ms). Nach Fix gezielter Repo-Aufruf32/32 grün, darin35 reine Nodefälle +als ein Wrapper,13 echte Browserfälle,17 bestehende UI-Assetfälle und Hashes. +Die neue Regression prüft auch kein Blind-Retry nach10s sowie599/600ms nach +expliziter Recovery. Unveränderte Reviewerproben vom Autor erneut ausgeführt: +5 Node-/7 Browserfälle grün einschließlich test_cooldown_rollback.py. Keine +Änderung der privaten Reviewerfälle. Logs: e05-f3-author-red.log, +e05-f3-author-green.log, e05-f3-reviewer-node-green.log und +e05-f3-reviewer-browser-green.log. Unabhängige Nachprüfung bleibt offen. diff --git a/docs/evidence/e05-portable-appbar-20261020/checks.json b/docs/evidence/e05-portable-appbar-20261020/checks.json index 06d6e84..c181b03 100644 --- a/docs/evidence/e05-portable-appbar-20261020/checks.json +++ b/docs/evidence/e05-portable-appbar-20261020/checks.json @@ -2,8 +2,8 @@ "version": "1.0.0", "base": "b63fbd24743140fed68dc2a8eca7dcd9a7ca6c61", "pureStateTestsPassed": 35, - "pytestTestsPassed": 31, - "realChromiumFixtureCases": 12, + "pytestTestsPassed": 32, + "realChromiumFixtureCases": 13, "existingUiAssetTestsPassed": 17, "browserFixtureUsesRealAppConnection": false, "hostIntegrationsMounted": [], @@ -15,11 +15,14 @@ "documentedAp4RingColorAssigned": false, "ap4FixtureColorIsSynthetic": true, "iconDimensionsUserAccepted": false, - "independentReview": "b36 request_changes; bounded fixes awaiting independent re-review", + "independentReview": "ef286 F1/F2/N1 closed; bounded F3 cooldown fix awaiting independent re-review", "authorReranUnchangedReviewerNodeCasesPassed": 5, - "authorReranUnchangedReviewerBrowserCasesPassed": 6, + "authorReranUnchangedReviewerBrowserCasesPassed": 7, "reviewedOriginalCommit": "b36af8563f47f0d488d0f885a36b8ed99c33032b", "clockRollbackFailClosed": true, "openDeadlineUsesWallAndMonotonic": true, - "tooltipViewportClamped": true + "tooltipViewportClamped": true, + "previousReviewCommit": "ef286c2edd60131dbb541a2280f252f0223f1dc7", + "invalidCooldownDoesNotSatisfyMinimum": true, + "cooldownRecoveryRestartsFull600Ms": true } diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS index 392f296..0a53785 100644 --- a/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS +++ b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS @@ -1,5 +1,5 @@ 98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e networkpunkt.css -6f7c2322c1428cd5b5de6b967ccf9e2d356b6a17f25184b7ce8946b16aa9c018 networkpunkt-appbar.js +7a4ef4699cf5e07f84f57279f86f73b9dbd85e1e9272e4329a5e430b977ad60d networkpunkt-appbar.js c97ef3d092a2df9687629a2d877672b133df8aeda8175f3bfdf2a7698758d7f8 networkpunkt-appbar-state.js 2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 networkpunkt-appbar-catalog.js b8626e2dc7d0f34a4065fc217e11cd0b52ce1d4dea0df802b4a642e7fec57b46 networkpunkt-appbar.css diff --git a/src/threaddesk/ui/static/networkpunkt-appbar.js b/src/threaddesk/ui/static/networkpunkt-appbar.js index a520ede..1d0d3e5 100644 --- a/src/threaddesk/ui/static/networkpunkt-appbar.js +++ b/src/threaddesk/ui/static/networkpunkt-appbar.js @@ -85,6 +85,11 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors for (const item of items.values()) { const observed = item.peer?.snapshot(); const state = item.color && observed ? observed : { state: "unknown", vault: "unknown", pulse: false }; + const cooldown = item.lastOpen?.(); + if (cooldown !== undefined && !Number.isFinite(cooldown)) { + if (item.open) item.open.state = "uncertain"; + item.abort?.abort(); + } else if (cooldown >= 600) item.lastOpen = null; if (item.open?.state === "opening" && item.open.age() >= 2000) { item.open.state = "uncertain"; item.abort?.abort(); } @@ -109,7 +114,7 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors } async function open(item) { paint(); - if (destroyed || typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state) || (item.lastOpen && item.lastOpen() < 600)) return; + if (destroyed || typeof onOpen !== "function" || ["opening", "uncertain"].includes(item.open?.state) || item.lastOpen) return; item.lastOpen = createElapsed(clock, wallClock); item.abort = new AbortController(); const actionId = `np-appbar-${Date.now()}-${mountId}-${++item.serial}`; item.open = { state: "opening", age: item.lastOpen, actionId }; paint(); @@ -132,7 +137,12 @@ export function mountAppBar(element, { catalog = APP_CATALOG, peers = {}, colors reset(appId, expected) { if (destroyed) return; if (expected.appId !== appId) throw new TypeError("Peer app mismatch"); const item = items.get(appId); if (item) { if (item.peer) item.peer.reset(expected); else item.peer = createPeerState(expected, {clock,wallClock}); } paint(); }, // Only after the host checked an unknown opening outcome; never automatic retry. - clearOpen(appId) { const item = items.get(appId); if (item) { item.abort?.abort(); item.open = null; paint(); } }, + clearOpen(appId) { const item = items.get(appId); if (item) { + item.abort?.abort(); + // Invalid time cannot satisfy a minimum wait: host recovery starts it anew. + if (item.lastOpen && !Number.isFinite(item.lastOpen())) item.lastOpen = createElapsed(clock, wallClock); + item.open = null; paint(); + } }, setLocale(value) { language = value === "en" ? "en" : "de"; paint(); }, refresh: paint, destroy() { if (destroyed) return; destroyed = true; win.clearInterval(timer); doc.removeEventListener("visibilitychange", invalidate); diff --git a/tests/test_appbar.py b/tests/test_appbar.py index d486290..bc136bb 100644 --- a/tests/test_appbar.py +++ b/tests/test_appbar.py @@ -164,12 +164,34 @@ def test_open_clock_stall_or_rollback_stays_uncertain_until_host_recovery(appbar assert page.evaluate("openCalls.length") == 1 assert button.get_attribute("aria-disabled") == "true" page.evaluate('bar.clearOpen("4allpass")') + page.evaluate("advance(600)") button.click() assert page.evaluate("openCalls.length") == 2 page.evaluate('resolvers[1]("focused")') page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"]").getAttribute("aria-disabled") === "false"') +def test_success_cooldown_clock_rollback_needs_host_recovery_and_new_full_cooldown(appbar): + page = appbar + page.evaluate('options.onOpen=async args=>{openCalls.push({appId:args.appId,actionId:args.actionId});return {actionId:args.actionId,outcome:"opened"};};mountAgain()') + button = page.locator('[data-app-id="4allpass"]') + button.click() + page.wait_for_function('document.querySelector("[data-app-id=\\"4allpass\\"]").getAttribute("aria-disabled") === "false"') + page.evaluate("time=100;wall=99900;bar.refresh()") + button.dispatch_event("click") + assert page.evaluate("openCalls.length") == 1 + assert "Öffnung unklar" in button.get_attribute("aria-label") + page.evaluate("advance(10000)") + button.dispatch_event("click") + assert page.evaluate("openCalls.length") == 1 + page.evaluate('bar.clearOpen("4allpass");advance(599)') + button.dispatch_event("click") + assert page.evaluate("openCalls.length") == 1 + page.evaluate("advance(1)") + button.click() + assert page.evaluate("openCalls.length") == 2 + + def test_missing_approved_tool_color_keeps_default_catalog_neutral(appbar): page = appbar page.evaluate('options.colors={};mountAgain();check("4allpass", {vault:{userRef:"fixture-user",vaultRef:"fixture-vault",allowed:true,state:"unlocked",ttlMs:1000}})') From 968620f2914d77e3ccfb5154e8d70ee687416825 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:10:00 +0200 Subject: [PATCH 4/4] test(ui): await tooltip resize and preserve bundle LF bytes --- .gitattributes | 1 + .../e05-portable-appbar-20261020/STATUS.md | 25 +++++++++++++++++++ .../e05-portable-appbar-20261020/checks.json | 9 +++++-- tests/test_appbar.py | 4 +++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index b1585fe..1f8b7b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,7 @@ src/threaddesk/ui/static/alpine.min.js -text src/threaddesk/ui/static/ui-vendor.json text eol=lf src/threaddesk/ui/static/UI-VENDOR-LICENSES.txt text eol=lf # Portable appbar copies pin exact source/asset bytes across host repositories. +src/threaddesk/ui/static/networkpunkt.css text eol=lf src/threaddesk/ui/static/networkpunkt-appbar*.js text eol=lf src/threaddesk/ui/static/networkpunkt-appbar*.css text eol=lf src/threaddesk/ui/static/networkpunkt-appbar*.json text eol=lf diff --git a/docs/evidence/e05-portable-appbar-20261020/STATUS.md b/docs/evidence/e05-portable-appbar-20261020/STATUS.md index a44ce52..8813846 100644 --- a/docs/evidence/e05-portable-appbar-20261020/STATUS.md +++ b/docs/evidence/e05-portable-appbar-20261020/STATUS.md @@ -138,3 +138,28 @@ expliziter Recovery. Unveränderte Reviewerproben vom Autor erneut ausgeführt: Änderung der privaten Reviewerfälle. Logs: e05-f3-author-red.log, e05-f3-author-green.log, e05-f3-reviewer-node-green.log und e05-f3-reviewer-browser-green.log. Unabhängige Nachprüfung bleibt offen. + +## Abschließende Prüfbarriere und lokale Byteportabilität + +Der unabhängige d016-Nachreview schließt F1/F2/N1/F3 mit unveränderten +Zustands-/Browserproben. Seine kombinierte Prüfung zeigte eine Race im neuen +Resize-Test: set_viewport_size garantiert noch keine ausgeführte Neupositionierung. +Der Test wartet jetzt konkret auf die erwarteten Tooltip-Bounds im320px- +Viewport. Keine feste Pause, höhere Timeoutgrenze oder Runtimeänderung. +Beide betroffenen Repo-Varianten frisch2/2 grün. + +Die bestehende mitgehashte networkpunkt.css war trotz LF im Gitobjekt unter +core.autocrlf=true nicht vor CRLF-Konvertierung geschützt. Genau diese Datei +erhält zusätzlich text eol=lf. Alle übrigen im Bundle gehashten Textdateien +sind bereits explizit LF, PNGs bleiben -text. Ein eigener isolierter +checkout-index-Export mit separatem Index, core.autocrlf=true und deaktivierter +globaler Attributesdatei bestätigt alle9 Bundlehashes sowie Bytegleichheit +mit den aktuellen Quellen; networkpunkt.css bleibt ohne CRLF. Keine Änderung +an NP-Inhalt, Markenfarben, Größen oder Runtime. Dies ist Git-Exportprüfung +auf macOS, keine native Windows-Abnahme. + +Private Belege: e05-resize-barrier-delta.log und +e05-autocrlf-fixed-u04iqcom/results.json/attributes.txt. +Runtime/Assetbytes bleiben d016be94681c368e7f3db9f71bc62d350855d6b8-identisch; +Basis wird auf main b32eef3 mit dem gesonderten CSS-Navigationstestfix gesetzt. +Unabhängiger finaler Source-/Delta-Recheck folgt; keine Liveintegration. diff --git a/docs/evidence/e05-portable-appbar-20261020/checks.json b/docs/evidence/e05-portable-appbar-20261020/checks.json index c181b03..6209c30 100644 --- a/docs/evidence/e05-portable-appbar-20261020/checks.json +++ b/docs/evidence/e05-portable-appbar-20261020/checks.json @@ -15,7 +15,7 @@ "documentedAp4RingColorAssigned": false, "ap4FixtureColorIsSynthetic": true, "iconDimensionsUserAccepted": false, - "independentReview": "ef286 F1/F2/N1 closed; bounded F3 cooldown fix awaiting independent re-review", + "independentReview": "d016 F1/F2/N1/F3 closed; resize-test/LF-attribute correction awaiting final recheck", "authorReranUnchangedReviewerNodeCasesPassed": 5, "authorReranUnchangedReviewerBrowserCasesPassed": 7, "reviewedOriginalCommit": "b36af8563f47f0d488d0f885a36b8ed99c33032b", @@ -24,5 +24,10 @@ "tooltipViewportClamped": true, "previousReviewCommit": "ef286c2edd60131dbb541a2280f252f0223f1dc7", "invalidCooldownDoesNotSatisfyMinimum": true, - "cooldownRecoveryRestartsFull600Ms": true + "cooldownRecoveryRestartsFull600Ms": true, + "runtimeIdenticalTo": "d016be94681c368e7f3db9f71bc62d350855d6b8", + "resizeDeltaCasesPassed": 2, + "bundleAutocrlfExportMatches": true, + "bundleTextAttributesLf": true, + "autocrlfExportIsWindowsNativeProof": false } diff --git a/tests/test_appbar.py b/tests/test_appbar.py index bc136bb..371afa4 100644 --- a/tests/test_appbar.py +++ b/tests/test_appbar.py @@ -223,5 +223,9 @@ def test_fixed_icon_geometry_and_only_ring_motion(appbar, width): box = page.locator(f'#{button.get_attribute("aria-describedby")}').bounding_box() assert box["x"] >= 0 and box["x"] + box["width"] <= width page.set_viewport_size({"width": 320, "height": 760}) + page.wait_for_function("""id => { + const box = document.getElementById(id).getBoundingClientRect(); + return box.left >= 0 && box.right <= 320; + }""", arg=button.get_attribute("aria-describedby")) box = page.locator(f'#{button.get_attribute("aria-describedby")}').bounding_box() assert box["x"] >= 0 and box["x"] + box["width"] <= 320