diff --git a/.gitattributes b/.gitattributes index 3c17acd..1f8b7b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,10 @@ 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.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 +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..053dd5e 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,25 @@ 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. + +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. + +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 new file mode 100644 index 0000000..b17cb99 --- /dev/null +++ b/docs/contracts/appbar-v1.md @@ -0,0 +1,179 @@ +# 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. 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. +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. 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 +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 +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. 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. +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 +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 +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..bce7167 --- /dev/null +++ b/docs/evidence/e05-portable-appbar-20261020/SHA256SUMS @@ -0,0 +1,10 @@ +98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e src/threaddesk/ui/static/networkpunkt.css +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 +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 +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 new file mode 100644 index 0000000..8813846 --- /dev/null +++ b/docs/evidence/e05-portable-appbar-20261020/STATUS.md @@ -0,0 +1,165 @@ +# 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 + +- 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. +- 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-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-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 +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. + +## 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. + +## 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. + +## 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 new file mode 100644 index 0000000..6209c30 --- /dev/null +++ b/docs/evidence/e05-portable-appbar-20261020/checks.json @@ -0,0 +1,33 @@ +{ + "version": "1.0.0", + "base": "b63fbd24743140fed68dc2a8eca7dcd9a7ca6c61", + "pureStateTestsPassed": 35, + "pytestTestsPassed": 32, + "realChromiumFixtureCases": 13, + "existingUiAssetTestsPassed": 17, + "browserFixtureUsesRealAppConnection": false, + "hostIntegrationsMounted": [], + "liveInstalledAppChanged": false, + "newDependencies": false, + "newSecretsPermissions": false, + "sourceSha256File": "SHA256SUMS", + "approvedKiraBridgeIconAssigned": false, + "documentedAp4RingColorAssigned": false, + "ap4FixtureColorIsSynthetic": true, + "iconDimensionsUserAccepted": false, + "independentReview": "d016 F1/F2/N1/F3 closed; resize-test/LF-attribute correction awaiting final recheck", + "authorReranUnchangedReviewerNodeCasesPassed": 5, + "authorReranUnchangedReviewerBrowserCasesPassed": 7, + "reviewedOriginalCommit": "b36af8563f47f0d488d0f885a36b8ed99c33032b", + "clockRollbackFailClosed": true, + "openDeadlineUsesWallAndMonotonic": true, + "tooltipViewportClamped": true, + "previousReviewCommit": "ef286c2edd60131dbb541a2280f252f0223f1dc7", + "invalidCooldownDoesNotSatisfyMinimum": true, + "cooldownRecoveryRestartsFull600Ms": true, + "runtimeIdenticalTo": "d016be94681c368e7f3db9f71bc62d350855d6b8", + "resizeDeltaCasesPassed": 2, + "bundleAutocrlfExportMatches": true, + "bundleTextAttributesLf": true, + "autocrlfExportIsWindowsNativeProof": false +} diff --git a/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS new file mode 100644 index 0000000..0a53785 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-SHA256SUMS @@ -0,0 +1,9 @@ +98371bd9edc96fb940a0e0de34933740fed1062b510aa516498fcb53e112591e networkpunkt.css +7a4ef4699cf5e07f84f57279f86f73b9dbd85e1e9272e4329a5e430b977ad60d networkpunkt-appbar.js +c97ef3d092a2df9687629a2d877672b133df8aeda8175f3bfdf2a7698758d7f8 networkpunkt-appbar-state.js +2a266588a5d1da0fb0d1c8d77f671d10847e038029670c14b38ae8609ec76639 networkpunkt-appbar-catalog.js +b8626e2dc7d0f34a4065fc217e11cd0b52ce1d4dea0df802b4a642e7fec57b46 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..d247198 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar-state.js @@ -0,0 +1,91 @@ +/* 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)); + +// 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) { + 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); + function snapshot() { + if (pending && pending.age() >= 2000) { + pending = null; accepted = null; neutral = "unknown"; + } + 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 && accepted.age() < 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, age: createElapsed(clock, 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 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 + && 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, age, 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..dbc1b3e --- /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; 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 new file mode 100644 index 0000000..1d0d3e5 --- /dev/null +++ b/src/threaddesk/ui/static/networkpunkt-appbar.js @@ -0,0 +1,154 @@ +/* NetzwerkPunkt appbar 1.0.0 — portable DOM renderer, no transport or host launch. */ +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(); +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; 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: 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 = []; + 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 }; + 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(); + } + 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; + 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)); + 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) || 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(); + 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); + 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; }, + 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(); + // 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); + 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); }, + }); + 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 0000000..d88ea8b Binary files /dev/null and b/src/threaddesk/ui/static/np-appbar-4allpass-dark-48.png differ diff --git a/src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png b/src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png new file mode 100644 index 0000000..c75cddf Binary files /dev/null and b/src/threaddesk/ui/static/np-appbar-threaddesk-dark-48.png differ diff --git a/tests/appbar_state.test.mjs b/tests/appbar_state.test.mjs new file mode 100644 index 0000000..12a58b3 --- /dev/null +++ b/tests/appbar_state.test.mjs @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import test from "node:test"; +const source = readFileSync(new URL("../src/threaddesk/ui/static/networkpunkt-appbar-state.js", import.meta.url)); +const { createPeerState } = await import(`data:text/javascript;base64,${source.toString("base64")}`); +const expected = { appId:"4allpass", connectionRef:"fixture-link", peerRef:"fixture-peer", peerEpoch:"epoch-1", + unlockScope:{ userRef:"fixture-user", vaultRef:"fixture-vault" } }; +function setup(appId="4allpass") { + let time=0, wall=100000; + 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--;}, 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",()=>{ + 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"); +}); +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/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..371afa4 --- /dev/null +++ b/tests/test_appbar.py @@ -0,0 +1,231 @@ +"""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_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")') + 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}})') + 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") + 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}) + 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 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.",