Compare commits

...
191 Commits
Author SHA1 Message Date
DogFatherGitandClaude Opus 5 b625d901a5 Jeder darf seine eigene Chat-Nachricht bearbeiten
Filipe: "die nachrichten die man in den chat reinschreibt. jeder soll
seine eigene nachricht bearbeiten koennen. diese option soll jeder
fuer seine eigene nachrichten haben die er selber verfasst hat."

NUR DIE EIGENE -- OHNE AUSNAHME, auch nicht fuer DogFather und die
rechte Hand. Beim LOESCHEN gibt es diese Ausnahme seit dem 22.09.
(fuer den Notfall), und es waere naheliegend gewesen, sie
mitzunehmen. Das waere falsch: Eine fremde Nachricht zu entfernen
heisst "das soll hier nicht stehen". Eine fremde zu AENDERN heisst,
jemandem Worte in den Mund zu legen, die unter seinem Namen und
seinem Bild stehen bleiben. Nicht dieselbe Befugnis in groesser,
sondern eine andere. Genau das ist die wichtigste Pruefzeile:
DogFather darf loeschen und bekommt beim Bearbeiten 403/404.

AN DER NACHRICHT STEHT "BEARBEITET". Ein Text, der sich still
aendert, nachdem andere darauf geantwortet haben, ist ein
Vertrauensproblem und kein Komfort. Der Vermerk traegt die Zeit im
Titel. Derselbe Text setzt ihn NICHT -- sonst stuende er irgendwann
ueberall und waere nichts mehr wert.

ERWAEHNUNGEN BLEIBEN, WIE SIE BEIM SENDEN WAREN. Wer beim Bearbeiten
"@Anna" ergaenzt, spricht Anna damit nicht an. Sonst gaebe es nur
schlechte Wege: nachtraeglich benachrichtigen laesst sich beliebig
oft wiederholen, und still eintragen setzt jemanden auf eine Liste,
von der er nie erfaehrt. Ansprechen tut man mit einer neuen
Nachricht. (Falls das anders gewuenscht ist, ist es eine eigene
Entscheidung -- nicht etwas, das hier nebenbei mitpassiert.)

DER RAUM RUECKT NICHT NACH OBEN und niemand bekommt die Nachricht
als ungelesen: Eine Tippfehlerkorrektur ist keine Wortmeldung.
`letzte_am` wird deshalb nicht angefasst.

Leer geht nicht -- dafuer steht "loeschen" daneben, mit Rueckfrage.
Grenzen (4000 Zeichen) sind dieselben wie beim Senden; eine zweite
Rechnung waere die, die auseinanderlaeuft.

Das Feld sitzt AN der Nachricht, nicht im Schreibfeld unten: Wer
seinen Text zum Bearbeiten unten wiederfindet, schickt ihn beim
naechsten Enter als NEUE Nachricht ab und hat ihn zweimal im Raum.
Enter speichert, Shift+Enter macht eine Zeile, Escape bricht ab --
dieselben Tasten wie beim Schreiben. 16 px Schrift, sonst zoomt iOS
beim Hineintippen die ganze Seite heran.

Der Stift traegt sich in die Familie der Handgriffe ein, wie es der
Hinweis in chat.css ausdruecklich verlangt ("wer einen sechsten
Handgriff baut, traegt ihn hier ein und bekommt sein Zeichen").

EIN FEHLER, DEN NUR DAS BILD GEZEIGT HAT. Ich hatte im Code
behauptet, die Gespraechsliste aendere sich beim Bearbeiten nicht,
und darum auf das Nachladen verzichtet. Auf dem Bildschirmfoto stand
rechts "Treffen um 15 Uhr" und links in der Liste weiter "Du:
Treffen um 15 Urh" -- derselbe Satz, zweimal verschieden, auf einem
Schirm. Richtig ist: Die REIHENFOLGE aendert sich nicht, die
VORSCHAU sehr wohl. Beide Haelften waren fuer sich gemessen und
gruen; keine Zahl hat es gemerkt.

pruef-chat 17 neue Pruefungen: eigene geht, fremde nicht, DogFather
nicht, Vermerk kommt mit nach draussen (auch im SELECT -- genau das
hat am 03.10. bei den Anhaengen einen halben Tag gekostet), Raum
rueckt nicht, Vorschau zieht nach, leer/zu lang abgelehnt,
unveraendert ohne Vermerk, geloeschte nicht bearbeitbar, wer nicht
im Raum ist bekommt 404 statt 403.

pruef-chat-optik 71 -> 84: im echten Browser, mit zwei Sitzungen.
Darunter die Zeile, auf die es ankommt -- der neue Text steht bei
Patrick, OHNE Neuladen. Ein Bearbeiten, das nur der Schreibende
sieht, waere schlimmer als keins.

Dabei zwei eigene Messfehler behoben: Die Sitzungen von oben waren
laengst geschlossen (Playwright meldet nur "Target page has been
closed"), und beide klickten "das oberste Gespraech" statt
denselben Raum -- wodurch die Pruefung "an ihr steht KEIN
bearbeiten" gruen war, weil die Nachricht gar nicht da war. Sie
haengt jetzt daran, dass er sie wirklich sieht.

Datenbank vorher gesichert und zurueckgelesen (integrity_check,
324 Nachrichten, 20 Personen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-04 00:06:29 +02:00
DogFatherGitandClaude Opus 5 5d9c8cd377 Haustrennung: kein Eintrag mehr aus dem anderen Haus
Filipe, 24.09.2026: "wenn ich bei der einen was mache soll nichts bei
der anderen passieren." -- und heute: "ja los".

GEMESSEN, BEVOR ETWAS ANGEFASST WURDE (jedes Brett x beide Adressen x
vier Rollen):

  AGENTUR-Adresse   DogFather  18 Bretter
                    Manager     6 Bretter
                    Creator     6 Bretter
                    Modi        kommt nicht rein (401)
  CREW-Adresse      DogFather  18 Bretter
                    Modi       13 Bretter
                    Manager     kommt nicht rein (401)
                    Creator     kommt nicht rein (401)

Die Anmeldung war also dicht. Durch griff genau EINE Rolle: DogFather.
Er wohnt in beiden Haeusern, und die Riegel in sichtbarEintrag()
fragten nach der ROLLE, nicht nach der Adresse. Auf der Crew-Adresse
stand damit das Brett der Agentur samt Inhalt.

Nachgemessen ist der Durchgriff AELTER als der gestrige Eventkarten-
Umbau -- zweimal gemessen, mit und ohne ihn, gleiches Ergebnis.

RIEGEL 0 in sichtbarEintrag(): Wer auf einer Adresse angemeldet ist,
sieht nur Eintraege dieses Hauses. Er haengt den uebrigen Riegeln
UM, statt in jeden Ausgang geschrieben zu werden -- die Funktion hat
drei Rueckgabepunkte, und der naechste waere sonst wieder offen.

Nachher, dieselbe Messung: DogFather sieht auf der Agenturadresse nur
Agentur-Eintraege, auf der Crew-Adresse nur die des Rudels. Manager,
Creator und Modi unveraendert.

WAS DABEI SCHIEFGING UND WIE ES AUFFIEL

1. Die erste Fassung liess bei `haus IS NULL` den BEREICH entscheiden.
   pruef-haus-trennung.mjs wurde sofort rot: "Lunas Live vom Montag"
   verschwand von der Agenturadresse. `live`, `technik` und
   `community` tragen BEIDES -- die Kacheln von Team Dogi und die
   Creator-Akten. Eine Regel, die jedem Brett genau ein Haus zuweist,
   kann das nicht. Jetzt bleibt ein Eintrag ohne Haus sichtbar: ein
   Eintrag zu viel faellt auf, ein fehlender nicht.

2. Damit NULL kein Dauerloch ist: FUENF von ACHT Stellen, die
   Eintraege anlegen, setzten `haus` gar nicht (workspace-video.js,
   -content.js, -bewerbung.js, -treff.js, -vorlagen.js). Nachgetragen.

3. Und `person.haus` war dafuer der falsche Massstab: Legt DogFather
   ueber die Agenturadresse ein Highlight an, gehoert es trotzdem dem
   Rudel -- sonst sieht die Community es nie. pruef-treff.mjs hat das
   gefunden (2 Fehler). Neu: hausFuerNeuenEintrag() -- bei den sieben
   Brettern des Rudels entscheidet das BRETT, sonst die Adresse.

NEU: pruef-haus-luecke.mjs (12 Pruefungen). Sie sucht die
Einfuege-Stellen im Quelltext und wird rot, sobald eine neunte
dazukommt, die `haus` vergisst -- mit Gegenprobe, dass das Suchmuster
eine solche Stelle auch wirklich erkennt. Ein Kommentar daneben haette
es nicht verhindert; das steht so schon im Projektgedaechtnis.

NEBENBEI: In bereich.js stand seit gestern `|| "Agentur-Events"` als
Rueckfall fuer das Etikett der Vorschau. pruef-treff.mjs verbietet
das zu Recht -- wie ein Brett heisst, haengt am Haus, und der Server
sagt es. Der Rueckfall ist weg; fehlt die Angabe, steht lieber kein
Etikett da als ein falsches.

pruef-treff.mjs nachgezogen (85 -> 86 Pruefungen, nicht weniger): Die
Zusage "DogFather sieht den Beitrag" wird jetzt auf der Crew-Adresse
geprueft, mit Gegenprobe fuer die Agenturadresse.

GEPRUEFT, alle gruen:
  pruef-haus-luecke     12    pruef-haus-trennung  100
  pruef-crew-adresse   169    pruef-treff           86
  pruef-eventkarte      77    pruef-agentur         62
  pruef-haus-seiten     38    pruef-eintrag-bild    24
  pruef-vorlagen        24    pruef-video, -content, -bewerbung,
                              -bereiche-lesend: in Ordnung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 23:33:03 +02:00
DogFatherGitandClaude Opus 5 3e1e0810ba Agentur-Events: die Karte als Aushang, Aufgaben zum Abhaken
Filipe, mit einem Bildschirmfoto des Oktober-Events: "ich will das viel
geiler viel perfekter. und auch wenn man die eintraegt und so soll viel
mehr perfektioniert und uebersichtlicher sein."

VORHER GEMESSEN (1280 px, eine einzige Eventkarte):

  Kartenhoehe            1196 px   -- das Fenster ist 900 px
  davon Banner            638 px   -- 53 %, ohne einen Buchstaben
  Titel steht bei        1482 px   -- zweimal scrollen bis zum Namen
  Titelgroesse          15,36 px   -- 1,4 px mehr als der Fliesstext
  Etikett               10,88 px   -- unter der eigenen Grenze (11,5)
  Datum                     3 x    -- in drei Zeilen untereinander
  Knoepfe              4 x gleich  -- "loeschen" wie "zur Aufgabe"

NACHHER, dieselbe Messung: Karte 618 px, Buehne 203 px, Titel 75 px
unter der Kartenkante und 24,8 px gross, Etikett 12 px, das Datum
einmal, "loeschen" abgesetzt.

DIE KARTE
Das Banner ist nicht mehr ein Anhang ueber dem Titel, sondern die
Buehne dahinter -- Hoehe gedeckelt, Titel darauf. Dazu die Frage, die
bei einem Wettbewerb wirklich zaehlt und bisher nirgends beantwortet
wurde: "noch 12 Tage", mit Zeitbalken. Ein Knopf "Banner ganz" holt das
vollstaendige Bild zurueck, das beim Umbau sonst verloren gegangen
waere -- bei Filipes Banner steht die Ansage IM Bild.

DIE AUFGABEN
Aus Fliesstext wird eine Liste, und jeder hakt fuer sich ab (neue
Tabelle event_punkte, Schluessel aus dem Zeilentext). Umsortieren
laesst den Haken, wo er ist; wird die Bedingung selbst umgeschrieben,
faellt er -- beides nachgemessen. Die Leitung sieht, wer wie weit ist,
eine Creatorin sieht diese Liste gar nicht erst.

DAS FORMULAR
Ein Feld je Zeile statt eines leeren Textfeldes. Im echten Event stand
".Jeden Tag Live gehen" neben ". 33k Diamanten erreichen" -- einmal mit
Leerzeichen, einmal ohne. Das ist die zwangslaeufige Folge davon, dass
die Aufzaehlungszeichen von Hand getippt werden; jetzt setzt sie das
Formular. Dazu "Laeuft 14 Tage.", eine Warnung bei verdrehtem Zeitraum
und eine Vorschau, die dieselbe Funktion benutzt wie die echte Karte.

NEBENBEI GEFUNDEN UND BEHOBEN
* Das Banner kam bei Creatorn mit 404 zurueck -- also an genau der
  Karte, die fuer sie gemacht ist. Die Regel "wer das Brett sieht,
  sieht das Bild" galt nur fuer die sieben Community-Bretter. Jetzt
  fragt der Bildweg dieselbe Regel wie das Brett (darfEintragSehen);
  die Gegenprobe zeigt, dass ein fremder Eintrag weiterhin 404 gibt.
* Zwei Stellen setzten das Formular zurueck, die kuerzere liess
  Vorschau und Zeilen-Editor stehen -- beim naechsten "Neuer Eintrag"
  standen die Aufgaben des vorigen Events noch da.
* Der Schriftgrund ragte auf dem Handy 6 px ueber die Karte (feste
  -20 px gegen 14 px Polsterung); jetzt an die Polsterung gekoppelt.

GEPRUEFT
pruef-eventkarte.mjs (neu): 77 Pruefungen, 0 Fehler -- mit Gegenproben
zu jeder Zusage und einer Kontrastmessung am Bildpunkt auf einem
weissen Banner (14,7:1; ohne den Schriftgrund 1,05:1).
pruef-agentur.mjs auf die neuen Bausteine nachgezogen, Zahl unveraendert
bei 62. pruef-eintrag-bild.mjs 24/0.

OFFEN, NICHT AUS DIESEM UMBAU: Das Brett `agentur` ist auf der
Crew-Adresse erreichbar. Zweimal gemessen, mit und ohne diese
Aenderung -- gleiches Ergebnis. Gehoert zur Trennungsregel vom
24.09.2026 und wird getrennt entschieden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 19:02:05 +02:00
DogFatherGitandClaude Opus 5 ae2f12c67d "Fuer wen"-Reihe weg -- aber nur bei Team Dogi
Filipe, mit Bildschirmfoto der Leiste "FUER WEN · Alle 3 2 ·
Marina 1 · Diene 0": "brauchen wir nicht auf der aufgaben seite."

GETRENNT GEFUEHRT, wie seit dem 24.09. fuer jeden Umbau. Es ist
DIESELBE Reihe in beiden Haeusern, aber nicht dieselbe Aufgabe:

  Team Dogi    -> "Fuer wen", darin drei, vier Modis, die ohnehin
                  alle auf einem Schirm stehen. Ein Filter fuer eine
                  Liste, die kuerzer ist als er. WEG.
  Spicy Media  -> "Alle Creator" / "Deine Creator". Scouts und
                  Manager filtern damit durch ihre Creator, und das
                  sind deutlich mehr. BLEIBT.

Die Trennung ist eine einzige Zeile an `ich.haus` -- das kommt vom
Server. Eine Rollenliste im Browser waere die zweite Wahrheit und fuer
DogFather, der in beiden Haeusern arbeitet, sogar falsch.

Der Zweig fuer die dritte Beschriftung ist mit entfernt, nicht
stehengelassen: "Fuer wen" ist von nirgends mehr erreichbar, und ein
Zweig, den niemand erreicht, sieht beim Lesen aus wie ein Fall, den
es gibt -- beim naechsten Umbau pflegt ihn jemand mit.

BEWIESEN IN BEIDE RICHTUNGEN, sonst waere die Trennung eine
Behauptung:

  * pruef-handy-teamdogi (neu, 3 Pruefungen, 244 -> 247): auf crew.
    im echten Browser ist die Reihe weg -- MIT der Gegenprobe, dass
    die Seite ueberhaupt steht (4 Knoepfe in der Schwester-Reihe).
    "Reihe nicht da" waere sonst auch auf einer kaputten Seite wahr.
  * pruef-aufgabenbrett (49, unveraendert): bei Spicy Media steht sie
    weiterhin da, mit "Alle, Tili, Luna" und der Beschriftung "Alle
    Creator".

ZWEI DINGE AM PRUEFWERKZEUG, die mich heute Zeit gekostet haben:

1. `probleme` wurde seit jeher gefuellt und NIE ausgegeben. Am Ende
   stand "1 Befund" und sonst nichts -- wer das liest, weiss DASS
   etwas ist, nicht WAS, und muss den ganzen Lauf noch einmal
   anstossen. Die Befunde stehen jetzt unter der Zahl.

2. Der Anmeldeweg stand in der Schleife, und fuer die neue Messung
   habe ich ihn abgeschrieben -- dabei eine aeltere Fassung erwischt,
   ohne `isVisible()` und mit `click()` statt `check()`. Ergebnis:
   30 Sekunden Zeitablauf an der Altersfrage, die es fuer DogFather
   gar nicht gibt. Jetzt ein Helfer, den beide Aufrufer benutzen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 15:25:26 +02:00
DogFatherGitandClaude Opus 5 7d56837049 Fuenfte Etappe: "abgebrochen" fehlte in der Pruefung
Die Live-Datenbank hat mir den Fehler gezeigt, nicht der Quelltext.
Nach dem Ausliefern ein Blick auf die echten Staende: erledigt 6,
offen 4 -- und abgebrochen 2. Die zwei standen in meiner Pruefung
nicht.

GRUND: Ich hatte die Etappen aus STATUS in workspace-aufgaben.js
abgeleitet, und dort stehen nur vier. "abgebrochen" fehlt dort
ABSICHTLICH, damit der normale Weg es nicht setzen kann -- es kommt
ueber eine eigene Route mit Pflichtbegruendung. Wer die Liste aus
STATUS ableitet, uebersieht also ausgerechnet den Endzustand, in dem
Aufgaben am laengsten liegen bleiben. Dieselbe Luecke hat in dieser
Datei schon einmal zwei Bedingungen erwischt, die nur gegen
'erledigt' prueften.

"In jeder etape" waere damit eine Zusage ueber vier von fuenf
Etappen gewesen.

Zwei Dinge dafuer nachgezogen:

  * Die Etappe wird jetzt aus der DATENBANK gelesen, nicht aus der
    Liste. Abgebrochene Aufgaben werden an mehreren Stellen bewusst
    ausgeblendet -- "Etappe steht wirklich" waere ueber die Liste
    gar nicht messbar gewesen.
  * Die Auskunft an die Oberflaeche wird nur verlangt, WENN die
    Aufgabe in ihrer Liste steht. Blendet das Haus eine Etappe aus,
    gibt es dort keinen Knopf zu zeigen; dann zaehlt allein der
    Server, und den misst der DELETE. Gemessen: Sie steht drin, und
    der Knopf wird auch dort angeboten.

pruef-verteilen 42 -> 44, jetzt 5/5 in allen Zaehlungen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 15:01:44 +02:00
DogFatherGitandClaude Opus 5 95d68fd392 Rechte Hand und DogFather duerfen Aufgaben immer loeschen, in jeder Etappe
Filipe: "ich will noch dass die rechte hand und dogfather bei den
aufgaben immer die option haben aufgaben zu loeschen. die soll es
immer geben in jeder etape."

NACHGEMESSEN STATT ANGENOMMEN, was vorher galt (darfAufgabenVerteilen
&& darfAendern):

  DogFather (admin)  -> Leitung, konnte schon immer ueberall loeschen.
  rechte Hand (hand) -> NUR an Aufgaben, bei denen sie selbst
                        Erstellerin, Verantwortliche oder Zielperson
                        war. An allen anderen fehlte der Knopf.

Die Luecke war also allein die rechte Hand -- und sie hing nicht an
der Etappe, sondern an der Zugehoerigkeit. Genau das faellt weg.

ZUR ETAPPE: Der Loeschweg hat auch vorher keinen Status geprueft, und
die Oberflaeche zeigt den Stift an JEDER Karte. Es gab hier nichts
freizuschalten -- die Zusage "in jeder etape" ist jetzt aber
festgenagelt, damit sie niemand mit einer gut gemeinten
Statusbedingung wieder aufhebt.

`istRechteHand` NEU, UND NICHT `istHand`: Letzteres umfasst auch die
LINKE Hand. Haette ich das genommen, haette sie das Recht lautlos
mitbekommen -- im ganzen Haus hat sie durchgaengig das kleinere
Recht, und es waren zwei Rollen genannt, nicht drei. Der Vergleich
`rolle === "hand"` stand bisher VIERMAL verstreut im Haus
(workspace-chat zweimal, workspace-material, workspace-rechte); eine
fuenfte Abschrift waere die naechste, die irgendwann abweicht. Die
vier alten bleiben vorerst unberuehrt -- nebenbei umgebaut waeren sie
vier ungepruefte Rechteaenderungen.

pruef-verteilen 30 -> 42. Darin steht jetzt das GEGENTEIL einer
bisherigen Zeile: "Gegenprobe: eine fremde Aufgabe loescht sie nicht
(403)" war am 25.09. richtig, als der Auftrag "ihre eigenen" hiess.
Sie ist mitgewandert statt stehenzubleiben -- eine Pruefung, die eine
zurueckgenommene Regel weiter verteidigt, haelt die Aenderung auf und
sieht dabei aus wie Sorgfalt.

Alle vier Etappen einzeln (offen, arbeit, review, erledigt), jeweils
mit der Kontrolle, dass die Etappe wirklich steht -- sonst waeren es
in Wahrheit viermal "offen" und die Pruefung gruen, ohne je etwas
anderes gesehen zu haben. Dazu je Etappe die Auskunft an die
Oberflaeche (darf_loeschen), der echte DELETE und ein Blick in die
Datenbank.

GEGENPROBE GEFAHREN: Mit `istHand` statt `istRechteHand` meldet die
Pruefung "die LINKE Hand darf es in keiner (0/4 mal abgelehnt)",
2 Fehler. Sie faengt also genau den Fehler, der am teuersten gewesen
waere.

Gegengemessen: pruef-haerte 20, pruef-aufgabenbrett 49,
pruef-rechtetafel 19, pruef-zuteilung 98, pruef-haus-trennung 100 --
alle 0 Fehler. Das andere Haus ist unberuehrt; "hand" gibt es dort
nicht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 15:00:03 +02:00
DogFatherGitandClaude Opus 5 e00a905ce8 Dopplung weg: "Bei dir klingelt nichts" stand zweimal auf einem Schirm
Der neue Kasten von heute Mittag hat die alte Tagesblick-Zeile nicht
ersetzt, sondern sich darueber gesetzt. Beide sagten dasselbe,
untereinander, auf derselben Seite.

GEFUNDEN HAT DAS NUR EIN BILDSCHIRMFOTO. Beide Teile waren fuer sich
gruen: pruef-glocke verlangte die Zeile, pruef-push-hinweis den
Kasten, und keine der beiden konnte sehen, dass die andere existiert.
Zahlen zeigen so etwas nie. Deshalb macht pruef-push-hinweis jetzt
bei jedem Lauf ein Foto -- und scrollt vorher hin, weil der erste
Versuch brav die Begruessungskachel zeigte und den Kasten gar nicht
im Bild hatte. Ein Beweisbild ohne das, was es beweisen soll, ist
schlimmer als keins: Man glaubt ja, hingesehen zu haben.

Entfernt wurde die aeltere der beiden (19.09.2026). Sie war gut
gebaut, server-seitig und nicht wegklickbar -- aber sie hat das
Problem nicht geloest: Am 03.10. nachgemessen hatten immer noch
12 von 20 kein Geraet, darunter die linke Hand und ein Modi. Sie SAGT
es und man kann nichts tun; sie fuehrt nur auf eine andere Seite. Der
neue Kasten hat einen Knopf und erklaert den iPhone-Fall, in dem
Web-Push ohne Home-Bildschirm gar nicht geht.

Entscheidung von Filipe am 03.10. Offen gesagt, was damit aufgegeben
ist: Wer "Nicht jetzt" tippt, bekommt keine zweite Erinnerung mehr.
Gegensicherung ist die Personenliste -- dort steht seit heute bei
jeder Person, die nichts bekommt, genau das.

pruef-glocke 36 -> 33, und die Zahl ist erklaert: 5 Pruefungen zur
alten Zeile entfernt, 2 neue dafuer. Die neuen pruefen das
GEGENTEIL von vorher -- dass die Zeile nicht unbemerkt
zurueckkommt -- mit der Zahl der geprueften Hinweise in der
Bedingung, damit eine leere Antwort der Schnittstelle nicht als
"steht nicht drin" durchgeht.

Die alte Gegenprobe ("mit angemeldetem Geraet ist der Hinweis weg")
ist ersatzlos entfallen, nicht aus Bequemlichkeit: Sie waere ab jetzt
IMMER gruen, egal was das Haus tut. Eine Pruefung, die nichts mehr
widerlegen kann, taeuscht nur Deckung vor. Was sie geprueft hat,
prueft pruef-push-hinweis am neuen Kasten, mit einer echten
Anmeldung.

OHNE_ZAHL in start.js bleibt als leere Liste stehen -- der naechste
Zustandshinweis braucht sie wieder, und als Liste ist sie die eine
Stelle dafuer.

Gegengemessen: pruef-tagesblick 23, pruef-start-ansicht 160,
pruef-push-hinweis 16 -- alle 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:47:50 +02:00
DogFatherGitandClaude Opus 5 cc716a9c1c Startseite sagt einmalig Bescheid, wenn jemand nichts bekommt
Am 03.10.2026 nachgemessen: 20 aktive Personen, 8 mit einer
Anmeldung. Zwoelf bekamen keine einzige Benachrichtigung -- darunter
die linke Hand und ein Modi. Die heute frueh behobene Dringlichkeit
hilft diesen zwoelf nichts: Ohne Anmeldung geht gar nichts hinaus.

Belegt im neuen Sendeprotokoll, seit dem Deploy heute Mittag:
10 Chat-Meldungen zugestellt, 26 Versuche an "keine_geraete"
gescheitert. Genau diese 26 sind der Grund.

Keiner der zwoelf wusste es. Die Glocke sagt es nur dem, der sie
anschaut -- und genau das hatten sie nie getan. Jetzt steht auf der
Startseite eine ruhige Zeile mit zwei Knoepfen: Anschalten oder
nicht jetzt.

ABGELEITET AUS `lage()`, der Stelle, die es ohnehin weiss. Eine
zweite Ableitung daneben waere die, die beim naechsten Umbau etwas
anderes behauptet als die Glocke zwei Zentimeter weiter oben.

NUR DORT, WO ES ETWAS ZU AENDERN GIBT. Bei "verboten" hilft kein
Knopf (das muss man im Browser zuruecknehmen), bei "geht-nicht"
erst recht nicht. Auf dem iPhone erklaert er stattdessen den Weg
ueber den Home-Bildschirm -- ohne den gibt es dort gar kein
Web-Push, und das weiss sonst niemand.

NUR AUF DER STARTSEITE, obwohl glocke.js auf 39 Seiten laeuft: Die
Seite stellt den Platz, das Skript fuellt ihn -- dasselbe Muster wie
`#glocke-platz`. Auf jeder Seite waere er nach dem zweiten Mal
Tapete.

Ruhig, nicht alarmierend: Es ist kein Fehler, sondern eine
Einstellung, die noch niemand getroffen hat. Rot waere hier falsch.

pruef-push-hinweis.mjs (neu, 16 Pruefungen). Sie misst VIER
Abwesenheiten und nur eine Anwesenheit, weil die Gefahr auf der
anderen Seite liegt: weg nach "Nicht jetzt", weg geblieben nach dem
Neuladen, nicht da auf anderen Seiten (mit der Gegenprobe, dass die
Glocke dort sehr wohl steht -- sonst waere nur gemessen, dass das
Skript gar nicht laeuft), und nicht da, wenn die Benachrichtigungen
AN sind. Die letzte mit echter Anmeldung, die nachweislich in der
Datenbank landet.

Dabei gelernt, und es stand nicht im Code: `browser.newContext()`
gibt ein Inkognito-Fenster, und Chrome unterstuetzt dort die Push-API
nicht -- "deliberately no way to feature-detect this". Die Pruefung
meldete zuerst den dritten Ausgang statt gruen, und weil sie die
Browsermeldungen mitschreibt, stand die Ursache sofort da. Jetzt
laeuft sie mit einem echten Profil.

Gegengemessen, dass die zusaetzliche Zeile nichts verschiebt:
pruef-glocke 36, pruef-start-ansicht 160, pruef-tagesblick 23,
pruef-kachelraster 24, pruef-handy 189, pruef-breiten 23,
pruef-lesbarkeit 14, pruef-ueberlappung (20 Breitenpaare) -- alle
0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:36:51 +02:00
DogFatherGitandClaude Opus 5 628b99943e Personenliste zeigt, wen eine Benachrichtigung ueberhaupt erreicht
Am 03.10.2026 am laufenden System nachgemessen: 20 aktive Personen,
aber nur 8 mit einer Push-Anmeldung. Zwoelf bekommen keine einzige
Benachrichtigung -- darunter ein Modi und die linke Hand.

Das war fuer niemanden sichtbar. Die Glocke sagt es nur dem, der sie
anschaut, und wer etwas Wichtiges schreibt, konnte nicht wissen, wen
es erreicht. Die Dringlichkeit, die heute frueh behoben wurde, hilft
diesen zwoelf gar nichts: Ohne Anmeldung geht nichts hinaus.

Der Hinweis steht jetzt auf der Personenseite, also dort, wo ohnehin
ueber Personen entschieden wird -- nicht auf einer eigenen Seite, die
niemand aufruft. Aus DERSELBEN Abfrage wie alles andere; eine zweite
waere eine zweite Gelegenheit, dass die Liste etwas anderes sagt als
die Wirklichkeit.

NUR BEIM FEHLEN, UND DAS IST ABSICHT. Stuende bei jeder Person eine
Zeile, stuenden bei zwanzig Personen zwanzig Zeilen da, und die, auf
die es ankommt, gingen darin unter -- eine Angabe, die immer kommt,
wird nicht mehr gelesen. Schweigen heisst hier: erreichbar. Die
Zeilen verschwinden eine nach der anderen, sobald jemand die Glocke
anschaltet.

Nicht bei gesperrten Personen: Dort ist es keine Luecke, sondern
richtig so.

pruef-personen-liste 33 -> 37, mit der Gegenprobe in beide
Richtungen: Eine Person bekommt im Testbestand ein angemeldetes
Geraet, und bei genau ihr darf der Hinweis NICHT stehen. Stuende er
ueberall, waere "er steht da" wertlos.

Gegengemessen, dass die zusaetzliche Zeile nichts verschiebt:
pruef-personen-kachel 45, pruef-hand-personen 49,
pruef-modi-verborgen 94, pruef-betreuung 18, pruef-scout-zuteilung
(liest dieselbe Klasse .person__bezug) -- alle ohne Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:09:51 +02:00
DogFatherGitandClaude Opus 5 e349de0040 Sendeprotokoll fuer Benachrichtigungen: "ich bekomme nichts" ist jetzt beantwortbar
Beim Nachsehen zu Dienes Meldung ist aufgefallen, dass ueber den
Versand selbst nichts festgehalten wird. Im Protokoll standen nur
push_angemeldet und push_abgemeldet -- kein Wort darueber, ob je
etwas verschickt wurde, an wie viele Geraete, und warum nicht.

Von neun Aufrufstellen wertete genau eine den Rueckgabewert aus; die
anderen acht warfen ihn weg. Sagt jemand "ich bekomme nichts", liess
sich also nicht nachsehen, OB gesendet wurde -- dieselbe Falle wie am
06.09. in VanVans Shop, wo zwei Stunden in die Zustellung ermittelt
wurden, bevor jemand fragte, ob die Mails abgeschickt waren. Sie
waren es, alle, nachweisbar in einer Abfrage.

zuletzt_ok reicht dafuer nicht: Es sagt, wann zuletzt irgendetwas
ankam -- nicht was, nicht an wen sonst, und nichts ueber die Faelle,
in denen gar nicht erst gesendet wurde. Genau die (abgeschaltet,
Ruhezeit, kein Geraet) sind die haeufigste Antwort auf die Frage.

Neue Tabelle push_versand: Zeit, Person, Art, Grund, Geraete,
zugestellt. Festgehalten wird JEDER Ausgang, auch der, bei dem nichts
hinausging -- ein Protokoll, das nur Erfolge kennt, kann die Frage
nicht beantworten, fuer die es angelegt wurde.

Das Protokoll sitzt als Huelle um den Versand, nicht in ihm: Sieben
Ausgaenge einzeln zu protokollieren waere eine Liste zum Pflegen, und
der achte, den jemand naechstes Jahr einbaut, umginge sie still --
so sind am 11.09. die Spalten beim Tabellenumbau verschwunden. So
steht ein neuer Ausgang ohne Zutun mit drin.

Aufraeumen nach 30 Tagen, neben dem bestehenden Aufraeumen von
push_verschickt. Gemessen statt geschaetzt: rund 16 Chat-Nachrichten
am Tag an bis zu acht angemeldete Geraete -- ein paar tausend Zeilen
im Monat.

Das Schreiben kann den Versand nicht aufhalten (try), meldet sich
aber, wenn es scheitert: ein Protokoll, das heimlich nichts
schreibt, ist schlimmer als keins.

pruef-push-eilig 13 -> 20. Darunter die Gegenprobe, dass auch das
NICHT-Senden protokolliert wird, und dass ein Fehlschlag nicht als
zugestellt gilt.

Datenbank vorher gesichert und die Sicherung geprueft (integrity_check,
20 Personen, 10 Anmeldungen lesbar).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:03:29 +02:00
DogFatherGitandClaude Opus 5 73f9b24793 Benachrichtigungen wecken das Telefon wieder: Urgency je Art
Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."

Alles Messbare war gruen: Der Service Worker zeigt die Meldung bei
geschlossener App (pruef-push-zu, 13 Pruefungen), jede Anmeldung im
Haus stand auf fehler = 0, der Push-Dienst quittierte mit 201. Nur
den Kopf auf der Leitung hat nie jemand gemessen:

    Urgency: normal

Beide Dienste behandeln das ausdruecklich als "darf warten".
Android/FCM haelt solche Meldungen zurueck, solange das Telefon
doest, und stellt sie zu, wenn es aufwacht -- typischerweise beim
Entsperren oder Oeffnen der App. Apple/APNs nennt es "verzoegert,
gebuendelt oder gedrosselt". Das ist Dienes Satz, Wort fuer Wort,
und es stand als Vorgabe im eigenen Quelltext.

zuletzt_ok konnte das nie zeigen: Es beweist, dass der DIENST
angenommen hat, nicht dass das GERAET etwas angezeigt hat.

Bis hierher hing die Dringlichkeit an der Ruheregel (dringend =
regel === "nie"), mit der Begruendung, "darf das nachts stoeren?"
und "darf das warten?" haetten dieselbe Antwort. Haben sie nicht:
Ein Chat um 3 Uhr soll schweigen, um 14 Uhr aber nicht vierzig
Minuten liegen bleiben. Jetzt zwei Felder -- in DERSELBEN Zeile
derselben Artenliste, damit sie nicht auseinanderlaufen koennen.

10 von 19 Arten sind eilig (Anruf, Chat, Erwaehnung, Support, Hilfe,
Termin, Wecker, zweimal Live, Probe). Die anderen neun duerfen
warten -- waere alles eilig, waere nichts mehr eilig.

DIE FALLE BEIM BEHEBEN: dringend steuerte auch die Haltbarkeit
(TTL 150). Ein einfach auf "dringend" gestellter Chat waere nach
150 s verfallen -- wer sein Telefon drei Minuten aus hat, haette die
Nachricht GAR nicht mehr bekommen. Aus "zu spaet" waere "nie"
geworden. Deshalb sind eilig und kurzlebig getrennt; kurzlebig
bleibt genau beim Anruf und der Probe.

Der alte Name kracht jetzt, statt still "normal" zu liefern.

pruef-push-eilig.mjs (neu, 13 Pruefungen): die Entscheidung je Art
unabhaengig notiert statt aus der Artenliste abgelesen, jede Art
muss eine Entscheidung haben, und der Kopf wird durch
benachrichtige() hindurch an einem nachgebauten Push-Dienst
gemessen. Gegenprobe gelaufen: ohne das Feld meldet sie
"chat_nachricht geht mit Urgency: normal hinaus", 4 Fehler.
Die Uhr ist dort eine Eingabe, keine Annahme -- die Zeitzone wird so
gewaehlt, dass der Lauf immer auf 12 Uhr faellt, sonst waere die
Pruefung nachts rot ohne Befund.

pruef-anruf-klingelt 25 -> 26. Dabei aufgefallen: Eine ihrer
Pruefungen war gruen, obwohl die Zeile aus dem Code verschwunden war
-- sie stand nur noch in einem Kommentar, der die alte Fassung
zitiert. Quelltextpruefungen lesen dort jetzt ohne Kommentare.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 12:58:01 +02:00
DogFatherGitandClaude Opus 5 db8dd2fbea 147 Zeichen je Zeile -- am Kontrast lag es nicht
Filipe: „nur die kacheln noch viel geiler. so dass ich die texte auch
besser erkenne."

NAHELIEGEND WAERE GEWESEN, die Farben aufzuhellen. Erst gemessen,
dann gebaut -- und die Vermutung war falsch:

    Kontrast .s-karte__text        15,7 : 1   (noetig 4,5)
    Kontrast aller zehn Textarten   7,5 bis 15,7 : 1
    ZEICHEN JE ZEILE                        147   (angenehm 45-75)

Am Kontrast lag es nicht; der ist ueberall ueppig. Es lag an der
ZEILENLAENGE. Auf 1280 px lief der Meldungstext ueber die ganze
Kartenbreite: 1120 px, knapp 150 Zeichen. Beim Zeilenwechsel findet
das Auge die naechste Zeile nicht mehr sicher -- man liest dieselbe
zweimal oder ueberspringt eine. Das ist der groesste Hebel beim
Lesen, und er kostet nichts.

GEAENDERT

    Meldungstext   15,2 -> 17 px,  max-width 72ch,  Zeilenhoehe 1.6
    Verlaufstext   14,4 -> 15,2 px, max-width 70ch
    Kartenrand     16/18/15/22 -> 18/20/16/24 px
    Trennlinie     zwischen Meldung und Verlauf

    147 -> 78 Zeichen je Zeile (PC), 39 auf dem Handy

`ch` UND NICHT PIXEL: `ch` ist die Breite der Null und waechst mit
der Schrift mit. Eine Angabe in Pixeln waere bei der naechsten
Schriftgroesse wieder falsch -- dieselbe Falle wie jede feste Zahl.

MEHR RAND, WEIL DIE SCHRIFT GEWACHSEN IST. Bei unveraendertem Rand
haette sie die Kante beruehrt, und die Karte waere voller gewirkt
statt lesbarer. Links bleibt es breiter: dort laeuft die
Leuchtschiene in der Farbe des Stands.

DIE TRENNLINIE kostet einen Pixel und beantwortet „wo hoert die
Meldung auf und wo faengt die Vorgeschichte an?". Vorher lief beides
ohne Zaesur ineinander.

EIN FEHLER IN MEINER EIGENEN MESSUNG -- und er haette Schaden
angerichtet

Die erste Fassung der Kontrastrechnung meldete fuer drei gut lesbare
Texte einen Kontrast von 1,04. Ich war nah dran, Farben zu
„reparieren", die in Ordnung sind.

Der Grund: `rgb()` zaehlt 0..255, `color(srgb …)` zaehlt 0..1 -- und
genau das liefert Chrome fuer jedes `color-mix()`. 0,77 als 0,77/255
gelesen ist fast Schwarz. Aufgefallen ist es nur, weil die Zahl
nicht zum Bildschirmfoto passte: Dort waren die Texte deutlich
lesbar.

DESHALB STEHT IN DER PRUEFUNG EINE GEGENPROBE IM BROWSER: Ein
absichtlich zu blasser Absatz wird eingehaengt und MUSS auffallen
(gemessen 1,23 : 1). Ohne sie waere „alle ueber 4,5" auch dann
gruen, wenn die Rechnung kaputt ist -- und genau das war sie.

GEPRUEFT -- pruef-support-bilder 56 -> 64 ok

    9 Textarten in der Karte gemessen
    der Meldungstext ist 16.96 px gross (mindestens 16)
    und bricht nach etwa 78 Zeichen um (hoechstens 85, vorher 147)
    der Verlaufstext: 15.2 px, etwa 75 Zeichen
    jeder Text haelt die Kontrastschwelle (0 darunter)
      — schwaechster 7.55 : 1
    Gegenprobe: ein absichtlich blasser Text faellt auf (1.23 : 1)
    auf 390 px ragt mit der groesseren Schrift nichts heraus (0 px)
    und die Zeile bleibt lesbar lang (etwa 39 Zeichen)

Dazu gruen: pruef-css-klassen · pruef-fingermass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 12:37:05 +02:00
DogFatherGitandClaude Opus 5 a1c2d49260 Drei Bilder konnte man schon -- nur sagte es niemand
Filipes Frage: „koennen die leute auch schon mehrere fotos schicken
wenn die im support was melden wollen?"

Technisch seit dem 02.10. (VanVans Meldung #11), und heute mehrfach
am laufenden Server nachgemessen. Beim Nachsehen auf der SEITE stand
aber:

    Knopf:       „Bild anhängen"          -- Einzahl
    Unterzeile:  „häng ein Bild dran"     -- Einzahl
    daneben:     (nichts)

Wer nicht zufaellig ein zweites Mal auf den Knopf tippt, schickt
eines. Aus seiner Sicht waere VanVans Meldung ungeloest -- und er
haette recht.

EINE MOEGLICHKEIT, VON DER MAN NICHTS WEISS, GIBT ES NICHT. Das ist
dieselbe Sorte wie ein Knopf, der eine Absage holt, nur andersherum:
Dort verspricht die Oberflaeche zu viel, hier zu wenig. Beides
kostet denselben Menschen dieselbe Zeit.

GEAENDERT

    Knopf:       „Bilder anhängen"
    Unterzeile:  „häng Bilder dran"
    daneben:     „bis zu 3 Bilder"        -- bevor man etwas waehlt
    nach einem:  „eins.png · 24 KB · noch 2 möglich"

Die letzte Zeile ist die wichtigere von beiden: „eins.png · 24 KB"
allein sagt nicht, dass ein zweites geht -- und genau an dieser
Stelle hoert jemand auf.

DIE ZAHL KOMMT VOM SERVER (`bilder_max`) und wird neu geschrieben,
sobald sie da ist. Ohne das stuende beim ersten Laden der
Vorgabewert dort -- heute zufaellig derselbe, morgen vielleicht
nicht. Eine Zahl, die zufaellig stimmt, ist keine Auskunft.

GEPRUEFT -- pruef-support-bilder 52 -> 56 ok

    der Knopf steht in der Mehrzahl („Bilder anhängen")
    und daneben steht, wie viele gehen („bis zu 3 Bilder")
    auch die Unterzeile sagt nicht mehr „ein Bild"
    und daneben steht, dass noch Platz ist
      („eins.png · 0 KB · noch 2 möglich")

Dazu gruen: pruef-zeichen 7 · pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 12:24:50 +02:00
DogFatherGitandClaude Opus 5 ae5b467e9a Der Supportverlauf: eine Zeitachse, eine Farbe je Runde
Filipe: „ich will diese komplette seite vom support viel
uebersichtlicher, profissioneller, jede runde soll auch immer eine
spezielle farbe haben und anders aufgestellt aufgeteilt sein damit
man einen viel besseren und krasseren ueberblick hat."

WAS VORHER DASTAND -- an einem Bild mit zwei Runden nachgesehen,
bevor eine Zeile angefasst wurde: vier Textzeilen untereinander,
alle in derselben Farbe. „RUNDE 1", Antwort, „Ging noch nicht · …",
„RUNDE 2". Bei fuenf Runden muss man die Runden ZAEHLEN, statt sie
zu sehen -- und wer spricht, stand nur als kleiner Name daneben.

VIER TEILE SIND NEU

 1. EINE LEISTE OBEN. Ein Punkt je Runde, in ihrer Farbe, mit ihrer
    Nummer -- und der Rand sagt, wie sie ausgegangen ist. Damit ist
    „wie oft ging es schon hin und her?" eine Frage des Hinsehens.
    Erst ab zwei Runden: bei einer waere sie ein Punkt neben nichts.

 2. EINE ZEITACHSE statt einer Liste. Jede Runde haengt mit einem
    Punkt an einer Linie in IHRER Farbe. Die Linie ist ein Rand an
    der Runde selbst, nicht am Behaelter -- so traegt jede ihr
    eigenes Stueck Achse.

 3. ZWEI SPRECHRICHTUNGEN je Runde, als getrennte Kaesten:
    „GEANTWORTET" in der Rundenfarbe, darunter „GING NOCH NICHT"
    (Bernstein) oder „GEHT WIEDER" (gruen) oder „WARTET AUF
    ANTWORT". Vorher standen beide Saetze als Absaetze untereinander
    und sahen gleich aus; man musste lesen, um zu wissen, von wem
    sie sind. Jetzt sagt es die Form.

 4. DIE LETZTEN ZWEI OFFEN, aeltere hinter „3 fruehere Runden
    zeigen". Die Leiste sagt ohnehin, wie viele es gab; im Wortlaut
    braucht es nicht alle. Zwei und nicht eine: Die letzte sagt, wo
    es steht, die vorletzte, woran es davor lag.

DIE FARBE WIRD GERECHNET, NICHT GEPFLEGT

`rundenTon(nr)` gibt 200 + ((nr-1) mod 4) * 32 Grad. Eine Liste mit
fuenf Farben waere die naheliegende Loesung und die falsche: Runde
sechs bekaeme keine. Diese Sorte Liste hat im Haus schon dreimal
etwas gekostet.

DER BEREICH IST ENG UND ABSICHTLICH: 200 bis 296 Grad, Blau ueber
Indigo nach Violett -- die Palette der Seite (babyblau mit lila) und
der Bereich, in dem KEINE Farbe nach „gut" oder „schlecht" aussieht.
Gruen und Bernstein sind fuer den AUSGANG reserviert; waere eine
Rundenfarbe rot, stuenden zwei Aussagen in einer Farbe.

AUGENSCHONEND: 52 % Saettigung, 64 % Helligkeit -- fuer alle Toene
gleich, und nur im CSS. Sonst waere Runde drei blasser als Runde
eins, und augenschonend ist das Gegenteil von zufaellig. Kein Neon,
kein Gluehen. Die Farbe ordnet Zeilen zu; sie schreit nicht.

UND DIE FARBE ALLEIN TRAEGT NICHTS: Die Nummer steht daneben, der
Ausgang in Worten. Fuer Vorleseprogramme gibt es statt elf Punkten
einen Satz.

EIN FUND UNTERWEGS: DIE MESSUNG MASS SEIT ZWEI TAGEN NICHTS

`mess-support-runde.mjs` schickte die Rueckmeldung noch als JSON.
Am 02.10. ist die Route auf den Kopf-Weg umgestellt worden
(`x-geht`, `x-text`); seither antwortete sie mit 400. Gemerkt hat es
niemand, weil diese Datei Bilder macht und keine Rueckgabewerte
prueft: Auf dem Bild stand danach EINE Runde statt vier, und das
sieht aus wie ein Ergebnis. Aufgefallen ist es erst, als die Bilder
vier Rundenfarben zeigen sollten.

Eine Messung, die nach einem Umbau stillschweigend etwas anderes
misst, ist schlimmer als keine -- man glaubt ihr. Sie meldet den
Fehlschlag jetzt mit Code und Text.

GEPRUEFT -- pruef-support-bilder 39 -> 52 ok

    die Leiste zeigt jede Runde (5 Punkte) · mit ihrer Nummer
    die ersten vier Runden haben vier verschiedene Farben (4)
    und die fuenfte faengt die Reihe wieder von vorn an
    Gegenprobe: sie sind nicht alle gleich (4 Farben)
    auch die Rundennummern tragen ihre Farbe (2 von 2)
    offen stehen die letzten zwei Runden (2)
    und die aelteren sind einen Griff entfernt
    jede Runde zeigt beide Seiten
    aufgeklappt stehen alle da (5) · und wieder zu
    auf 390 px ragt nichts heraus (0 px)
    bei einer einzigen Runde gibt es keine Leiste (0)

Die Gegenprobe „sie sind nicht alle gleich" ist die wichtigste:
Waere `--ton` nicht angekommen, waeren alle Punkte grau -- und „vier
Punkte" waere gruen gewesen, ohne dass eine Farbe zu sehen ist.
Gemessen wird die ANGEZEIGTE Farbe, nicht die Zahl im Attribut.

Dazu gruen: pruef-support 104 · pruef-css-klassen · pruef-zeichen 7 ·
pruef-fingermass 5.

Eine Schriftgroesse musste nachgebessert werden: .7rem sind 11,2 px,
und unter 11,5 px faengt im Haus die Grenze an, ab der man
zusammenkneift.

NUR DAS AGENTURHAUS: Die Supportseite liegt unter `/workspace`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:54:46 +02:00
DogFatherGitandClaude Opus 5 ad5e758721 922 px auf einem 844-px-Bildschirm -- die halbe Antwort war zu wenig
VanVan im Support, Meldung #15: „Die Modis koennen die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unuebersichtlich."

Heute Nacht habe ich dazu „Verstanden" gebaut: Gelesenes rutscht
hinter eine zugeklappte Zeile. Das ist richtig und war trotzdem nur
die halbe Antwort -- denn UNGELESENE stehen absichtlich offen da,
und davon hat ein Modi im Haus gerade neun.

GEMESSEN STATT GESCHAETZT, an seinem echten Bestand nachgebaut, auf
dem Handy (390 x 844 px):

    Band:            922 px   -- hoeher als das ganze Fenster
    Anteil der Seite: 49 %

Das IST ihr Satz. Mein Umbau loeste es erst NACH einem Griff auf
„Alle verstanden"; bis dahin stand die Wand unveraendert da.

GEAENDERT: Hoechstens drei stehen offen, der Rest ist einen Griff
entfernt („und 6 weitere"). Danach:

    Band:            566 px   -- passt in den Bildschirm
    nach einem Griff: 46 px   (zugeklappte Zeile)

WARUM NICHT NULL: Eine Absage, die man aufklappen muss, ist keine
Nachricht mehr -- das war die Begruendung vom 30.09., und sie gilt.
WARUM NICHT ALLE: siehe oben. Drei ist die Zahl, bei der Kopf,
Zeilen und Sammelknopf unter einem Bildschirm bleiben.

DIE NEUESTEN ZUERST -- der Server sortiert nach `entschieden_am
DESC`. Wer nicht aufklappt, hat die juengsten gesehen.

GEPRUEFT -- pruef-bewerbung-aufgaben 178 -> 184 ok

    mit fuenf Ungelesenen stehen drei offen (3)
      und der Rest ist einen Griff entfernt („und 3 weitere")
      das Band passt in den Bildschirm (436 px bei 1000 px)
    aufgeklappt stehen alle da (6, „Weniger zeigen")
    und wieder zu — der Knopf geht in beide Richtungen
    und die Probezeilen sind wieder weg — der Stand ist wie vorher

Die dritte Zeile ist die eigentliche Aussage: „drei Zeilen" waere
eine Zahl, „passt in den Bildschirm" ist der Zweck. Die letzte raeumt
die vier Probezeilen wieder weg -- ohne sie zaehlten die Pruefungen
darunter („1 aeltere Antwort", „2 aeltere Antworten") sechs statt
zwei und wuerden rot, ohne dass etwas kaputt ist.

Der Knopf geht in BEIDE Richtungen, und auch das steht da: Sonst
waere er ein Einwegschalter, und das faellt erst auf, wenn jemand
zurueckklappen will.

AUSSERDEM AN CASPERLINOS ECHTEN NEUN GEMESSEN (Kopie der Datenbank,
eigener Port, nichts im Live-System): neun Antworten, alle
ungelesen; „Verstanden" nimmt genau eine; „Alle verstanden" den
Rest; nachlesbar bleiben alle neun; ein zweiter Druck zaehlt null;
eine fremde Nummer gibt 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:45:03 +02:00
DogFatherGitandClaude Opus 5 63211252d2 VanVans Satz hatte zwei Haelften -- gemessen war eine
Meldung #11, Runde 2, woertlich:

    „man kann nicht mehrere Bilder zum hinzufuegen AUSWAEHLEN.
     Und wenn man es NACHEINANDER versucht hinzuzufuegen wird das
     Bild immer nur ersetzt"

Das NACHEINANDER stand seit gestern in der Pruefung -- es war die
Haelfte, die ich kaputt gebaut hatte und die mir deshalb im Kopf
war. Das AUSWAEHLEN nicht: drei Dateien in EINEM Griff, so wie der
Dateidialog sie uebergibt, wenn man sie mit gedrueckter Taste
markiert. Das haengt am `multiple` im Feld, und ohne Messung war es
eine Behauptung im HTML.

Aufgefallen beim Durchgehen ihres Satzes Wort fuer Wort, nachdem
Filipe auf den Screenshot gezeigt hat. Kaputt war nichts -- aber
unbewiesen, und das ist derselbe Zustand wie ungeprueft, nur mit
besserem Gefuehl.

NEU GEPRUEFT

    drei auf einen Griff ausgewaehlt ergeben drei (3)
      und zwar in der Reihenfolge des Dialogs
      das Feld laesst Mehrfachauswahl ausdruecklich zu (multiple)

Die letzte Zeile ist die Gegenprobe zur ersten: Ohne `multiple`
gaebe der Browser nur EINE Datei weiter, egal wie viele man
markiert -- und die Drei darueber koennte auch aus drei einzelnen
Griffen stammen. Gemessen wird deshalb das Merkmal selbst.

Davor wird ausdruecklich alles geleert („Alle weg"), sonst misst die
Zeile, was vorher schon dastand.

GEPRUEFT -- pruef-support-bilder 36 -> 39 ok

AUSSERDEM HEUTE AM LAUFENDEN SERVER NACHGEMESSEN (Meldung #11, auf
einer Kopie der echten Datenbank, eigener Port, nichts im
Live-System):

    er nennt die Grenze: 3
    die Meldung geht durch (201) · alle drei haengen dran (3)
    jedes kommt in SEINER Reihenfolge und mit SEINEM Typ zurueck (3/3)
    vier werden abgelehnt, mit einem Satz
    der alte Einzelbild-Weg ist weg (404) — es gibt nur noch einen

Und: Die Dateien, die VanVans Browser laedt, sind Byte fuer Byte die
geprueften -- support.js und support.css haben live dieselbe
Pruefsumme wie hier.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:35:32 +02:00
DogFatherGitandClaude Opus 5 ef911691b7 Der zweite Weg ist weg -- nicht nur sein Knopf
VanVan im Support, Meldung #8: „Wenn man auf ich fange an drueckt
steht dort in Bearbeitung und wenn man auf fertig drueckt dann wird
es zu erledigt. DIE AUFGABE BLEIBT ABER IM STATUS OFFEN STEHEN."

GESTERN HABE ICH DIE HALBE ARBEIT GEMACHT und daneben eine Ausrede
geschrieben. Die zwei Knoepfe kamen weg, und in den Kommentar kam:

    „Der Weg `/mein-stand` bleibt bestehen -- er ist die Schranke,
     falls ihn jemand direkt anspricht."

Eine Route ist keine Schranke gegen sich selbst. Sie setzte
weiterhin NUR `aufgaben_zuteilung.zustand` und liess
`aufgaben.status` stehen -- also genau den Widerspruch, den VanVan
beschrieben hat. Ich hatte ihn unsichtbar gemacht, nicht
abgeschafft: kein Knopf mehr, das Verhalten unveraendert im System.

GEFUNDEN BEIM NACHMESSEN AM LAUFENDEN SERVER, nicht beim Schreiben.
Filipe hat auf den Screenshot gezeigt und gesagt, es sei noch nicht
in Ordnung. Statt meine Pruefungen zu zitieren habe ich die Route
gelesen -- und dort stand es.

NACHGEMESSEN, BEVOR SIE WEGKAM: Kein einziger Aufruf mehr im
ausgelieferten Browsercode (grep ueber alle JS- und HTML-Dateien des
Workspace). Nur zwei Pruefungen benutzten sie.

UND EINE DAVON NICKTE DEN FEHLER AB. In pruef-zuteilung stand:

    Bea setzt "in Bearbeitung" (HTTP 200)
    und danach "erledigt" (HTTP 200)

Zwei gruene Haken ueber genau dem Verhalten, das gemeldet wurde --
weil sie nur den Rueckgabewert ansahen und nie den Aufgabenstatus
daneben. Eine Pruefung, die nur eine Haelfte misst, kann den
Widerspruch gar nicht finden. Jetzt steht dort:

    Bea setzt "in Bearbeitung" (HTTP 200)
      und BEIDES steht auf "in Arbeit" (Aufgabe arbeit,
      Zuteilung arbeit) — das war VanVans Befund
    und danach "erledigt" (HTTP 200)
      und wieder beides (Aufgabe erledigt, Zuteilung erledigt)

WAS JETZT GILT: `PATCH /workspace/api/aufgaben/:id` mit `{ status }`.
Er setzt den Status UND zieht die Zuteilung mit
(`zuteilungenNachStatus`), kennt dieselbe Sperre fuer dauerhafte
Aufgaben und dieselbe Rechtepruefung. Eine Frage, eine Antwort.

ENTFERNT STATT AUSKOMMENTIERT -- dieselbe Entscheidung wie bei
`/vorlagen/hilfe` am 01.09.: Eine Route, die niemand mehr aufruft,
wird beim naechsten Mal fuer lebenden Code gehalten und mitgepflegt.

EIN SCHRECKMOMENT UNTERWEGS, der sich als Messfehler herausstellte:
Nach der Umstellung meldete pruef-bewerbung-aufgaben eine 404 beim
Abhaken -- also der Verdacht, dass eine ZUGETEILTE Aufgabe ueber den
Statusweg gar nicht erreichbar ist und ich gerade etwas kaputt
gemacht haette. Nachgemessen statt geglaubt: Die Aufgabe steht in
ihrer Liste, der PATCH antwortet 200. Die rote Zeile war eine
DRITTE Stelle, die ich beim Umstellen uebersehen hatte und die noch
auf die alte Route zeigte. Zwei Minuten Messung statt einer Stunde
Suche an der falschen Stelle.

GEPRUEFT

  pruef-zuteilung             ok, mit zwei neuen Zeilen, die BEIDE
                              Zustaende messen
    den zweiten Weg (mein-stand) gibt es nicht mehr (404)
  pruef-bewerbung-aufgaben    178 ok
  pruef-struktur              102 ok, 414 -> 413 Routen
  pruef-aufgabenbrett · pruef-modi-katalog 159 ·
  pruef-aufgaben-vorlagen 60

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:31:18 +02:00
DogFatherGitandClaude Opus 5 2270f0de91 Der „Anpassen"-Knopf war nur an EINEM der beiden Bretter geprueft
Beim Nachsehen zu VanVans Meldung #6 aufgefallen, nicht beim Bauen:
Der Knopf haengt an ZWEI Kartenarten -- an den Creator-Vorlagen
(vorlagenbrett.js:459) und am Team-Katalog (:1220). Im Browser
gemessen war nur die erste.

UND DIE ZWEITE IST DIE, DIE VANVAN BENUTZT. Die Creator-Vorlagen
stehen auf „Aufgaben" und richten sich an Betreuer; der Katalog
steht auf „Eure Aufgaben", und dort verteilt die rechte Hand an die
Modis. Genau das war ihre Meldung.

Dass man diese beiden Bretter verwechselt, ist in diesem Haus schon
passiert: In pruef-bewerbung-aufgaben steht es seit dem 30.09. als
Messfehler notiert, „der wie ein Befund aussieht". Diesmal waere es
andersherum gewesen -- ein gruener Haken ueber einem Weg, den
niemand geprueft hat.

GEMESSEN HAT ES NICHTS KAPUTTES GEFUNDEN: Der Katalogzweig
funktioniert. Aber er war unbewiesen, und das ist derselbe Zustand
wie „ungeprueft" -- nur mit besserem Gefuehl.

DIE GEGENPROBE ZUM ANDEREN BRETT STECKT JETZT DRIN. Ein Creator
bekommt den „dauerhaft"-Haken NICHT (geprueft in
pruef-aufgaben-vorlagen), die Leitung MUSS ihn bekommen (geprueft
hier). Zwei Pruefungen, die denselben Unterschied von beiden Seiten
messen -- erst dann ist es eine Regel und nicht ein Zufall.

GEPRUEFT -- pruef-modi-katalog 150 -> 159 ok

  jede Katalogkarte hat einen „Anpassen …"-Knopf (12 von 12)
  das Fenster hat Frist und Anmerkung
  und die Leitung bekommt den „dauerhaft"-Haken
  die Frist der Vorlage steht als Vorgabe drin (2026-10-05,
    fruehestens 2026-10-03)
  mit Haken wird die Frist gesperrt
  die angepasste Aufgabe liegt in ihrer Liste (3 -> 4)
  sie ist wirklich dauerhaft · und hat keine Frist
  die Anmerkung steht als Notiz dabei

  Die letzten drei fragen die DATENBANK, nicht den Bildschirm. Was
  auf dem Brett steht, ist eine Darstellung; was in der Aufgabe
  steht, gilt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:04:03 +02:00
DogFatherGitandClaude Opus 5 3d859cbed5 Eine untaugliche Zweitfassung erkennt der Server selbst
NACHGEMESSEN AN DEN SECHS, DIE HEUTE NACHT LIVE ENTSTANDEN SIND --
vier tragen das Loch am Anfang, weil sie gebaut wurden, bevor es
bekannt war:

    387,6 s Zeitachse fuer wenige Sekunden Ton
    164,2 s
    134,1 s
     63,8 s
      9,7 s  (in Ordnung)
      3,3 s  (in Ordnung)

Sie gehoeren dem Dienstbenutzer; von aussen sind sie nicht
wegzuraeumen. Und mit „gibt es schon, dann weiter" waeren sie fuer
immer so geblieben.

EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG und
die falsche: eine Zahl, die jemand hochzaehlen muss, wird beim
naechsten Mal vergessen. Gefragt wird deshalb die DATEI SELBST --
nach dem, was schiefgehen kann: Passt ihre Zeitachse zu der
Tonmenge, die sie traegt? AAC packt 1024 Abtastwerte in einen
Rahmen; Rahmenzahl mal 1024 durch die Abtastrate ist die echte
Laenge. Mehr als anderthalb Sekunden darueber heisst: Loch.

Was nicht passt, wird beim naechsten Nachruestlauf weggeraeumt und
neu gebaut -- einmal, denn danach passt es.

ZWEI FEHLER IN DIESER FUNKTION, BEIDE VON DER GEGENPROBE GEFUNDEN

Und beide waeren still geblieben:

 1. ffprobe GIBT DIE WERTE IN SEINER REIHENFOLGE AUS, nicht in
    meiner: erst `sample_rate`, dann `nb_frames`. Ich hatte
    `[rahmen, rate, dauer]` destrukturiert und damit 48000 Rahmen
    bei 95 Hz gerechnet -- eine „echte Laenge" von einer halben
    Million Sekunden. Die Funktion haette JEDE Datei fuer tauglich
    erklaert, immer. Gelesen wird jetzt mit Namen.

 2. DER DATEINAME FEHLTE IM AUFRUF. ffprobe antwortete „You have to
    specify one input file", der `catch` machte daraus ein
    freundliches „taugt" -- und wieder sagte die Funktion zu allem
    ja.

Das ist zweimal dieselbe Sorte: gruen und wertlos. Gefunden hat es
nicht das Lesen, sondern die Frage „kann sie ueberhaupt NEIN
sagen?". Genau dafuer gibt es die Gegenprobe.

WIE MAN EINE DATEI MIT LOCH NACHSTELLT -- UND WIE NICHT

`-itsoffset 40` war der naheliegende Weg und ergab 2,5 s: Der
MP4-Baukasten rechnet einen reinen Anfangsversatz wieder heraus.
Dasselbe mit `-copyts`, `-output_ts_offset` und `-muxdelay`. Was
bleibt, ist eine Zeitachse, die laenger ist als ihr Ton -- und das
stellt eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin
ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die die
Funktion liest, sind dieselben.

GEPRUEFT -- pruef-chat-anhaenge 164 -> 168 ok

  eine Fassung mit zu langer Zeitachse nachgestellt (40,0 s)
  und sie wird als untauglich erkannt
  der Nachruestlauf ersetzt sie (40,0 s → 2,5 s)
  und die neue gilt als tauglich — er wandelt sie nicht ewig weiter

Die letzte Zeile ist die zweite Gegenprobe: Ein Lauf, der bei jedem
Durchgang alles neu wandelt, waere eine Warnung, die immer kommt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 02:04:23 +02:00
DogFatherGitandClaude Opus 5 ff48a0a6ef Das Loch am Anfang der Sprachnachrichten -- und ein halb fertiges Stueck
NACHGEMESSEN AN EINER ECHTEN NACHRICHT AUS DEM HAUS, nachdem die
Zweitfassungen heute Nacht live entstanden waren. `mumqbw6v….webm`
vom 29.09., 46 Pakete:

    Paket 1 bei   0,000 s
    Paket 2 bei  61,116 s
    ...
    Paket 46 bei 63,756 s

2,76 Sekunden Ton, verteilt ueber eine Zeitachse von 63,8 Sekunden.
Dazwischen ein Loch von einer Minute. `MediaRecorder` setzt diesen
Versatz selbst; die Datenbank kennt ihn nicht (dort steht die Angabe
des Absenders: 2910 ms).

WAS DAS FUER DEN HOERER HEISST: Das Abspielgeraet zeigt 1:03,
springt beim Antippen in eine Minute Stille und faengt erst danach
an. Auf einem Handy ueber Mobilfunk sieht genau das aus wie „laedt
die ganze Zeit" -- Miss' Satz aus Runde 5, woertlich.

Das war also nicht nur der Behaelter. Die Umwandlung von heute Nacht
hat das Loch brav mituebernommen: 63,8 s Zeitachse, 32 KB Inhalt.

GEAENDERT: `-af asetpts=N/SR/TB` vergibt die Zeitstempel neu,
fortlaufend ab null. An der echten Datei nachgemessen -- der
Toninhalt bleibt unveraendert (260 KiB dekodiert vorher wie
nachher), nur die Laenge geht von 63,8 s auf 2,76 s. Es wird nichts
abgeschnitten, sondern ein Loch geschlossen.

UND EIN ZWEITER FUND, von der Pruefung und nicht vom Nachdenken

Sie holte die zweite Fassung ab und bekam 44 BYTES. ffmpeg legt die
Zieldatei sofort an und fuellt sie danach -- bei `+faststart`
schreibt es sie am Ende noch einmal um. Die Stelle, die fragt „gibt
es die zweite Fassung?", sah in dieser Zeit eine Datei, die es gibt
und die nichts enthaelt. Wer dann zuhoert, bekommt ein Bruchstueck.

Jetzt entsteht sie unter `….m4a.teil` und wird erst am Ende
umbenannt. Umbenennen im selben Ordner ist EIN Schritt: entweder
vollstaendig da oder gar nicht. Der Bruchstuecknamen endet
ausdruecklich NICHT auf `.m4a`, sonst waere die Luecke nur
umbenannt -- und deshalb muss `-f mp4` dabeistehen, weil ffmpeg das
Format sonst an der Endung waehlt und `.teil` nicht kennt. Auch das
hat die Pruefung gefunden: Der erste Versuch mit Umbenennen erzeugte
gar keine Datei mehr.

WAS DIE PRUEFUNG JETZT MISST -- UND WAS NICHT

Sie vergleicht NICHT mehr die Zeitachsen beider Dateien. Genau das
war mein erster Anlauf, und er haette das Loch durchgewunken: Wenn
es mitwandert, sind beide Zeitachsen gleich lang und alles ist
gruen. Gefragt wird jetzt nach dem TON -- wie viele Bytes
herauskommen, wenn man beide dekodiert (231 KiB zu 231 KiB) -- und
danach, ob die Zeitachse zu dieser Tonmenge passt (2,46 s fuer
2,46 s Ton).

Ein dritter kleiner: `tonBytes` las die Rueckgabe von
`execFileSync`. ffmpeg schreibt seine Zusammenfassung aber auf
stderr -- die Messung meldete fuer beide Dateien 0 KiB. Rot, und zu
Recht: Sie hat nichts gemessen.

WAS BEWUSST OFFEN BLEIBT: Eine Aufnahme, die schon als MP4
hereinkommt (seit dem 02.10. der Normalfall), wird nicht
umgewandelt -- und traegt ein solches Loch, falls sie eines hat,
weiter. Dass sie eines haette, ist nicht gemessen: Seit der
Umstellung gibt es keine einzige neue Aufnahme. AAC noch einmal nach
AAC zu wandeln kostet Qualitaet fuer ein Problem, das ich nicht
beobachtet habe. Faellt es auf, ist die Stelle bekannt.

GEPRUEFT -- pruef-chat-anhaenge 163 -> 164 ok

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 01:55:46 +02:00
DogFatherGitandClaude Opus 5 d70d00cedc Jede Sprachnachricht in einer Fassung, die JEDES Geraet abspielt
Miss im Support, Meldung #12 -- fuenf Runden seit dem 23.09.: „Bei
mir laesst sich die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt
es die ganze Zeit." Bei allen anderen ging es.

GEMESSEN, BEVOR GEBAUT WURDE

  · Alle sechs Sprachnachrichten im Haus sind audio/webm (Opus).
  · Miss' Geraet: iPhone, iOS 18.7, WebKit.
  · Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
    wurde KEINE EINZIGE neue aufgenommen. Ihr Problem betrifft also
    ausschliesslich die sechs alten Dateien -- und jede kuenftige aus
    einem Firefox, der nichts anderes kann.

WAS ICH NICHT MESSEN KONNTE, und das gehoert dazu: Ob WebKit
WebM/Opus abspielen kann, laesst sich auf diesem Rechner nicht
nachsehen -- Playwrights WebKit startet hier nicht (libegl.dll
fehlt). Die Vermutung „iPhones koennen kein WebM" ist begruendet,
aber von mir nicht gemessen.

GENAU DESHALB STELLT DIE LOESUNG DIE FRAGE NICHT. Statt zu raten,
welches Geraet welchen Behaelter kann, legt der Server neben jede
Sprachnachricht eine zweite Fassung in dem Format, bei dem sich seit
zwanzig Jahren alle einig sind: AAC in MP4. Gibt es sie, wird sie
abgespielt -- bei jedem, nicht nur auf iPhones. Kein Geraetename im
Code, keine Liste, die altert.

  helfer-ffmpeg.mjs     ffmpeg finden, umwandeln, drei Ausgaenge
  beim Hochladen        nebenher, nicht davor: Die Nachricht wartet
                        nicht auf ffmpeg
  toeneNachruesten()    das Netz darunter -- fuer die sechs von
                        frueher und fuer den Fall, dass eine
                        Umwandlung einmal nicht geklappt hat
  ?form=mp4             dieselbe Route, dieselben Rechte; eine
                        zweite haette dieselbe Sichtbarkeitspruefung
                        ein zweites Mal gebraucht

ffmpeg IST ABGESPROCHEN INSTALLIERT (Debian 7.1.5, auf Nachfrage
freigegeben). Fehlt es, passiert nichts Schlimmes: Die
Sprachnachricht geht wie bisher im Originalformat hinaus, und es
steht EINMAL eine Zeile im Protokoll -- nicht bei jedem Hochladen.

DREI ENTSCHEIDUNGEN, DIE NICHT NAHELIEGEND WAREN

 1. `-movflags +faststart` IST NICHT KOSMETIK. Ohne es steht die
    Inhaltsuebersicht einer MP4 am ENDE. Das Abspielgeraet muss dann
    erst bis ans Ende lesen, bevor es anfangen kann -- genau das
    „laedt die ganze Zeit" aus Miss' Runde 5. Geprueft wird die
    Reihenfolge der Kaesten in der Datei, nicht die Zeile im Aufruf.

 2. DAS ORIGINAL BLEIBT LIEGEN. Ohne `?form=mp4` kommt weiter die
    WebM. Sie ist das, was aufgenommen wurde; sie durch eine
    Umrechnung zu ersetzen hiesse, das Original wegzuwerfen.

 3. EIN GEMEINSAMER LOESCHER. Zwei Stellen entfernen Anhaenge
    (Gespraech wegraeumen, Nachricht zuruecknehmen). Beide nahmen
    genau eine Datei -- ab heute waere bei jeder geloeschten
    Sprachnachricht eine m4a liegengeblieben, ohne Zeile, die auf
    sie zeigt. Dieselbe Luecke hatte ich gestern bei den
    Supportbildern gefunden; hier steht sie von Anfang an an EINER
    Stelle.

ZWEI FUNDE DER PRUEFUNG, BEIDE MEINE EIGENEN

 · `anhang_datei` STAND NICHT IN DER ABFRAGE der Nachrichtenliste.
   Die Funktion, die auf der Platte nachsieht, bekam deshalb nichts
   und sagte brav „gibt es keine zweite Fassung" -- fuer JEDE
   Nachricht. Alles war gebaut, nichts kam an. Gefunden hat das die
   Pruefung, nicht das Lesen.
 · Mein erster Anlauf mass die Aufnahme ueber den Knopf -- und die
   ist seit dem 02.10. schon MP4, braucht also gar keine zweite
   Fassung. Die Pruefung wurde rot und hatte recht: Gemessen werden
   muss der Fall, den es bei Miss gibt. Jetzt nimmt sie ausdruecklich
   eine WebM auf.

Dazu zweimal derselbe alte Tritt: ein Gegen-Apostroph in einem
Kommentar INNERHALB eines Template-Literals. Der Server startet dann
gar nicht. Steht jetzt als Warnung an beiden Stellen.

GEPRUEFT -- pruef-chat-anhaenge 146 -> 163 ok

  Eine echte WebM/Opus-Aufnahme (31 972 Bytes) wird hochgeladen; die
  zweite Fassung entsteht von selbst und kommt als audio/mp4 heraus,
  mit „ftyp"-Marke, mit Accept-Ranges. ffprobe sagt: Original Opus,
  Zweitfassung AAC, 2,40 s gegen 2,46 s. Die Inhaltsuebersicht steht
  bei Byte 32, die Tondaten ab 1270 -- also vorne. Beim Loeschen
  gehen beide Dateien. Gegenproben: ein Bild bekommt keine zweite
  Fassung; eine halbierte Laenge waere aufgefallen; das Original
  bleibt unter seiner eigenen Adresse abrufbar.

  Der dritte Ausgang hat einen eigenen Zaehler: Fehlt ffmpeg, steht
  „KONNTE NICHT NACHSEHEN" mit Anleitung da -- nicht gruen und nicht
  rot. Und der Block raeumt hinter sich auf: Mein Probebild liess
  eine spaetere Pruefung rot werden, weil es die Gespraechsliste
  veraendert hatte. Eine Pruefung, die den Stand fuer die naechste
  verschiebt, ist schlimmer als keine.

  Dazu gruen: pruef-struktur 102 (92 Module) · pruef-ports 10 ·
  pruef-gifs 17 · pruef-treffchat 114 · pruef-chat-optik ·
  pruef-aufbewahrung 45.

NUR DAS AGENTURHAUS? NEIN -- der Chat gehoert beiden Haeusern, und
die Aenderung gilt fuer beide gleich. Sie aendert nichts an
Sichtbarkeit oder Rechten: Wer eine Nachricht hoeren darf, hoert sie
jetzt in einem Format, das sein Geraet kann.

OFFEN BLEIBT DIE ANTWORT VON MISS. Ob es bei ihr jetzt laeuft, weiss
nur sie -- deshalb steht ihre Meldung weiter offen und nicht auf
erledigt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 01:41:34 +02:00
DogFatherGitandClaude Opus 5 1ce2c3785e Der Hinweis an der Anmeldung sagte seit dem 01.10. das Gegenteil
VanVan im Support, Meldung #5, zweite Runde: „Man kann sich jetzt nur
noch über die richtige Schaltfläche anmelden. Das funktioniert jetzt.
Aber wenn man sich 2x versucht über den falschen Button anzumelden,
dann kommt ein irreführender Text wo steht, dass die Auswahl oben
nicht schuld ist."

SIE HAT RECHT -- UND DER GRUND IST MEINE EIGENE AENDERUNG

Auf der Crew-Wand stand ab dem zweiten Fehlversuch: „Zugangscode
stimmt nicht. Achte auf die Bindestriche -- die Auswahl oben ist
nicht schuld."

Das war am 20.09.2026 richtig. Dort gab es den STILLEN ZUGANG: Die
rechte Hand und die Modis kamen ueber die Codekennung herein, egal
welche Kachel sie antippten. Der Satz „Stimmt die Auswahl oben?"
haette sie in eine Schleife geschickt -- alle Kacheln durchprobieren,
acht Fehlversuche, Adresse gesperrt. Ein Hinweis, der die Sperre
herbeifuehrt, gegen die er helfen soll.

Mit VanVans ERSTER Runde derselben Meldung ist der stille Zugang
weggefallen („es soll fest sein"). Nachgemessen in workspace.js: Die
Kandidaten kommen seither aus `WHERE rolle = ?`, auf beiden Waenden.
Die Kachel entscheidet ueberall -- und der Satz sagte ab diesem Tag
das Gegenteil der Wahrheit, genau an der Stelle, an der jemand
feststeckt.

DAS IST DIE SORTE FEHLER, DIE EIN UMBAU HINTERLAESST: Nicht der neue
Code war falsch, sondern ein Satz drei Dateien weiter, dessen
Voraussetzung er entfernt hat. Er stand sogar ausfuehrlich begruendet
da -- und die Begruendung las sich beim Umbau wie eine Bestaetigung,
weil sie von einem Zustand sprach, den es nicht mehr gab. Gefunden
hat ihn kein Prueflauf, sondern VanVan beim Benutzen.

GEAENDERT: Ein Satz fuer beide Waende. Die Fallunterscheidung nach
Wand ist weg -- es gibt nur noch eine Regel, also auch nur noch eine
Auskunft. Der Hinweis auf die Bindestriche bleibt; er galt nie nur
fuer eine Wand.

  „Zugangscode stimmt nicht. Stimmt die Auswahl oben? Ein Code
   gehört immer zu genau einer davon – und achte auf die
   Bindestriche."

GEPRUEFT -- pruef-modi-verborgen 87 -> 94 ok

  Im echten Browser, beide Waende (crew-index.html und index.html),
  je zweimal mit einem falschen Code:
    · die Waende sind wirklich verschieden (gate--crew true/false)
    · „nicht schuld" steht nirgends mehr
    · stattdessen die Frage nach der Auswahl -- die dort seit dem
      01.10. wirklich gilt (Abschnitt 1 derselben Datei misst das)
    · derselbe Satz auf beiden Waenden
    · Gegenprobe: nach dem ERSTEN Versuch steht er noch nicht da,
      dort steht die kurze Absage. Ein Hinweis, der immer kommt,
      waere keiner.

  DIE VERSUCHSSPERRE WIRD VOR DER MESSUNG GELEERT. Acht Fehlversuche
  je Adresse in zehn Minuten -- die Abschnitte davor verbrauchen
  welche, und ab dem neunten stuende „Zu viele Versuche" statt des
  Satzes. Das waere ein roter Haken ueber die Messumgebung gewesen,
  nicht ueber das Programm.

  Dazu: pruef-struktur 102 ok.

BEIDE HAEUSER BETROFFEN, und das ist hier richtig: Es ist dieselbe
Anmeldung mit derselben Regel. Nachgewiesen wird genau das -- der
Satz ist auf beiden Waenden derselbe, und er stimmt auf beiden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 00:16:05 +02:00
DogFatherGitandClaude Opus 5 f2f11e8091 Gelesene Antworten rutschen zur Seite -- statt sich zu stapeln
VanVan im Support, Meldung #15: „Die Modis können die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unübersichtlich."

WARUM ES NICHT EINFACH EIN SCHALTER GEWORDEN IST

Am 30.09. habe ich diesen Kasten absichtlich NICHT einklappbar
gebaut, und die Begruendung steht woertlich im Quelltext: „Eine
Antwort, die man erst aufklappen muss, ist wieder keine." Sie ist
richtig -- fuer eine Antwort, die man noch nicht gelesen hat. Danach
ist sie falsch herum, und genau das meldet VanVan.

Ein Schalter, der alles wegklappt, haette die naechste Absage
mitversteckt: die Zeile, derentwegen der Kasten ueberhaupt
entstanden ist. Deshalb nicht „einklappbar", sondern GELESEN:

  · Was noch niemand quittiert hat, steht offen da -- wie bisher.
  · „Verstanden" schiebt eine Zeile hinter „N ältere Antworten",
    zugeklappt, jederzeit wieder aufzumachen. Nichts wird geloescht;
    eine Absage samt Begruendung wegzuwerfen, weil jemand sie einmal
    gelesen hat, waere das Gegenteil des Umbaus vom 30.09.
  · Ab zwei offenen Antworten gibt es „Alle N verstanden" -- wer nach
    dem Urlaub sieben vorfindet, soll nicht siebenmal tippen, und
    sieben Anfragen waeren sieben Gelegenheiten, dass eine verloren
    geht.

AM MENSCHEN, NICHT AM GERAET

`gesehen_am` steht in `vorlagen_bewerbungen`, nicht im
Browserspeicher. „Habe ich das gelesen?" ist eine Frage ueber die
Person: Sonst waere dieselbe Antwort auf dem Handy wieder neu,
nachdem man sie am Rechner gelesen hat -- und das waere genau die
Unuebersichtlichkeit, die gemeldet wurde, nur eine Tuer weiter.

Das AUFKLAPPEN der aelteren bleibt dagegen im Augenblick: kein
Zustand, den man mitschleppt. Nach dem Neuladen ist wieder zu.

GEGEN FREMDE ZEILEN GESCHUETZT: Nur die eigenen, nur die
beantworteten, 404 statt 403 -- wie ueberall im Haus. Ein zweites
„Verstanden" zaehlt nicht noch einmal, sonst wanderte die Zeile bei
jedem Klick ans Ende und man saehe nicht mehr, wann man sie wirklich
gelesen hat.

WAS ICH FALSCH ERWARTET HATTE: Meine erste Pruefung verlangte, dass
nach einem „Verstanden" OBEN NICHTS mehr steht. Sie wurde rot --
Frida hatte zwei Antworten, und der Knopf gilt je Zeile. Die
Pruefung hatte recht; dass er nur seine eigene Zeile nimmt, ist das
gewollte Verhalten und steht jetzt als Aussage dort.

NEBENBEI: Ein 11,2-px-Pfeil (gemeldet von pruef-css-klassen). Unter
11,5 px faengt im Haus die Grenze an, ab der man zusammenkneift --
dass es „nur ein Zeichen" ist, aendert daran nichts.

GEPRUEFT

  pruef-bewerbung-aufgaben  164 -> 178 ok
    Im echten Browser, am Handy (390 px): die ungelesene Antwort
    steht offen mit „Verstanden", danach ist genau diese eine Zeile
    weg (2 -> 1), sie liegt hinter „1 ältere Antwort", zugeklappt
    wird sie gar nicht erst gebaut, aufgeklappt steht sie samt
    Begruendung wieder da, „Alle verstanden" raeumt den Rest, nach
    dem Neuladen gilt beides weiter als gelesen und ist wieder zu.
    Am Server: fremde Antwort 404, erfundene Nummer 404, zweites
    „Verstanden" zaehlt 0.
  pruef-struktur 102 ok, 414 Routen (eine neue) ·
  pruef-css-klassen · pruef-zeichen 7 · pruef-modi-katalog 150 ·
  pruef-vorlagen 24 · pruef-aufgaben-vorlagen 60

NUR DAS AGENTURHAUS. „Eure Aufgaben" liegt unter `/workspace`; am
Crew-Haus aendert sich keine Zeile.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 00:10:15 +02:00
DogFatherGitandClaude Opus 5 018d02c3b8 Vorlagen anpassen: Frist, dauerhaft und eine Anmerkung
VanVan im Support, Meldung #6, zweite Haelfte -- der Rest, der heute
frueh ausdruecklich offen stehen blieb: „Bei den Vorlagen laesst sich
weder die Frist anpassen noch ‚dauerhaft' einstellen, und eine
Anmerkung fehlt auch."

EIN ZWEITER KNOPF, NICHT EIN FENSTER FUER ALLE

„An alle" verteilt zwoelf Aufgaben mit einem Druck. Haette jedes
Uebernehmen jetzt ein Fenster geoeffnet, waere der haeufige Weg
langsamer geworden, um den seltenen moeglich zu machen. Neben
„Uebernehmen" steht deshalb „Anpassen …" -- an den Creator-Vorlagen
und am Katalog, aus derselben Funktion.

DAS FENSTER IST DAS DES HAUSES

`frageNach` kann seit heute ein DATUM und einen HAKEN, die Anmerkung
konnte es als `grund` schon. Ein eigener kleiner Dialog in
vorlagenbrett.js waere der zweite im Haus gewesen -- und der, in dem
beim naechsten Mal Esc, Fokusfalle oder der Abbruch fehlen. Beide
Felder sind standardmaessig aus; fuer die dreissig anderen Aufrufe
aendert sich nichts.

DREI ENTSCHEIDUNGEN, DIE NICHT NAHELIEGEND WAREN

 1. DIE ANMERKUNG WIRD EINE NOTIZ (`aufgaben_notizen`), kein Anhang
    an der Beschreibung. Sie traegt damit, von wem sie stammt, und
    die Aufgabe zeigt sie ohnehin an. In die Beschreibung geschrieben
    waere sie von der Vorlage nicht mehr zu unterscheiden -- und
    dieselbe Vorlage haette beim naechsten Mal einen anderen Text.

 2. EINE DAUERHAFTE AUFGABE BEKOMMT KEINE FRIST, auch wenn eine
    mitgeschickt wird. Sie waere ab dem naechsten Tag fuer immer
    ueberfaellig, und eine Warnung, die immer kommt, ist keine mehr.
    Dieselbe Regel steht seit Langem im Aenderungsweg; haette sie
    hier gefehlt, gaebe es zwei Antworten auf dieselbe Frage.
    Im Fenster wird das Datum deshalb GRAU, sobald der Haken sitzt --
    ein Datum, das dasteht und nicht gilt, ist schlimmer als keins.

 3. „DAUERHAFT" DARF NUR, WER VERTEILEN DARF. Eine dauerhafte Aufgabe
    laesst sich nicht abhaken (Commit von heute frueh); wer sie sich
    selbst anlegen koennte, haette etwas, das er nie wieder loswird.
    Der Haken fehlt deshalb im Fenster eines Creators -- und
    abgelehnt wird trotzdem am Server, nicht nur ausgeblendet.

EIN FUND, DEN DIE PRUEFUNG GEMACHT HAT

`Number(tage) || 7` -- die Untergrenze des Datumsfeldes lag dadurch
sieben Tage in der Zukunft statt heute, weil Null in JavaScript
unwahr ist. Die VORGABE („in 1 Tag") lag damit UNTER der erlaubten
Grenze: Wer das Fenster oeffnete und einfach „Uebernehmen" drueckte,
bekam eine Absage. `Number.isFinite` fragt, ob eine Zahl da ist, und
nicht, ob sie wahr ist -- derselbe Unterschied wie bei `kill -0`.

NEBENBEI BEHOBEN: Ein gerades Anfuehrungszeichen in einem deutschen
Fehlersatz (gemeldet von pruef-struktur).

GEPRUEFT

  pruef-aufgaben-vorlagen   46 -> 60 ok
    eigene Frist kommt an (und die Vorlage saehe 5 Tage vor -- die
    Angabe hat also wirklich gewirkt), Anmerkung wird woertlich zur
    Notiz mit Namen, ohne Angabe bleibt alles wie bisher, Scout 403
    und es entsteht auch nichts, DogFather 200 und KEINE Frist,
    „morgen" 400, der 45.13. 400, Vergangenheit 400, und als
    Gegenprobe dieselbe Vorlage ohne Frist 200.
    Im Browser: der Knopf, das Fenster, die Vorgabe, kein Haken fuer
    einen Creator, die Aufgabe traegt danach genau die eingetragene
    Frist samt Notiz -- und Abbrechen legt nichts an.
  pruef-nachfrage           74 -> 89 ok
    Datum mit Vorgabe und Untergrenze, Haken 44 px hoch mit 22-px-
    Kaestchen (nicht ueber die ganze Zeile), Sperre und Gegenprobe,
    beide Schranken fuer ein zu fruehes Datum einzeln.
  pruef-struktur 102 · pruef-css-klassen · pruef-modi-katalog 150 ·
  pruef-aufgabenbrett · pruef-vorlagen 24 ·
  pruef-bewerbung-aufgaben 164 · pruef-zuteilung

NUR DAS AGENTURHAUS. Vorlagenbrett und Aufgaben liegen unter
`/workspace`; am Crew-Haus aendert sich keine Zeile.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 23:57:41 +02:00
DogFatherGitandClaude Opus 5 b31bd9b515 Zwei bis drei Bilder je Supportmeldung -- und zwei Funde unterwegs
VanVan im Support, Meldung #11, VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufuegen bei einer
Meldung. 2-3 waeren besser." Und in der zweiten Runde der Satz, auf
den es ankommt: „wenn man es nacheinander versucht hinzuzufuegen wird
das Bild immer nur ersetzt."

EINE TABELLE STATT NEUER SPALTEN

`support_bilder` haelt ab jetzt JEDES Supportbild -- das der Meldung
(`runde_nr` NULL) und das einer Antwort (`runde_nr` = Runde). Die
Alternative waere `bild2_datei`, `bild3_datei` gewesen, und beim
vierten Bild wieder. Eine Zeile je Bild kennt keine Obergrenze im
Schema; die Grenze steht an EINER Stelle im Code (`BILDER_MAX = 3`)
und kommt von dort in die Oberflaeche, statt dort ein zweites Mal
zu stehen.

DIE ACHT VORHANDENEN BILDER WANDERN MIT. Ohne diesen Schritt haette
die neue Tabelle ab heute recht und die alten Bilder waeren
unsichtbar -- ohne Fehler, ohne rote Zeile, nur acht leere Karten.
Der Umzug steht NACH der Spaltennachruestung: Er liest
`urteil_bild_datei`, und die gibt es in einer bestehenden Datenbank
erst, nachdem sie ergaenzt wurde. Stuende er davor, scheiterte er
genau dort, wo es darauf ankommt -- live, waehrend lokal alles gruen
bliebe, weil jede Pruefung ihre Datenbank frisch anlegt.

DREI BILDER IN EINER ANFRAGE

`x-bilder: 20481,15320` sagt, wo zu schneiden ist, der Rumpf ist die
Aneinanderreihung. `multipart/form-data` haette einen Zerleger
gebraucht, den dieses Haus nicht hat; drei Anfragen nacheinander
haetten den Zustand „Meldung da, Bild zwei laedt noch" erzeugt --
genau den, gegen den die Kommentare an dieser Route schon vorher
argumentieren. Die Summe muss auf das Byte stimmen, und jedes Stueck
wird einzeln an seinen ersten Bytes erkannt: Wer falsch schneidet,
bekommt eine Absage, kein verfaelschtes Bild. Ohne den Kopf gilt der
ganze Rumpf als ein Bild -- derselbe Satz mit einer Laenge, damit
eine Seite aus dem Zwischenspeicher weiterlaeuft.

EINE ROUTE STATT DREI. `/:id/bild` und `/:id/runde/:nr/bild` sind
weg; es gibt `/:id/bild/:bid`. Wohin ein Bild gehoert, steht in
seiner Zeile -- der Weg muss es nicht wiederholen. Die
Meldungsnummer bleibt trotzdem im Pfad: Sie ist die
Sichtbarkeitsfrage, und beides muss zusammenpassen (gemessen).

ZWEI FUNDE, DIE DIE PRUEFUNG GEMACHT HAT UND NICHT ICH

 1. UEBER DIE SEITE KAM GAR KEIN BILD MEHR AN. Beim Melden stand
    kein `Content-Type`. Das ging gut, solange der Rumpf eine
    einzelne Datei war -- ein `File` bringt seinen Typ mit. Ein
    `Blob` aus mehreren hat keinen, `fetch` schickt die Zeile dann
    gar nicht, `express.raw` fuehlt sich nicht zustaendig, und der
    Server bekam einen leeren Rumpf. Die Meldung waere durchgegangen,
    der Text angekommen, die Bilder weg -- ohne Fehlermeldung. Alle
    Pruefungen am Server waren dabei gruen; gefunden hat es erst der
    echte Browser.

 2. DAS KREUZ DES DRITTEN BILDES LAG AUF DEM ZWEITEN. Der
    Entfernen-Knopf ist 44 px breit und absolut gesetzt, der Kasten
    aber nur so breit wie sein Bild. Bei einem schmalen Bild ragt er
    darueber hinaus -- wer „das zweite weg" antippt, loescht das
    dritte. `min-width`/`min-height` loesen das an der Ursache: Ein
    Kasten ist nie schmaler als der Knopf in ihm.

WAS ICH FALSCH ANGENOMMEN HATTE: Ich hatte eingebaut, dass ein
Nachtrag in derselben Runde die Bilder ersetzt. Die Pruefung dazu
wurde rot -- zu Recht: Eine zweite Antwort in derselben Runde kann
es nicht geben, die erste verlaesst den Stand „wartet". Der Code
waere nie gelaufen und damit nie pruefbar gewesen. Er ist weg; an
seiner Stelle steht der Beweis, dass er nicht fehlt.

DREI WEITERE ROTE ZEILEN, DIE NICHT ZU DIESEM UMBAU GEHOERTEN

 * `manager-ziele.js` hatte einen ZWEITEN Notnagel
   (`frageNach ? … : confirm(…)`). `nachfrage.js` hat denselben
   laengst, und zwar mit dem vollstaendigen Text; der hiesige war
   der kuerzere und haette gewonnen. Zwei Antworten auf dieselbe
   Frage -- gemeldet von `pruef-nachfrage`.
 * Zwei Mittelpunkte in `reaktion.css` standen woertlich im
   `content`. Sie liegen im Latin-1-Block, wo `pruef-zeichen` die
   Truemmer einer verunglueckten Kodierung sucht. Jetzt als Escape
   -- im Browser nachgemessen, es steht Zeichen fuer Zeichen
   dasselbe da.
 * Das Aufraeumen nach 90 Tagen loeschte nur das EINE Bild der
   Meldung; die Bilder aus den Antwortrunden blieben ohne Zeile auf
   der Platte liegen. Die Liste kommt jetzt aus einer Abfrage statt
   aus einer Spalte und kann deshalb nicht wieder unvollstaendig
   sein.

GEPRUEFT

  pruef-support            78 -> 104 ok
    darunter: der Umzug der alten Bilder auf einer eigenen
    Wegwerf-Datenbank -- zweimal und dreimal gestartet, nichts
    verdoppelt, Datum von damals erhalten
  pruef-support-bilder     NEU, 36 ok (echter Browser)
    dreimal nacheinander waehlen ergibt drei, das vierte wird mit
    einem Satz abgelehnt, dasselbe zaehlt nicht doppelt, einzeln
    entfernen laesst die anderen stehen, alle drei laden wirklich
    (naturalWidth), Kreuze 44x44 und keines verdeckt (mit Gegenprobe
    per Deckel), nichts ragt auf 390 px heraus
  pruef-nachfrage          69 -> 74 ok
  pruef-struktur          102 ok, 413 Routen (vorher 414: zwei weg,
                          eine neu)
  pruef-zeichen             7 ok (vorher 1 Fehler)
  pruef-aufbewahrung       45 ok
  pruef-manager-ziele     216 ok
  pruef-ports              10 ok · pruef-portnummern 41 ok
    (die neue Pruefdatei verschiebt die abgeleiteten Nummern)

NUR DAS AGENTURHAUS IST BETROFFEN. Die Supportseite liegt unter
`/workspace`; am Crew-Haus aendert sich keine Zeile.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 23:38:03 +02:00
DogFatherGitandClaude Opus 5 3ab69d36a5 Eine dauerhafte Aufgabe wird nicht abgehakt -- auch nicht ueber den Status
VanVan im Support, Meldung #6: „Der Modi kann die dauerhafte Aufgabe
immer noch auf erledigt setzen."

DIE SPERRE GAB ES SEIT DEM 30.09. -- ABER NUR AN EINER TUER.

`/mein-stand` lehnt „erledigt" bei einer dauerhaften Aufgabe seither
ab. Der STATUSWEG (`PATCH /workspace/api/aufgaben/:id`) kannte
`dauerhaft` ueberhaupt nicht. Wer die Aufgabe ohnehin aendern durfte --
etwa weil das Uebernehmen aus dem Pool ihn verantwortlich macht --
hakte sie damit einfach ab. Zwei Tueren, eine Regel, und die Regel
hing nur an einer.

UND ICH HABE DAS LOCH HEUTE FRUEH VERBREITERT. Mit dem Commit davor
darf eine Modi den Status ihrer Aufgabe setzen (damit VanVans Satz
„es gibt darüber ja den button starten" fuer sie ueberhaupt stimmt).
Damit stand ihr genau der Weg offen, der ihr an der anderen Tuer
ausdruecklich verwehrt ist. Gefunden habe ich es nicht beim Bauen,
sondern beim Lesen der offenen Supportmeldungen -- ihr Satz stand seit
dem 28.09. da und passte ploetzlich auf meine eigene Aenderung.

GEAENDERT

 1. Der Statusweg lehnt „erledigt" bei einer dauerhaften Aufgabe ab,
    wenn die Person nicht verteilen darf. DERSELBE Fehlercode wie in
    `/mein-stand` (`dauerhafte_aufgabe`) -- die Oberflaeche uebersetzt
    ihn schon, und ein zweiter Code fuer dieselbe Sache waere der, den
    beim naechsten Mal jemand uebersetzt und der andere nicht.

 2. Der Knopf faellt weg, der die Absage holen wuerde (`darf_beenden`
    vom Server). Dieselbe Ueberlegung wie bei „Fertig" auf der
    Zuteilungskarte, die seit dem 30.09. daneben steht: Ein Knopf, der
    eine Absage holt, ist schlimmer als keiner.

    NUR DER LETZTE SCHRITT faellt weg. „starten" und „zur Freigabe"
    bleiben -- auch eine stehende Aufgabe hat einen Anfang, und der
    Unterschied zwischen „offen" und „in Arbeit" sagt etwas.

 3. BEENDET WIRD SIE VON DER LEITUNG. Das stand seit dem 30.09. als
    Satz im Kommentar von `/mein-stand`; jetzt stimmt er auch.

GEPRUEFT -- UND ZWAR BEIDE TUEREN, sonst wandert der Fehler nur:

    eine dauerhafte Aufgabe (#3)
    Tuer 1 (mein-stand) ist zu (409 dauerhafte_aufgabe)
    Tuer 2 (Status) jetzt auch (409 dauerhafte_aufgabe)
      anfangen darf sie trotzdem (200)
      und die Leitung beendet sie (200)
    und die Oberflaeche erfaehrt es (darf_beenden false)

Die dritte Zeile ist die Gegenprobe gegen zu viel Sperre: „gesperrt"
darf nicht heissen, dass gar nichts mehr geht.

GEPRUEFT: pruef-zuteilung 90 -> 96 ok · pruef-bewerbung-aufgaben 164 ·
pruef-aufgabenbrett 49 · pruef-aufgaben-vorlagen 46 · pruef-struktur 102.

WAS AUS DERSELBEN MELDUNG NOCH OFFEN IST (VanVan, #6): Bei den
VORLAGEN laesst sich weder die Frist anpassen noch „dauerhaft"
einstellen, und eine Anmerkung fehlt auch. Das ist ein eigener Umbau
und steht hier nur, damit es nicht untergeht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:26:55 +02:00
DogFatherGitandClaude Opus 5 2fb92f86f3 Die Routenwache war unvollstaendig -- 13 Routen waren ihr unsichtbar
GEFUNDEN ALS FEHLALARM, GEBLIEBEN IST EIN ECHTER FUND.

pruef-struktur meldete:

    assets/js/manager-ziele.js: Schnittstelle gibt es nicht
      -> /workspace/api/manager-ziele

Nachgesehen statt geglaubt: Die Seite ruft diese Adresse NIE auf. In
Zeile 31 steht `const BASIS = '/workspace/api/manager-ziele'`, benutzt
wird ausschliesslich `${BASIS}/stand`, `${BASIS}/eintraege`,
`${BASIS}/eintrag/${id}`.

DER EIGENTLICHE FUND LAG EINE EBENE TIEFER. Der SERVER registriert
seine Routen genauso:

    const BASIS = "/workspace/api/manager-ziele";
    managerZieleRouter.get(`${BASIS}/stand`, ...)

Die Sammelregel verlangte aber, dass ein Routenpfad direkt mit
`/workspace/` beginnt. ALLE DREIZEHN Routen dieses Moduls fehlten
damit in der Liste -- gemessen, nicht geschaetzt. Die Wache war also
nicht zu streng, sie war UNVOLLSTAENDIG: Zu diesen Routen konnte sie
gar nichts sagen, weder dass es sie gibt noch dass es sie nicht gibt.
Und weil die Aufrufe dorthin ebenfalls zusammengesetzt sind, ist es
nie aufgefallen -- ausser an dieser einen nackten Konstanten.

    Server-Routen eingelesen:  401 -> 414

GEAENDERT

 1. Einfache Praefix-Konstanten werden je Datei aufgeloest. Mehr
    nicht: Wer seinen Pfad aus drei Variablen zusammensetzt, bleibt
    unauffindbar -- und das ist richtig so, denn geraten wird hier
    nicht. Ohne bekannten Wert wird GAR NICHTS eingetragen; eine
    geratene Route waere schlimmer als eine fehlende, weil sie einen
    toten Aufruf als lebendig durchgehen liesse.

 2. Eine nackte Praefix-Konstante im Browser gilt als Praefix, wenn
    es Routen DARUNTER gibt. Abgeleitet, nicht aufgezaehlt -- eine
    Ausnahmeliste mit „manager-ziele" darin waere die naechste, die
    niemand pflegt.

GEGENPROBEN IN BEIDE RICHTUNGEN, weil die neue Regel etwas
durchlaesst:

    eine Praefix-Konstante wird als Praefix erkannt (Routen darunter)
    eine erfundene Adresse OHNE Routen darunter bleibt ein Fund
    und eine echte Route bleibt eine echte Route, kein Praefix

DAZU VIER SCHLUSSZEICHEN. Die Wache fuer das deutsche
Anfuehrungszeichen meldete vier Stellen in denselben zwei Dateien:
`„${was}" löschen?` und drei Geschwister. Berichtigt auf `“`.

UND EINE KORREKTUR AN MIR: Ich habe pruef-struktur heute dreimal als
„99 ok, Exitcode 0" protokolliert und dabei zwei rote Zeilen
uebersehen -- mein Filter zeigte nur die ersten drei FEHL-Zeilen und
die Zahl der gruenen. Gefunden habe ich es erst, als dieselbe Pruefung
spaeter Exitcode 1 meldete. Nachgemessen mit `git stash`: Die zwei
Funde stecken auch in HEAD, sie sind also nicht aus meiner laufenden
Arbeit gekommen. Eine Zusammenfassung, die nur die gruenen Zeilen
zaehlt, ist keine.

GEPRUEFT: pruef-struktur 102 ok, 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:25:54 +02:00
DogFatherGitandClaude Opus 5 63e3924a08 Die Erinnerungen wirklich zugestellt -- und eine Zeile, an der alles hing
`pruef-manager-ziele` prueft, was `zielRufe()` ZURUECKGIBT. Eine Liste
ist aber keine Benachrichtigung. Dazwischen liegen noch: der
Fuenf-Minuten-Takt, die Artenliste, der Schalter in der Glocke, die
Ruhezeit, die Verschluesselung und das Merkmal gegen Doppelsendungen.
Dieser Weg war genauso nie gelaufen wie der Monatswechsel -- und er
laeuft zum ersten Mal am 1. November um 00:05 Uhr, von selbst, fuer
alle gleichzeitig.

DIE WICHTIGSTE FRAGE STAND GLEICH AM ANFANG

Am 1. um 00:05 ist RUHEZEIT (Vorgabe 22 bis 7). Die Meldung darf dann
nicht herausgehen -- aber sie darf auch nicht VERFALLEN. Das haengt an
einer einzigen Zeile in `benachrichtige`: Das Merkmal wird erst
geschrieben, NACHDEM wirklich zugestellt wurde. Waere es umgekehrt,
bekaeme am 1. November niemand eine Meldung, und zwar fuer immer --
der Takt haette sie als „schon geschickt" abgehakt, waehrend alle
schliefen. Gemessen: nachts null zugestellt UND null Merkmale, um
09:00 dann zwei. Die Zeile haelt.

GEPRUEFT WIRD DIE ECHTE UHRZEIT, NICHT EINE GEFAELSCHTE RUHEZEIT. Das
Haus kann die Ruhezeit per Umgebungsvariable verstellen; das waere
hier die falsche Frage gewesen. Gefragt ist, was am 1. November um
fuenf nach null passiert -- also wird die Uhr dorthin gestellt.

WAS SONST NOCH BEWIESEN IST (20 Pruefungen, 0 Fehler)

  * Der Takt laeuft 288-mal am Tag. Drei weitere Laeufe direkt
    hintereinander stellen NICHTS mehr zu -- ohne das Merkmal
    bekaeme jeder 288 Meldungen und legte das Handy weg.
  * Der Schalter in der Glocke schlaegt die Erinnerung: Wer die Art
    abgeschaltet hat, bekommt nichts, obwohl bei ihm etwas offen ist.
  * Ein Creator bekommt nichts (er hat diese Pflichten nicht), und
    wer kein Geraet angemeldet hat, erzeugt keine Geisterzustellung.
  * Am 11. ist Ruhe. Ohne diese Gegenprobe hiesse „es kommt an"
    moeglicherweise „es kommt jeden Tag", und der ganze Terminplan
    der Vorlage waere wirkungslos.
  * Der Glueckwunsch kommt genau einmal, und danach ist fuer den
    Fertigen Ruhe -- waehrend der andere am 16. weiter gemahnt wird.

GEMESSEN WERDEN ZUSTAENDE, NICHT ZEITPUNKTE. Der Server startet seinen
eigenen Takt und kann jederzeit in die Pruefung hineinlaufen. Deshalb
steht nirgends „nach meinem Aufruf kamen genau drei dazu", sondern „in
push_verschickt steht jetzt genau eine Zeile je Person".

UND DIE ERSTE PRUEFUNG DER DATEI PRUEFT DIE PRUEFUNG. „Null
zugestellt" ist gleich die erste erwartete Antwort; ohne den Beweis,
dass ueberhaupt etwas ankommen KANN, hiesse sie moeglicherweise „der
Dienst ist gar nicht angeschlossen".

NUR DIESE EINE DATEI IST DRIN. Der erste Anlauf dieses Commits hat
mit `git add -A` drei Dateien mitgenommen, die ich nie angefasst
habe -- sie wurden waehrenddessen von einer anderen Sitzung im selben
Verzeichnis bearbeitet (Zeitstempel: Sekunden alt). Zurueckgenommen,
bevor etwas gepusht wurde; ihre Arbeit liegt unveraendert im
Arbeitsverzeichnis.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:18:56 +02:00
DogFatherGitandClaude Opus 5 72e51c57b6 Den Monatswechsel durchgespielt -- und zwei stille Fehler gefunden
Der zentrale Weg dieser Kachel war nie gelaufen: „Am 1. jedes Monats
um 00:00 Uhr starten alle Zaehler automatisch bei 0." Der erste
Oktober lag vor der Auslieferung, der erste November liegt dahinter.
Er laeuft ohne Zutun und betrifft alle gleichzeitig -- waere er
falsch, waere er fuer alle auf einmal falsch, und niemand wuesste
warum.

Dieselbe Lage gab es am 01.09.2026 schon einmal in RunOne (der Umbau
zur Hashkette war seit Tagen live und nie gelaufen). Die Lehre stand
danach in der Projektnotiz, und sie gilt hier woertlich: Was sich
nicht zuruecknehmen laesst, wird vorher auf einer Kopie durchgespielt.

`pruef-monatswechsel.mjs` verstellt dafuer die Uhr -- eine Huelle um
`Date`, ganz oben, vor jedem Import. Damit laeuft der ECHTE Code
durch zwei echte Monatswechsel (20. Oktober -> 1. November, 00:05 ->
3. Dezember), ohne dass eine Zeile dafuer umgebaut werden muesste.
41 Pruefungen, 0 Fehler: Zaehler bei 0, keine Warnung am ersten Tag,
die neuen Zielzahlen greifen, die alten Eintraege stehen unveraendert
da, der Oktober ist zu, der Verlauf misst ihn am OKTOBER-Ziel, und
„Neuer Monat" geht an alle drei -- auch an den, der den Oktober voll
hatte.

WAS DABEI AUFGEFALLEN IST -- zwei Fehler, beide stumm

1. DIE SUCHE NACH DEM ROLLENWECHSEL MASS DIE FALSCHE PERSON.
   `WHERE person_id = ?` im Protokoll findet den, der die Aenderung
   GEMACHT hat -- also DogFather --, nicht den, dessen Rolle sich
   geaendert hat (workspace-personen.js schreibt die betroffene
   Nummer ins `detail`). Fuer den Betroffenen fand die Abfrage
   deshalb nie etwas; bei DogFather schob jede fremde
   Rollenaenderung SEINEN Pflichtbeginn. In der echten Datenbank
   stand bei ihm „grund: rollenwechsel" -- ein plausibler Wert aus
   der falschen Zeile.

   NACHGESEHEN, OB ES SCHADET: Alle sechs Zeilen stehen auf 2026-10,
   und das ist ohnehin der frueheste moegliche Monat. Der Fehler
   hatte noch keine Wirkung -- er haette sie beim naechsten
   Rollenwechsel bekommen.

2. `node:sqlite` BINDET EINE ZAHL ALS REAL.
   Der erste Versuch der Reparatur lautete
   `detail LIKE ('#' || ? || ' %')` und traf NIE -- ohne Fehler, ohne
   Warnung, immer leer. Nachgemessen:

       SELECT ('#' || ? || ' %')  mit der Zahl 42  ->  '#42.0 %'

   Gesucht wurde „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
   als Zeichenkette stimmt es sofort. Das Muster wird jetzt in
   JavaScript gebaut; im uebrigen Haus kommt dieselbe Verkettung mit
   einer Zahl nicht vor (nachgesehen).

   Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den
   ECHTEN Weg benutzt (die Route der Personenverwaltung) statt den
   Protokolltext selbst zu schreiben. Haette sie ihn selbst
   geschrieben, haette sie ihre eigene Annahme geprueft und waere
   gruen gewesen.

AUSSERDEM BERICHTIGT

Der Pflichtbeginn wurde EINMAL gesetzt und nie wieder angesehen
(`INSERT OR IGNORE`). Wer die Rolle verliert und spaeter zurueck-
bekommt, haette damit keinen Schonmonat mehr bekommen, obwohl die
Vorlage ihn zusichert. Jetzt wird neu gerechnet, wenn seit der
Festlegung ein Rollenwechsel dazugekommen ist -- und nur dann;
geprueft wird ausdruecklich, dass ein zweiter Abruf nichts bewegt.

GEPRUEFT: 216 + 41 = 257 Pruefungen, 0 Fehler

Mit drei Gegenproben an der neuen Stelle: DogFathers Pflichtbeginn
bewegt sich NICHT, wenn er die Rolle eines anderen aendert; ein
unbeteiligter Scout behaelt seinen Wert; und ein zweiter Abruf
rechnet nichts neu. Ohne die erste waere der Fehler von oben
unentdeckt geblieben, ohne die zweite hiesse „einer hat sich
geaendert" vielleicht „alle".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 18:02:28 +02:00
DogFatherGitandClaude Opus 5 baf8288433 Korrigieren duerfen jetzt beide: Spicy Media und DogFather
Filipe auf die Rueckfrage, wer fremde Eintraege richtigstellen darf:
„ja spicy und dogfather".

Die Vorlage kannte dort nur DogFather („Vergangene Monate sind
gesperrt ... Ausnahme: DogFather"). Das gilt ab jetzt fuer beide --
und zwar fuer BEIDE Faelle, nicht nur fuer einen:

  * einen fremden Eintrag aendern oder loeschen
  * einen abgeschlossenen Monat dafuer kurz oeffnen

ES IST EINE MENGE UND KEINE ZWEITE LISTE. `darfKorrigieren()` gibt
`siehtAlles()` zurueck -- dieselbe Menge, die schon ueber die
Team-Uebersicht und die Zielzahlen entscheidet. Eine eigene
Aufzaehlung derselben zwei Rollen waere die, die beim naechsten Umbau
auseinanderlaeuft. Und inhaltlich gehoert es zusammen: Wer alle
Zahlen sieht und die Ziele setzt, muss einen Zahlendreher gerade
ruecken koennen; zwei verschiedene Grenzen fuer „darf alles sehen"
und „darf etwas richtigstellen" koennte spaeter niemand mehr
erklaeren.

Der Name der Variablen hiess vorher `istAdmin` -- also die Rechnung
statt ihrer Bedeutung. Jetzt heisst sie, was sie beantwortet.

NACHVOLLZIEHBAR BLEIBT ES UNVERAENDERT: Jede Korrektur an einem
fremden Eintrag und jede Aenderung an einem abgeschlossenen Monat
steht mit Name, Rolle und Zeit im Protokoll -- geprueft wird jetzt
ausdruecklich, dass dort auch `spicy` auftaucht.

GEPRUEFT: 206 Pruefungen, 0 Fehler (vorher 194)

Die Grenze wird in beide Richtungen gemessen, nicht nur in eine:
Spicy aendert wirklich (200, und der neue Wert steht in der
Datenbank), Spicy loescht wirklich -- aber ein Manager und ein
fremder Scout werden weiterhin abgewiesen (403), und danach steht
immer noch der Wert von Spicy da. Ohne diese Gegenproben hiesse
„Spicy darf" moeglicherweise „jeder darf". Loeschen ist eigens
geprueft: PATCH und DELETE sind zwei Routen, und zwei Routen koennen
auseinanderlaufen.

Auch die Freigabe fuer alte Monate wird nach Spicys Korrektur wieder
geschlossen gemessen -- eine geoeffnete Tuer ist kein Erfolg.

EINE MEINER PRUEFUNGEN WAR WIEDER FALSCH, NICHT DER CODE: Ich hatte
erwartet, dass ein Manager mit einer fremden Nummer in der Adresse
„nicht bearbeitbar" bekommt. Er bekommt `true` -- weil er gar keine
fremde Liste bekommt, sondern seine eigene; die Nummer wird fuer ihn
schlicht nicht beachtet. Richtiges Verhalten, falsche Frage. Gemessen
wird jetzt, was zaehlt: dass bei ihm kein einziger fremder Eintrag
ankommt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 17:52:36 +02:00
DogFatherGitandClaude Opus 5 b46f75484f Ein Klick statt eines Formulars -- der Schnell-Eintrag
Filipe mit dem Bildschirmfoto der Zeilen: „ich will das viel perfekter
und geiler. will dass es viel einfacher ist. am besten so wenig wie
moeglich zu tippen. fertige sachen, bereit um ab zu gehen."

GEZAEHLT, WAS EIN EINTRAG VORHER KOSTETE -- das war der Punkt:

    Manager Meeting   Knopf, Dialog, Speichern     2 Klicks + Fenster
    Schulung          dazu die Art waehlen         3 Klicks + Fenster
    Werbung           dazu den Link tippen         2 Klicks + tippen
    Creator           dazu den Namen tippen        2 Klicks + tippen

Ein Fenster fuer die Aussage „ich war heute im Meeting" sind drei
Handgriffe fuer null Angaben. Vier Aufgaben im Monat, acht Klicks,
acht Fenster.

JETZT

    Manager Meeting   EIN Klick  („Heute eingetragen")
    Schulung          EIN Klick  (zwei fertige Knoepfe)
    Werbung           Einfuegen + Eintragen, kein Tippen
    Creator           ein Klick je Lead -- oder mehrere Namen auf
                      einmal einwerfen

Am echten Bildschirm nachgemessen: 0/2 vorher, EIN Klick, 1/2 nachher,
„Rueckgaengig" bringt 0/2 zurueck. Nicht „der Knopf ist da", sondern
„danach steht eine andere Zahl dort".

DREI ENTSCHEIDUNGEN DAHINTER

1. DER WEG NIMMT EINE LISTE. „Drei Creator auf einmal" ist EIN
   Vorgang. Wer drei Namen aus Discord kopiert, hat das Monatsziel in
   einem Zug erledigt -- das ist der eigentliche Gewinn, nicht der
   gesparte Klick.

   JEDER EINTRAG WIRD EINZELN GEPRUEFT UND EINZELN BEANTWORTET. Die
   ganze Liste zurueckzuweisen, weil ein Name schon dasteht, waere
   die bequeme und die falsche Loesung: Dann weiss niemand, welcher
   der drei das Problem war, und tippt alles noch einmal. Geprueft
   wird genau dieser Fall -- zwei gehen durch, einer wird im Klartext
   abgelehnt, mit Namen.

2. KEINE SICHERHEITSABFRAGE VORHER, SONDERN „RUECKGAENGIG" DANACH.
   Eine Nachfrage bei jedem Klick waere der Handgriff, den wir gerade
   abgeschafft haben, in neuer Verkleidung. Sie kostet jeden; das
   Zuruecknehmen kostet nur den, der sich vertippt hat. Acht Sekunden
   statt drei -- lang genug, um es mit einem Daumen zu treffen.

3. DIE ZULETZT GEWAEHLTE ART IST VORGEWAEHLT -- abgeleitet aus dem
   letzten Eintrag, nicht in einer Einstellung gespeichert. Eine
   Spalte „Lieblingsart" waere ein zweiter Bestand, der veralten
   kann; der letzte Eintrag veraltet nie.

WAS MIR DABEI AUFGEFALLEN IST

Ein Ein-Klick-Knopf macht den Doppeltipper zur wahrscheinlichsten
Fehleingabe -- bei „Heute eingetragen" merkt man nichts davon, es gibt
ja keine Angabe. Zwei Meetings an einem Tag zu VERBIETEN waere aber
falsch, sie sind moeglich. Also ein Hinweis statt einer Sperre:
„Fuer diesen Tag stehen jetzt 2. War das Absicht?" -- und das
Zuruecknehmen liegt ohnehin daneben. Mit Gegenprobe, dass beim ERSTEN
keine Warnung kommt; sonst waere sie keine Warnung, sondern ein
Begleittext.

AUSSERDEM

  * Die Zeilen bauen sich beim Eintragen nicht mehr neu, sondern
    ziehen nur die Zahlen nach. Sonst verschwaende das Feld, in dem
    man gerade tippt, unter der Hand.
  * „Einfuegen" holt den Link aus der Zwischenablage -- mit drittem
    Ausgang: Firefox gibt sie ohne Erweiterung nicht her, und dann
    wird das gesagt, statt dass ein Knopf stumm bleibt.
  * Im Dialog drei fertige Tage (Heute / Gestern / Vorgestern) statt
    eines Kalenders. Was nicht mehr in diesen Monat gehoert, wird gar
    nicht erst angeboten -- am 1. und 2. fallen welche weg.
  * Der Dialog heisst jetzt „Mehr Angaben" und ist die Ausnahme fuer
    Notiz oder altes Datum. Dass er fuer etwas Normales gebraucht
    wurde, war sein Fehler.

GEPRUEFT: 194 Pruefungen, 0 Fehler (vorher 168)

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 17:48:04 +02:00
DogFatherGitandClaude Opus 5 f6d2437985 Manager-Ziele zu Ende gebaut -- und dabei zwei Loecher gefunden
Filipe: „perfektioniere alles jetzt sofort, es muss ready sein."

Die Vorlage Punkt fuer Punkt gegen das Gebaute gehalten, nicht gegen
meine eigene Liste von heute Mittag. Vier Punkte standen noch offen,
und auf dem Weg dorthin sind zwei Fehler aufgefallen, nach denen
niemand gesucht hat.

DIE ZWEI FEHLER ZUERST -- beide gefunden durch Messen, nicht Denken

1. EINE GELOESCHTE PERSON HAETTE IHRE ZAHLEN MITGENOMMEN.
   `mz_eintrag.person_id` stand auf ON DELETE CASCADE. Die Vorlage
   sagt aber: „Wer die Rolle verliert, sieht die Kachel nicht mehr;
   die Daten bleiben fuer den DogFather erhalten." Mit CASCADE waere
   genau das nicht wahr gewesen -- `DELETE FROM personen` haette den
   Monatsverlauf eines Menschen lautlos mitgenommen.

   Jetzt SET NULL, und Name und Rolle stehen zusaetzlich als Text am
   Eintrag (dieselbe Bauweise wie bei support_meldungen). Die Rolle
   ist nicht Zierde: Ohne sie wuerde ein abgeschlossener Monat
   rueckwirkend an den Zielzahlen einer anderen Rolle gemessen.

   Der Umbau laeuft auf dem Bestand von heute Mittag -- Spaltenliste
   AUS PRAGMA abgeleitet, nicht gepflegt, und geprueft werden Zeilen
   UND Spalten. Am 11.09.2026 hat genau so ein Umbau drei Spalten mit
   Inhalt verloren, ohne Fehlermeldung, bei unveraenderter Zeilenzahl.

2. MEIN EIGENER SPERR-TRIGGER HAETTE DAS LOESCHEN BLOCKIERT.
   ON DELETE SET NULL ist kein Loeschen, sondern ein UPDATE auf
   person_id. Der Trigger sah eine Aenderung an einem abgeschlossenen
   Monat und brach ab -- `DELETE FROM personen` waere damit
   gescheitert, an einer Stelle, die mit Monatszielen nichts zu tun
   hat. Erlaubt ist jetzt genau eine Aenderung an einem alten Monat:
   dem Eintrag seinen Besitzer zu nehmen. Als BEDINGUNG und nicht als
   `UPDATE OF <spaltenliste>` -- eine Liste muesste jemand pflegen.

   Weil `CREATE TRIGGER IF NOT EXISTS` eine geaenderte Fassung nicht
   erneuert, wird die alte am INHALT erkannt und ersetzt. Eine
   Fassungsnummer muesste jemand hochzaehlen, und das wird vergessen.

DIE VIER OFFENEN PUNKTE DER VORLAGE

  04  Jede Aufgabenzeile hat ihr eigenes Zeichen -- aus dem Haus
      (`window.Bereiche`), nicht neu gezeichnet: Trichter, Bildschirm,
      Buch, Rahmen. Das Statuszeichen bleibt daneben; ein eingefaerbtes
      Aufgabenzeichen allein traegt die Stufe nicht.
  05  „Farbiger Rand + Badge": Eine Kachel, an der eine Warnung haengt,
      traegt jetzt einen feinen Saum -- JEDE Kachel, nicht nur diese.
      Eine Regel, die nur an einer Stelle gilt, wird beim naechsten Mal
      vergessen.
  08  Die Team-Tabelle ist sortierbar: jede Spalte ein Knopf (kein
      anklickbares <th> -- das erreicht die Tastatur nicht), mit
      aria-sort, und sortiert wird nach ANTEIL statt nach nackter Zahl.
      Dazu eine Ampel-Spalte mit Wort. Auf dem Handy verschwindet die
      Kopfzeile im Kartenmodus, deshalb steht das Sortieren zusaetzlich
      in der Leiste -- sonst waere es auf einem Telefon nicht
      vorhanden.
  09  Wer die Rolle verliert, steht weiter in der Uebersicht, als
      „nicht mehr dabei" und mit der Rolle von damals. Wer geloescht
      wurde, erscheint als zusammengefasste Zeile unter dem
      mitgeschriebenen Namen.

WAS DER SAUM MICH GELEHRT HAT

Er stand zuerst in start.css und war wirkungslos -- der Browser
lieferte weiter den Faseschatten. Der Grund steht seit dem 25.09.2026
in module.css: `:is()` uebernimmt die Spezifitaet seines staerksten
Arguments, und `.gruppe[data-gruppe]` macht die ganze Modulliste
(0,2,0) -- genau so stark wie `.kachel[data-warn="ja"]`, bei
Gleichstand gewinnt die zuletzt geladene Datei. Dieselbe Falle wie
damals bei den Fokusringen, dieselbe Antwort: Was gegen die Modulform
gewinnen muss, gehoert in die Datei mit der Modulform. Gemerkt habe
ich es nur, weil die Bildmessung den errechneten Schatten AUSGIBT
statt ein Bild zu machen.

Beim Herausschneiden blieb eine Klammer zu viel in start.css stehen --
gefunden von pruef-css-klassen („eine schliessende Klammer ohne
oeffnende"), bevor sie still CSS verschluckt hat.

AUSSERDEM BEHOBEN

  * Spicy Media sah an einer FREMDEN Liste „Bearbeiten" und „Loeschen",
    und der Server antwortete mit 403. Ein Knopf, der nichts tut, ist
    schlimmer als kein Knopf.
  * Klick auf eine Person klappt jetzt alle vier Zeilen auf. Die
    Vorlage verspricht „zeigt deren Eintraege" -- zugeklappt zeigte
    der Klick nur Zahlen.
  * Der CSV-Export kennt drei Staende statt zwei: „pflichtig", „neu,
    noch ohne Pflicht", „nicht mehr dabei". Vorher hiess beides „nein".
  * Das Aufklappen baute die ganze Liste neu und riss den
    angeklickten Knopf weg (Fokus sprang nach oben).

GEPRUEFT: 168 Pruefungen, 0 Fehler (vorher 141)

Neu darunter: der Umbau auf einem echten Alt-Bestand (Zeilen, Spalten,
Inhalt, Indizes, Trigger, und ein zweiter Lauf, der nichts mehr tut),
das Loeschen einer Person mit Eintraegen aus einem abgeschlossenen
Monat -- mit Gegenprobe, dass dieselbe Sperre den INHALT weiterhin
nicht aendern laesst.

Zwei meiner neuen Pruefungen haben zuerst sich selbst gemessen statt
den Code: Eine verglich gegen einen Eintrag, den sie vorher geloescht
hatte (404 sah aus wie ein haltender Riegel), die andere meldete eine
fehlende Spalte, die nur ihr eigener Handeinsatz verursacht hatte.
Beide berichtigt.

Am Bildschirm nachgemessen bei 412 px und 1280 px: kein waagerechtes
Schieben, kein eigenes Beruehrziel unter 40 px, genau EINE Kachel mit
Saum und zwanzig ohne.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 17:34:58 +02:00
DogFatherGitandClaude Opus 5 db656f6e14 Manager-Ziele: vier feste Monatsaufgaben, die sich selbst zuruecksetzen
Filipe mit der Vorlage „Prompt · Kachel Agentur-Aufgaben" (02.10.2026):
Scouts, Manager, DogFather und Spicy Media bekommen vier Pflichten je
Monat -- Creator rekrutieren, Manager-Meeting, Schulung oder Community
Talk, Werbung auf TikTok -- mit Fortschritt, Ampel, Warnungen und
einem Monatsschnitt, der nichts loescht.

DREI ENTSCHEIDUNGEN, DIE VON DER VORLAGE ABWEICHEN -- alle abgestimmt:

1. DIE KACHEL HEISST „Manager-Ziele", nicht „Agentur-Aufgaben".
   Es gibt bereits eine Kachel „Agentur" und eine „Aufgaben". Eine
   dritte mit beiden Woertern im Namen waere auf einem Handy nicht
   mehr auseinanderzuhalten.

2. DIE ZIELZAHLEN GELTEN JE ROLLE, und DogFather UND Spicy Media
   duerfen sie aendern. Ein Scout muss nicht dieselbe Zahl schaffen
   wie die Leitung.

3. DIE SCOUT-PIPELINE IST ANGEBUNDEN, in beide Richtungen: Vorschlaege
   aus uebergebenen Leads, Namensvorschlaege beim Tippen, die
   Verbindung bleibt am Eintrag gespeichert. Aber NICHTS zaehlt von
   selbst -- gezaehlt wird nur, was ein Mensch bestaetigt hat. Ein
   Zaehler, der sich allein fuellt, ist einer, dem niemand glaubt.

WAS ANDERS GEBAUT IST, ALS ES NAHELAG

DAS ZIEL WIRD PRO MONAT EINGEFROREN (`mz_ziel` hat den Monat im
Schluessel). Laege nur ein aktueller Wert in `einstellungen`, schriebe
jede spaetere Aenderung rueckwirkend den ganzen Verlauf um: Ein Monat,
der mit 2/2 abgeschlossen war, staende nach einer Erhoehung auf 4
ploetzlich als „nicht erreicht" da. Ein Verlauf, der sich rueckwirkend
aendert, ist keiner. Es gibt deshalb gar keinen Weg, den laufenden
Monat umzuschreiben -- gespeichert wird immer in den naechsten.

DIE SPERRE VERGANGENER MONATE SITZT IN DER DATENBANK, nicht im Code
(drei Trigger). Die Ausnahme fuer DogFather laesst sich in SQLite
nicht ueber die Sitzung abfragen, also ist sie ein sichtbarer Vorgang:
`mz_freigabe` wird fuer die eine Handlung geoeffnet, im `finally`
wieder geschlossen und verfaellt nach zwei Minuten von selbst. Jede
Korrektur steht mit Name und Zeit im Protokoll.

DER LAUFENDE MONAT STEHT IN EINER TABELLE (`mz_lage`), nicht in
`strftime(...,'localtime')`. Sonst entschiede die Zeitzone des Servers,
und am Monatsersten zwischen 00:00 und 02:00 griffe die Sperre fuer
den falschen Monat. Gerechnet wird durchgehend in Europe/Berlin
(identisch mit dem Europe/Luxembourg der Vorlage, aber dieselbe
Zeitrechnung wie der Rest des Hauses).

KEIN ZWEITER ZAEHLER FUER DIE KACHELWAND. Rand und Abzeichen auf der
Startseite kommen aus `workspace-hinweise.js` und damit aus derselben
Rechnung wie die Seite (`standFuer`). Zwei Rechnungen ueber dieselbe
Sache laufen auseinander, und zwar lautlos.

KEIN IMPORTKREIS ZU workspace-push.js. Die Erinnerungen entstehen hier
als Liste (`zielRufe`), verschickt werden sie im vorhandenen
Fuenf-Minuten-Takt. Der Tag steht im Merkmal -- dadurch geht pro
Person hoechstens EINE Meldung am Tag heraus, obwohl der Lauf
288-mal stattfindet.

GETRENNTE HAEUSER: Auf crew.dogfather-universe.com gibt es diese
Kachel nicht, auch nicht fuer DogFather. Gemessen, nicht angenommen.

GEPRUEFT (141 Pruefungen, 0 Fehler) -- mit Gegenproben zu jeder Sperre

  * Vier Rollen kommen herein, drei bekommen 404 (nicht 403), und die
    ANZAHL steht in der Bedingung. „Alle abgewiesen" waere auf einer
    leeren Liste wahr.
  * Die Ampel wird mit EINGESETZTEN Tagen gemessen, nie gegen die
    Wanduhr -- diese Pruefung sagt am 16. November dasselbe wie heute.
    (gate-oeffnung.mjs im Shop war gruen, bis der Kalender sie
    ueberholte.)
  * Die Datenbank lehnt einen Eintrag im Vormonat selbst ab; danach
    wird nachgewiesen, dass die Freigabe nur EINMAL gewirkt hat.
  * Neun Absagen mit dem jeweils richtigen Grund -- und eine
    Instagram-Adresse, die durchgehen MUSS, weil sonst nur bewiesen
    waere, dass die Pruefung streng ist, nicht dass sie richtig ist.
  * Am 7. des Monats ist Ruhe: Ohne diese Zeile bewiese der
    Erinnerungs-Block nur, dass immer etwas kommt.

ZWEI BEFUNDE KAMEN AUS DER MESSUNG, NICHT AUS DEM NACHDENKEN

  * Beim Aufklappen einer Zeile wurde die ganze Liste neu gebaut --
    der angeklickte Knopf existierte danach nicht mehr, der Fokus
    sprang an den Seitenanfang. Gefunden hat es die Bildmessung, der
    die Schaltflaeche unter der Hand wegbrach.
  * Zwei meiner Messungen waren falsch, nicht der Code: Der
    Haus-Test schickte den Keks nicht mit (401 statt 404), und
    `Response.text()` entfernt ein BOM beim Dekodieren -- der Export
    hatte eines, die Pruefung sah es nur nicht. Jetzt wird in Bytes
    gemessen.

Kachelton 47 (#7368ff) ist mit tools/kachel-farbe-einzeln.mjs gegen
alle 46 vorhandenen gerechnet, nicht ausgesucht: Abstand 0,0899,
Kontrast 4,61:1. Beruehrziele, waagerechtes Schieben und
Schriftgroessen sind am Bildschirm bei 412 px und 1280 px nachgemessen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 16:53:09 +02:00
DogFatherGitandClaude Opus 5 731537b682 Eine Wahrheit statt zwei: Der Status der Aufgabe gilt
VanVan im Support (Runde 1, „Ging noch nicht"): „Das dort steht ich
bewerbe mich ist jetzt weg, aber dafür hat er noch mal 2 Buttons
hinzugefügt mit ich fange an und fertig. Wenn man auf ich fange an
drückt steht dort in Bearbeitung und wenn man auf fertig drückt dann
wird es zu erledigt. Die Aufgabe bleibt aber im status offen stehen.
Die beiden Buttons können entfernt werden, weil es darüber ja den
button starten gibt, der auch korrekt funktioniert."

SIE HAT ETWAS GROESSERES GEFUNDEN ALS ZWEI UEBERFLUESSIGE KNOEPFE.

Es gab ZWEI Zustaende nebeneinander, und sie kannten sich nicht:

    aufgaben.status             offen · arbeit · review · erledigt
    aufgaben_zuteilung.zustand  angenommen · arbeit · erledigt

Die zwei Knoepfe setzten den zweiten (`/mein-stand`), der
Starten-Knopf den ersten. Auf der Karte stand „in Bearbeitung", in der
Liste „offen" -- und beides stimmte. Das ist schlimmer als ein Fehler:
Es gibt nichts, dem man glauben kann. Zwei Antworten auf dieselbe
Frage sind in diesem Haus verboten, und genau das war es.

WAS ICH BEINAHE FALSCH GEMACHT HAETTE

Ihr Wunsch war „entfernt die Knoepfe". Bevor ich das tue, habe ich
gemessen, was danach bliebe -- am Bildschirm einer Modi mit einer
angenommenen Aufgabe:

    Karten-Knoepfe:  []
    Schritt-Knoepfe: []

KEIN EINZIGER. Die Modi sieht den Starten-Knopf NICHT, weil
`darfAendern` fuer sie falsch ist: Sie ist weder Leitung noch
`creator_id`, `verantwortlich_id` oder `erstellt_von` -- die Zuteilung
laeuft ueber eine eigene Tabelle. VanVan ist Leitung und sieht ihn;
deshalb klang „den gibt es doch" selbstverstaendlich.

Haette ich die Knoepfe einfach geloescht, haette ich der Modi die
einzige Handlung weggenommen, die sie hatte -- eine Meldung „behoben",
nach der weniger geht als vorher.

ALSO WIRD IHR SATZ WAHR GEMACHT

 1. Wer eine Aufgabe WIRKLICH hat (angenommen/arbeit/erledigt), darf
    ihren STATUS setzen. Damit sieht die Modi denselben Knopf wie alle
    -- gemessen: „Schritt-Knoepfe: [starten ▶]".

    ENG GEFASST: nur der Status, nur allein in der Anfrage. Die
    Pruefung ist `Object.keys(...).length === 1` und nicht „enthaelt
    status" -- sonst waere die schmale Tuer die breite mit einem
    Zusatzfeld.

 2. Der Statuswechsel zieht die Zuteilung MIT. Ohne das waere das
    Entfernen eine stille Verschlechterung gewesen: Die Zaehler einer
    Person („offen / in Arbeit / erledigt") lesen die ZUTEILUNG, nicht
    die Aufgabe. Jede Zuteilung waere fuer immer auf „angenommen"
    stehen geblieben, und die Zahlen haetten aufgehoert, die
    Wirklichkeit zu zeigen -- ohne dass irgendwo etwas rot wird.

    ABGELEITET, NICHT ZWEIMAL GESCHRIEBEN: `STATUS_ALS_ZUSTAND` gibt
    es seit dem 22.09. Benutzt wird genau sie, mit EINER Abweichung,
    und die steht daneben: Wer zugesagt hat, faellt beim Zurueckdrehen
    auf „angenommen", nicht auf „offen". Eine Zusage verschwindet
    nicht, weil jemand den Status zurueckstellt.

 3. Die zwei Knoepfe sind weg. Der Weg `/mein-stand` bleibt -- er ist
    die Schranke fuer den, der die Schnittstelle direkt anspricht.

GEGENPROBEN ZUM ERWEITERTEN RECHT (ein Recht ohne Gegenprobe ist ein
Loch mit Begruendung):

    Anna hat eine Aufgabe, die ihr NUR zugeteilt ist
      (darf_aendern false, darf_status true)
    sie setzt den Status ihrer Aufgabe (200)
    mit einem zweiten Feld kommt sie nicht durch (403)
    und umschreiben darf sie gar nicht (403)
    der Titel steht unveraendert da („Clips schneiden")
    und wer sie nicht hat, setzt auch keinen Status (404)
    zurueckgedreht steht Anna wieder auf „angenommen"

    eine Bewerbung bleibt eine Bewerbung (abgelehnt -> abgelehnt)

ZWEI EIGENE FEHLER, BEIDE VON DER MESSUNG GEFUNDEN:

 · Mein erster Zeuge war Bea und die Pool-Aufgabe. Die Gegenprobe
   wurde rot: Bea darf sie ohnehin umschreiben, weil das Uebernehmen
   aus dem Pool sie verantwortlich macht. An ihr laesst sich ueber die
   neue, schmale Tuer gar nichts zeigen. Der reine Fall wird jetzt
   GESUCHT (darf_aendern falsch, Zuteilung angenommen) statt
   hingeschrieben -- eine feste Nummer waere die naechste, die beim
   naechsten Umbau nicht mehr stimmt.
 · Mein Abschnitt stellte Annas Aufgabe auf „arbeit" und liess sie so
   stehen; ein spaeterer zaehlte ihre „angenommen" und wurde dadurch
   rot. Eine Pruefung, die den Bestand fuer die naechste veraendert,
   misst ab da etwas anderes als sie glaubt. Jetzt raeumt sie auf --
   und die Rueckfahrt ist selbst eine Messung.

ZWEI PRUEFUNGEN MUSSTEN MITZIEHEN, und das ist richtig so: Beide
verlangten „Ich fange an" -- geschrieben von mir am 30.09. fuer
VanVans ERSTE Meldung. Ihre Absicht bleibt woertlich dieselbe („kann
sie wirklich etwas tun?"), nur ist der Griff jetzt der Statusknopf.
`knoepfeAn` sieht dafuer auch neben den Zuteilungsblock: „kann sie
etwas tun?" laesst sich am Block allein nicht beantworten.

GEPRUEFT: pruef-zuteilung 75 -> 90 ok · pruef-bewerbung-aufgaben
162 -> 164 ok · pruef-struktur 99 · pruef-resuemee 35 ·
pruef-aufgabenbrett 49 · pruef-rechtetafel 19 ·
pruef-aufgaben-vorlagen 46.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 15:11:35 +02:00
DogFatherGitandClaude Opus 5 664b799568 Geprüft: Die Benachrichtigung kommt an, wenn die App ZU ist
Filipe: „mach noch einen check ob alles mit den benachrichtigungen jetzt
perfekt klappt und dass die leute sie auch bekommen wenn die app zu ist."

DREI PRUEFUNGEN GAB ES SCHON -- UND KEINE BEANTWORTET DIE FRAGE

    pruef-push       die Verschluesselung, gegen die Testvektoren aus
                     RFC 8291 und RFC 8292 gerechnet        (24 ok)
    pruef-push-weg   Zustellung, TTL, VAPID-Kopf, 410-Fall  (20 ok)
    pruef-push-ziel  wer was bekommt und wer nicht          (38 ok)

Sie hoeren alle beim Push-Dienst auf. Danach faengt der Teil an, um den
es geht: Der Browser muss den Service Worker AUFWECKEN, obwohl keine
Seite offen ist, und der muss etwas anzeigen.

NEU: pruef-push-zu.mjs (13 Pruefungen)

    1. Seite auf, Service Worker meldet sich an.
    2. ALLE Seiten des Workspace zu -- nachgezaehlt, nicht behauptet.
    3. Ein echter Push ueber das DevTools-Protokoll
       (`ServiceWorker.deliverPushMessage`) -- derselbe Weg, den
       Apple und Google benutzen.
    4. Erst DANACH wieder eine Seite, und gefragt, was dasteht.

Eine Meldung, die in Schritt 4 dasteht, kann nur in Schritt 3
entstanden sein. Gemessen:

    nach dem Push steht 1 Meldung da — bei geschlossener App
      „Neue Nachricht" · „VanVan hat dir geschrieben."
      sie weiss, wohin sie fuehrt (/workspace/chat.html)
      und traegt das Gesicht des richtigen Hauses (crew-192.png)
      mit dem Abzeichen fuer die Statusleiste (abzeichen-96.png)

DAS MESSINSTRUMENT IST EINE LEERE SEITE, und das steht so im Kommentar:
`ServiceWorker.enable` gibt es nur an einer SEITE, nicht am Browser
(nachgemessen -- am Browser antwortet das Protokoll „wasn't found").
Eine Sitzung an der App-Seite stirbt mit ihr. `about:blank` gehoert
nicht zum Haus, und dass KEINE Workspace-Seite mehr offen ist, wird
ausdruecklich gezaehlt.

AUCH EIN PUSH OHNE DATEN ZEIGT ETWAS AN. Das ist kein Schoenheitstest:
Ein Browser, der eine Push-Berechtigung hat und mehrmals schweigt,
ENTZIEHT sie wieder -- ab da kommt gar nichts mehr an. Der Fehler, der
sich selbst verschlimmert. Gemessen: „Creator Workspace · Es gibt
etwas Neues."

DER DRITTE AUSGANG, UND ER WAR NOETIG

Mein erster Lauf meldete „nach dem Push steht 0 Meldungen da" -- das
sah aus wie ein schwerer Befund am Haus. Es war der Browser.
Fuenf Aufbauten gemessen, eine Antwort:

    headless (Vorgabe), grant mit origin       -> denied
    headless (Vorgabe), grant ohne origin      -> denied
    headless (Vorgabe), permissions im Kontext -> denied
    headless=old                               -> denied
    mit Fenster (headless: false)              -> GRANTED

Ein kopfloser Chromium verweigert Benachrichtigungen, egal wie man die
Erlaubnis erteilt. Die Pruefung oeffnet deshalb ein Fenster -- und wenn
die Berechtigung trotzdem fehlt, endet sie mit Rueckgabewert 2 und dem
Satz „konnte nicht nachsehen. Das ist KEIN Befund am Haus." Eine
Pruefung, die ihre Voraussetzung nicht hat, darf nicht rot werden.

WAS DAMIT NICHT BEWIESEN IST, und das gehoert in denselben Absatz: ob
ein bestimmtes Handy sie auch anzeigt. Das haengt an den Einstellungen
des Geraets (Nicht stoeren, Berechtigung entzogen, auf dem iPhone die
Installation auf dem Startbildschirm). Geprueft ist der Weg bis zum
Browser, nicht die Laune des Telefons.

AM LAUFENDEN SYSTEM NACHGESEHEN (nur gelesen)

Zehn Anmeldungen, alle gesund -- `fehler = 0` bei jeder einzelnen, und
`zuletzt_ok` bei vieren auf heute 12:31 Uhr. Der Push-Dienst hat also
heute Zustellungen angenommen, und der laeuft ueber Apple und Google,
nicht ueber eine offene Seite.

    BananaStift  iPhone, Android, Windows   zuletzt ok 01.10. 18:18
    Diene        Android                    heute 12:31
    Dogfather    Android                    heute 09:18
    Ghost        Android                    heute 12:31
    Marina       Android                    heute 09:53
    Miss         iPhone                     heute 12:31
    Tamy         Android                    01.10. 18:18
    VanVan       Android                    heute 12:31

GEPRUEFT: pruef-push-zu 13 ok · pruef-push 24 · pruef-push-weg 20 ·
pruef-push-ziel 38 · pruef-ports 10 · pruef-portnummern 41 ·
pruef-pruefzaehler 7. (Die Portnummern leiten sich aus der
alphabetischen Stelle ab -- eine neue Pruefdatei verschiebt sie.)

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 14:53:17 +02:00
DogFatherGitandClaude Opus 5 4c08c8859d Beim Antworten im Support darf jetzt ein Bild mit
VanVan (Support): „Wenn man hier im Support auf deine Frage 'geht es
wieder' reagiert und antwortet, kann man auch kein Bild hinzufügen. Das
müsstest du auch noch hinzufügen, damit man nochmal ein Bild anhängen
kann, wenn das Problem noch besteht oder sich durch die Änderung ein
neues Problem ergeben hat."

Ihr zweiter Halbsatz ist der eigentliche Grund, und ich waere nicht
darauf gekommen: Das Bild beim MELDEN zeigt das ERSTE Problem. Taucht
durch die Aenderung ein neues auf, hilft das alte Bild niemandem.

WO ES LIEGT: AN DER RUNDE, NICHT AN DER MELDUNG

Die Meldung hat schon ein Bild -- das vom ersten Mal. Wuerde es hier
ueberschrieben, waere nach Runde drei nicht mehr zu sehen, womit es
angefangen hat. Genau diese Frage loest einen wiederkehrenden Fehler,
und genau deshalb gibt es die Rundentabelle ueberhaupt (ihre eigene
Begruendung steht seit dem 25.09. darueber).

DERSELBE WEG WIE BEIM MELDEN, NICHT EIN ZWEITER

Text und Urteil reisen im Kopf (`x-text`, `x-geht`), das Bild im
Rumpf, eine Route fuer beides. Die Begruendung stand schon beim
Melden und gilt hier genauso: Zwei Routen haetten einen Zustand
dazwischen -- eine Antwort, die schon zaehlt, waehrend das Bild noch
laedt. Ein leerer Rumpf ist zulaessig; ein Bildschirmfoto ist Hilfe,
keine Huerde.

DIE SPALTEN MUESSEN NACHGETRAGEN WERDEN, und das ist die Stelle, an
der es sonst schiefgeht: Die Tabelle entsteht mit `CREATE TABLE IF NOT
EXISTS`. Auf einer Datenbank, die es schon gibt -- also auf dem Server
-- sieht das den Namen, findet ihn, und ist fertig. Die vier neuen
Spalten kaemen dort NIE an: lokal alles gruen (jede Pruefung legt ihre
Datenbank frisch an), live ein Schreibfehler. Zwanzig Zeilen weiter
oben steht derselbe Fall schon einmal, damals mit einem Index.
Deshalb ein ALTER-Nachtrag, der die Tabelle SELBST fragt
(`PRAGMA table_info`) statt einer gepflegten Liste.

DATENBANK VORHER GESICHERT (Hausregel bei Schemaaenderungen):
`sicherungen/vor-support-rundenbild-20261002-142949.db`, geprueft mit
`integrity_check: ok`, 20 Personen, 16 Runden.

DER DIALOG KANN JETZT EIN BILD -- UND ZWAR NUR, WENN MAN IHN FRAGT

Das Feld ist eine Option von `frageNach` und standardmaessig AUS.
Ohne diese Vorgabe bekaemen die 56 anderen Rueckfragen im Haus ein
Bildfeld, nach dem niemand gefragt hat.

Es steht dort und nicht in support.js, weil Grund und Bild in
DENSELBEN Kasten gehoeren: Zwei Dialoge nacheinander hiessen, dass
jemand beim zweiten abbricht und den ersten umsonst getippt hat --
dieselbe Begruendung, aus der die Anzahl-Zeile dort gelandet ist.

Mit Vorschau. Wer sieht, was er anhaengt, haengt seltener das falsche
Bild an.

IM NOTAUSGANG GIBT ES KEINS, und das wird gesagt statt verschwiegen:
`window.prompt` kann keine Datei. Wer einen Browser ohne `<dialog>`
hat, kann antworten -- nur eben ohne Anhang. `bild: null` sorgt dafuer,
dass die aufrufende Stelle nicht raten muss.

GEPRUEFT

pruef-support 48 -> 78. Fuenf vorhandene Aufrufe mussten auf den neuen
Weg mitgezogen werden -- haette ich das vergessen, haetten sie ab
heute nur noch ihre eigene Veraltung gemessen. Neu dazu:

    die Antwort geht mit Bild durch (200)
    im Verlauf haengt das Bild an Runde 1
      und zwar an DIESER Runde, nicht oben an der Meldung
    der Melder bekommt es wieder (200, 70 von 70 Bytes)
    und ueber den gemeinsamen Ausliefer-Weg (Accept-Ranges)
    die Leitung sieht es auch (200)
    ein Fremder bekommt 404 — nicht 403, sonst waere die Nummer verraten
    ohne Anmeldung gar nichts (401)
    ohne Bild geht es genauso (200)
    eine PDF wird abgelehnt (415)

pruef-nachfrage 53 -> 69, am echten Bildschirm, mit echten Dateien
ueber `DataTransfer`:

    ohne Angabe bleibt die Bildzeile verborgen
    der Knopf ist 44 px hoch (Fingermass)
    nach der Wahl steht der Name da, Vorschau ist da
    ein 300x900 grosses Bild wird auf 160 px gedeckelt
      und der Senden-Knopf steht weiter im Fenster
    Gegenprobe: Abbrechen gibt nichts zurueck, auch kein Bild
    und beim naechsten Oeffnen ist es leer

ZWEI EIGENE FEHLER, BEIDE VON DER MESSUNG GEFUNDEN:
 · Meine erste Fassung las die neue Meldung ueber `.id` statt
   `.meldung.id` und meldete „#undefined". Die Pruefung hatte recht,
   der Fehler war meiner.
 · Die Obergrenze der Vorschau habe ich zuerst an einem 1x1-Bild
   gemessen: „3 px, hoechstens 160" -- gruen und wertlos, ein ein
   Pixel hohes Bild kann keine Grenze ueberschreiten. Jetzt entsteht
   im Browser ein 300x900 grosses, und die Grenze wird wirklich
   geprueft.

Dazu unveraendert gruen: pruef-aufbewahrung 45 · pruef-css-klassen 37 ·
pruef-struktur 99.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 14:30:39 +02:00
DogFatherGitandClaude Opus 5 58911d7c4f Auf dem Handy steht jetzt da, wer reagiert hat
VanVan (Support): „Wenn man auf dem Handy auf die Reaktionen unter einer
Nachricht geht, dann sieht man nicht wer darauf reagiert hat. Auf dem PC
funktioniert es."

DIE URSACHE STAND IM QUELLTEXT, mit Kommentar und allem:

    b.title = `${r.wer.join(', ')} · ...`

Ein `title` erscheint beim UEBERFAHREN MIT DER MAUS. Auf einem Finger
gibt es kein Ueberfahren -- und das Antippen schaltet stattdessen die
eigene Reaktion um. Die Auskunft war also nicht versteckt, sondern an
ein Geraet gebunden, das die Haelfte des Teams nicht benutzt. „Auf dem
PC funktioniert es" war der entscheidende Satz ihrer Meldung.

GEAENDERT: Unter den Kacheln steht auf Fingergeraeten eine Zeile mit
den Namen -- je Zeichen, mit dem Zeichen davor:

    👍 Patrick · ❤️ VanVan, Miss

WARUM EINE ZEILE UND KEIN LANGDRUECKEN. Ein langer Druck ist die
uebliche Antwort und die schlechteste: Man muss wissen, dass es ihn
gibt. Hier sind es Leute aus EINEM Raum, also kurze Listen -- sie
passen hin. Was dasteht, muss niemand finden.

WARUM NUR AUF DEM FINGER. Am Rechner funktioniert das Ueberfahren, und
eine Dauerzeile unter jeder zweiten Nachricht waere dort Unruhe ohne
Gewinn. Der `title` bleibt unveraendert.

ZWEI KLEINIGKEITEN, DIE KEINE SIND:
 · `aria-hidden="true"` an der Zeile. Jede Kachel traegt ihre Namen
   schon im `aria-label`; ohne das hoerte ein Vorleseprogramm alles
   doppelt.
 · Die Farbe ist die der Blase (`--blase-leise`), nicht eine feste.
   Derselbe Grund wie bei der Sprachnachricht: Jede Blase traegt die
   Farbe ihres Absenders, und eine feste Schrift ergaebe auf Babyblau
   wieder 1,91:1.

GEPRUEFT AUF BEIDEN GERAETEN, sonst beweist es nichts (pruef-chat-optik,
62 -> 71). Am Handy MUSS die Zeile da sein, am Rechner MUSS sie fehlen
und der Titel die Namen tragen. Ohne die zweite Haelfte waere eine
Regel, die immer gilt, genauso gruen -- und haette am Rechner eine
Zeile angehaengt, die niemand bestellt hat.

    auf dem Handy steht die Zeile da (true)
      und sie nennt den Namen: „👍 Patrick"
      und ein Vorleseprogramm hoert sie nicht doppelt (aria-hidden)
    am Rechner bleibt sie weg — dort funktioniert das Überfahren
      und die Namen stehen wie bisher im Titel

Gemessen wird mit einem FREMDEN Namen (DogFather schreibt, Patrick
reagiert). Mit der eigenen Reaktion staende „Du" da, und die Pruefung
haette den Fall nicht gemessen, um den es geht.

GEPRUEFT: pruef-chat-optik 71 ok · pruef-chat 63 ok ·
pruef-chat-aufloesen 126 ok.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 14:17:51 +02:00
DogFatherGitandClaude Opus 5 b808171704 Der Kamerahinweis wird wieder sichtbar -- und eine eigene Regression weg
ICH HATTE ES GESTERN FALSCH BEHAUPTET UND HEUTE SELBST VERSCHLIMMERT.
Beides steht hier, weil beides zum Befund gehoert.

WAS ICH GESAGT HATTE: „Auf 390 px ist der Hinweis 0 px hoch und
deshalb nicht zu lesen -- wo er hingehoert, ist eine Gestaltungsfrage
und gehoert Filipe."

Der zweite Halbsatz war eine Ausrede. Im Quelltext stand die ganze
Zeit eine 6-Sekunden-Uhr (reaktion.js, `sagFehler`), die ihn wieder
ausblendet. Statt das nachzusehen, habe ich aus zwei Messungen zu
verschiedenen Zeitpunkten einen Widerspruch gebaut (62 px hier, 0 px
dort) und ihn fuer eine Eigenschaft der Breite gehalten.

ALSO ABGETASTET STATT HERGELEITET (390x844, nach dem Laden):

    nach  500 ms  Text 104 Zeichen · hidden=nein ·   0 px
    nach 5000 ms  Text 104 Zeichen · hidden=nein ·   0 px
    nach 7000 ms  Text 104 Zeichen · hidden=ja   ·   0 px

Die Uhr stimmt also -- und trotzdem ist er die ganzen sechs Sekunden
NULL PIXEL hoch. Ein `role="alert"`, den niemand sehen kann. Das war
auf 390 px schon vorher so.

UND AUF 320 PIXELN HABE ICH ES HEUTE SELBST KAPUTTGEMACHT. Vorher
bekam `#fehler` dort eine stillschweigende fuenfte Rasterzeile mit
71 Pixeln -- sichtbar, aber auf Kosten des Bildes (56 statt 127).
Seit die Regie aus dem Fluss ist, bleibt fuer diese Zeile nichts
uebrig: Der Hinweis kostete nichts mehr und war dafuer unsichtbar.
Von „sichtbar und zu teuer" auf „gratis und wirkungslos" ist keine
Verbesserung.

DIE URSACHE, und sie steht seit dem 30.09. im Haus beschrieben:
`#fehler` ist ein Kind des Saals ohne Platzangabe. Der Saal hat vier
Zeilen; das Feld landet in einer fuenften, die es nicht gibt.

ERSTER REPARATURVERSUCH, GEMESSEN UND ZURUECKGENOMMEN: `grid-row: 2`
ohne `position: absolute`. Damit belegte das Feld die Zelle, der Raum
wich in eine stillschweigende zweite SPALTE aus, und das BILD fiel auf
0 px (320) bzw. 2 px (360). Bei `.raum` steht der gleiche Satz fuer
eine zweite ZEILE -- derselbe Fehler, andere Achse. Ich habe den
Kommentar gelesen, nachdem die Messung ihn mir bestaetigt hatte, nicht
davor.

SO GEHT ES: dasselbe Muster wie beim Pult. `position: absolute` nimmt
das Feld aus dem Fluss -- es belegt keine Zelle und verdraengt nichts.
`grid-row: 2` sagt dann nur noch, WORIN es liegt, `align-self: end`
setzt es an die Unterkante. Keine gerechnete Zahl.

GEMESSEN DANACH:

    320x568   Hinweis 62 px · im Fenster · obenauf · Bild 180 -> 180
    390x844   Hinweis 42 px · im Fenster · obenauf · Bild 219 -> 219
    430x932                                        · Bild 242 -> 242

Sichtbar, wenn er kommt. Nach sechs Sekunden wieder weg. Und er
kostet dem Bild kein Pixel mehr.

DIE PRUEFUNG STELLT DEN ZUSTAND SELBST HER (pruef-reaktion-schmal,
38 -> 44). Im Betrieb erscheint der Hinweis, wenn Kamera oder Mikrofon
fehlen -- auf einem Messrechner immer, auf einem Handy mit Freigabe
nie. Eine Pruefung, die darauf wartet, prueft die Messumgebung. Sie
schreibt den Text jetzt selbst hinein, macht ihn sichtbar, misst Hoehe
UND Bildhoehe, und raeumt wieder auf.

GEPRUEFT: pruef-reaktion-schmal 44 ok · pruef-reaktion 421 ok ·
pruef-css-klassen 37 ok · pruef-community-sicht 10 ok ·
pruef-breiten 23 ok.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 14:04:05 +02:00
DogFatherGitandClaude Opus 5 4a0ddf7d49 Die Regie wird auch auf dem kleinen Handy zur Schublade
Filipe: „auf so apps wie youtube und so geht es doch auch auf dem handy
also perfektionier es endlich."

Er hat recht gehabt, und meine Begruendung von 2026-09 war ueberholt.

WAS AUF 320x568 WIRKLICH PASSIERTE (Regie offen, laufende Sendung):

    Bild 0 px · Pult 1324 px IM RASTER · Transportleiste endet bei
    1567 von 568 Pixeln Fenster

Die Regie stand im Fluss statt darueber und schob alles hinaus. Ab
360 px war genau das laengst geloest -- es war dieselbe Seite, nur
schmaler. In reaktion.css stand dazu eine Sperre `and (min-width:
360px)` mit der Begruendung, der Kopf der Regie brauche 210 Pixel und
es seien nur 190 da. Fuenf Versuche hatten das nicht geloest.

DIE RECHNUNG STIMMTE NICHT MEHR. Seit 2026-09 gibt es einen Block
`@media (pointer: coarse)` ohne Breitengrenze, der den Kopf strafft.
Nachgemessen am 02.10.: 197 px, nicht 210 -- und mit einer weiteren
Straffung 147. Der Platz, der damals fehlte, war inzwischen da. Wer
der alten Begruendung geglaubt haette, haette nie nachgesehen.

VIER VARIANTEN DURCHGEMESSEN STATT DIE SECHSTE ZU RATEN
(320x568, Regie offen):

    heute             Bild   0 · Kopf 197 · Schublade 1324
    nur Sperre weg    Bild 180 · Kopf 197 · Schublade  234 · Inhalt  36
    + Knoepfe enger   Bild 180 · Kopf 147 · Schublade  234 · Inhalt  86
    + 86 % statt 72   Bild 180 · Kopf 147 · Schublade  280 · Inhalt 132

Die dritte Variante (Messwerte einzeilig) brachte gegenueber der
zweiten NULL und ist deshalb nicht eingebaut -- die bestehende
coarse-Regel erledigt das schon. Eine Regel, die nichts aendert, ist
eine, die beim naechsten Mal jemand sucht.

GEAENDERT

 1. Die beiden Sperren `and (min-width: 360px)` sind weg. Die Regie
    liegt jetzt auf JEDEM Fingergeraet ueber dem Bild statt im
    Raster, rollt in sich und hat einen stehenden Kopf.
 2. Neuer Block fuer unter 360 px: die drei grossen Knoepfe in EINE
    Zeile (`nowrap`, weniger Polsterung) und die Schublade darf
    86 statt 72 Prozent hoch werden.

    DIE 44 PIXEL HOEHE BLEIBEN -- das ist die Daumengrenze des
    Hauses. Nur die Breite gibt nach, und nachgemessen wird KEIN
    Knopf abgeschnitten (`scrollWidth <= clientWidth`).
    Die 86 statt 100 Prozent sind derselbe Gedanke wie die
    urspruenglichen 72: Oben bleibt ein Streifen Bild stehen, weil
    man sehen muss, worueber man gerade redet.

NACH DEM UMBAU GEMESSEN:

    320x568   Bild 180 px (war 0) · Kopf 147 (war 197)
              Schublade 280 · sichtbarer Inhalt 132 · Rest rollt 994 px
              Transportleiste endet bei 568 von 568
    360x640   Bild 203 px   unveraendert
    390x844   Bild 219 px   unveraendert

Zu UND auf ist das Bild auf 320 px jetzt gleich hoch -- das Oeffnen
der Regie kostet es nichts mehr.

EIN NEBENBEFUND HAT SICH MITERLEDIGT. Der Hinweis „Kamera oder
Mikrofon sind nicht freigegeben" sass in einer impliziten FUENFTEN
Rasterzeile (der Saal deklariert vier) und kostete das Bild 71 Pixel
(56 statt 127). Weil das Pult nicht mehr im Fluss steht, gibt es
diese Zeile nicht mehr: gemessen kostet der Hinweis jetzt 0 px
(325 -> 325).

WAS ICH DABEI FALSCH GEMACHT UND ZURUECKGENOMMEN HABE: Ich wollte
den Hinweis zusaetzlich mit `grid-row: 2` festnageln. Gemessen fiel
das Bild daraufhin auf 0 px (320) und 2 px (360) -- die explizite
Platzierung verdraengte die automatische des Bildes. Sofort wieder
entfernt. Die Messung hat es gefunden, nicht das Nachdenken; ohne
den Lauf davor haette ich eine Verschlechterung ausgeliefert.

DIE PRUEFUNG WURDE SCHAERFER, NICHT GRUENER (pruef-reaktion-schmal,
24 -> 38). Bis heute protokollierte sie den Mangel unter 360 px nur,
statt ihn zu behaupten -- richtig, solange es keine Reparatur gab,
falsch in dem Moment, in dem es eine gibt. Jetzt gilt auf JEDER
Breite: Bild behaelt seine Hoehe (zu und auf), Transportleiste bleibt
im Fenster, Inhalt der Schublade erreichbar, drei Knoepfe mit 44 px
unbeschnitten und obenauf, Zu-Knopf in jedem Zustand erreichbar.
Die Konstante `AUS_DEM_FLUSS_AB = 360` ist geloescht -- eine
Konstante, die nichts mehr trennt, ist der Anfang einer Erklaerung,
die nicht stimmt.

GEPRUEFT: pruef-reaktion-schmal 38 ok · pruef-reaktion 421 ok ·
pruef-css-klassen 37 ok · pruef-community-sicht 10 ok ·
pruef-breiten 23 ok · pruef-fingermass 5 ok.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 13:43:29 +02:00
DogFatherGitandClaude Opus 5 31437ac0dc Die Reaction auf dem kleinen Handy: gemessen, bewacht -- und ein Befund
MEINE EIGENE MERKLISTE WAR FALSCH.

Dort stand seit dem 01.10.: „320x568 zeigt noch ein 320x75 grosses
Videofeld und eine 1 px schmale Chatleiste." Nachgemessen stimmte davon
keine einzige Zahl. Eine Bestandsliste ist ein Wegweiser, keine
Wahrheit -- auch meine eigene.

WAS WIRKLICH DASTEHT (320x568, laufende Sendung):

    Regie ZU   Bild 127 px · Transportleiste endet bei 568 von 568
    Regie AUF  Bild   0 px · Transportleiste endet bei 1585 von 568
    Regie AUF bei 390/430: Bild unveraendert, Transport am Fensterrand

Die Chatleiste ist auf JEDER Handybreite `display: none` -- sie liegt
dort im Registerstreifen. Absicht, keine 1-px-Leiste.

Dass bei OFFENER Regie auf 320 px das Bild verschwindet, ist der in
reaktion.css dokumentierte Mangel samt sechs gescheiterten Versuchen.
Er wird hier NICHT repariert und auch nicht gruen abgehakt -- sonst
wuerde eine spaetere Reparatur rot.

DIE ENTSCHEIDENDE FRAGE WAR EINE ANDERE: Kommt man wieder heraus?
Gemessen in jedem Zustand und auf jeder Breite: Der Knopf, der die
Regie zumacht, steht im Fenster UND liegt obenauf (`elementFromPoint`,
nicht nur „sichtbar"). Es ist also ein Schoenheitsfehler und keine
Falle. Das war vorher niemandem bekannt, weil es niemand gemessen hat.

NEU: pruef-reaktion-schmal.mjs (24 Pruefungen)

Die Reaction-Seite war im Browser so gut wie unbewacht -- von fuenf
Pruefungen, die reaktion.html erwaehnen, oeffnet sie nur eine
ueberhaupt in einem Browser, und keine auf 320 px. Bewacht wird jetzt
die Grenze:

  1. Mit geschlossener Regie muss das Bild auf jeder Handybreite
     mindestens 100 px hoch sein (heute 127 / 219 / 242).
  2. Ab 360 px -- genau dort verlaeuft `@media (pointer: coarse) and
     (min-width: 360px)` -- muss das Bild seine Hoehe auch bei
     OFFENER Regie behalten.
  3. In JEDEM Zustand muss der Zu-Knopf im Fenster stehen und
     anklickbar sein.

Mit Gegenproben, die beweisen, dass sie rot werden kann: ein auf 0
gedruecktes Bild faellt durch, und ein zugedeckter Knopf gilt nicht
als anklickbar, obwohl er im Fenster steht.

-------------------------------------------------------------------
EIN BEFUND, DER FILIPE GEHOERT UND NICHT MIR

Beim Messen kam etwas heraus, das nicht auf der Liste stand. Die
Seite zeigt ein Hinweisfeld „Kamera oder Mikrofon sind nicht
freigegeben. Im Browser oben in der Adresszeile laesst sich …".
Gemessen, zweimal reproduziert:

    320 px   Das Feld kostet das Bild 71 px: 56 -> 127
             (es landet in einer impliziten FUENFTEN Rasterzeile;
              der Saal deklariert vier)
    390 px   Das Feld kostet 0 px -- weil es dort 0 px HOCH ist

Also: Auf dem kleinen Handy frisst der Hinweis mehr als die Haelfte
des Bildes. Auf dem normalen ist er ueberhaupt nicht zu lesen. Eine
Meldung, die erklaert, wie man die Kamera freigibt, und die man dabei
nicht sehen kann, ist keine.

Wo dieser Hinweis hingehoert, ist eine Gestaltungsfrage und damit
Filipes. Deshalb steht die Messung im Protokoll der Pruefung, aber
nicht als Behauptung im Code.

WIE ES GEMESSEN WIRD, nachdem der erste Anlauf wackelte: Nicht die
Hoehe des Feldes (die hing davon ab, WANN gelesen wurde -- in einem
Lauf 62 px, im naechsten 0), sondern der Unterschied vorher/nachher im
selben Lauf: Bild messen, Meldung leeren, Bild noch einmal messen.
Zweimal hintereinander identisch.

GEPRUEFT: pruef-reaktion-schmal 24 ok · pruef-ports 10 ok ·
pruef-portnummern 41 ok · pruef-pruefzaehler 7 ok · pruef-struktur 99 ok.
Die Portnummern leiten sich aus der alphabetischen Stelle ab, eine neue
Pruefdatei verschiebt sie -- deshalb stehen die beiden Port-Pruefungen
mit dabei. Der Gesamtlaeufer liest das Verzeichnis (`readdirSync`) und
findet die neue Datei von selbst; es gibt keine Liste, die veralten
koennte.

AM PRODUKT IST NICHTS GEAENDERT.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 12:29:54 +02:00
DogFatherGitandClaude Opus 5 ea53d950cd Fingermass: beide Grundlinien auf NULL -- kein blindes Telefonfenster mehr
Seit dem 29.09. haelt pruef-fingermass zwei Zahlen, und beide muessen
GENAU stimmen: waechst eine, ist eine neue blinde Messung dazugekommen;
sinkt sie, deckt sie Platz fuer die naechste. Nach dem Grosscheck von
heute sagte die Pruefung selbst, was zu tun ist:

    FEHL Nur noch 0 blinde Fenster — die Grundlinie steht auf 1.
         Bitte in pruef-fingermass.mjs auf 0 senken.
    FEHL 0 Fenster mit Variable und ohne hasTouch (Grundlinie 2).

Beide Zahlen zaehlten dasselbe: `pruef-grosscheck`. Im Kommentar stand
seit dem 01.10. woertlich, warum sie stehen blieben -- „die Datei zu
aendern waere ein Zweizeiler; sie zu pruefen hiesse, 206 Seiten ueber
vier Rollen laufen zu lassen, und das braucht Filipes Zusage. Eine
Aenderung, die ich nicht pruefen darf, liefere ich nicht aus."

Die Zusage kam heute („mach jetzt den prüf groß check"), der Lauf ist
gruen, also faellt die Ausnahme. Beide Grundlinien stehen auf 0.

DAS IST MEHR ALS EINE KLEINERE ZAHL: Es gibt im ganzen Haus kein
Telefonfenster mehr ohne Finger und keines, bei dem offenbleibt, ob am
Telefon mit Mauszeiger gemessen wird. Jedes neue ist ab jetzt ein
Befund und kein Bestand. 29 Dateien messen am Telefon, 28 Fenster
haben einen Finger, 0 blind.

    pruef-fingermass: 5 Pruefungen, 0 Fehler

-------------------------------------------------------------------
NACHTRAG ZUR ZEITBOMBE VON HEUTE NACHT -- UND EINE KORREKTUR AN MIR

Heute Nacht war pruef-gifs rot, weil sie ins Treff-Gespraech schrieb,
wo zwischen 0 und 6 Uhr die Nachtruhe gilt. Repariert, indem sie sich
ein eigenes Gespraech anlegt.

Danach wollte ich wissen, ob noch andere Pruefungen dieselbe Bombe
tragen, und habe sie gestartet „solange das Fenster offen ist". Es war
NICHT offen: Im Protokoll stand 11:40 Uhr. Der Rechner war zwischendurch
aus, und ich hatte meine eigene, veraltete Zeitnotiz fuer die Gegenwart
gehalten -- genau der Fehler, vor dem in CLAUDE.md steht, dass auch
meine eigenen Notizen altern. Die erste Runde beantwortete die Frage
also gar nicht.

DIE NACHT BRAUCHT MAN DAFUER AUCH NICHT. Das Fenster ist eine
Einstellung (`TREFF_NACHT_AB` / `TREFF_NACHT_BIS`), und pruef-treffchat
macht es laengst so: Fenster verschieben statt Systemuhr. Damit laesst
sich die Nacht um 11:42 Uhr herstellen.

GEMESSEN, STATISCH AUSGEWAEHLT: Von 16 Pruefungen, die in einen Raum
schreiben, legen 13 ihn selbst an; uebrig blieben sechs Kandidaten.
Alle sechs mit kuenstlicher Nachtruhe (Fenster 11-13 Uhr, jetzt 11:42):

    pruef-chat-kanaele    81 ok      pruef-chat-aufloesen  126 ok
    pruef-entwicklung     79 ok      pruef-chatkachel       40 ok
    pruef-loeschen        31 ok      pruef-anruf           132 ok

489 Pruefungen, 0 Fehler. Keine weitere Zeitbombe dieser Art.

UND WEIL GRUEN NUR DANN ETWAS HEISST, WENN ES AUCH ROT WERDEN KANN --
drei Messungen statt einer Behauptung:

    1. Kommt der Schalter an?    istNachtruhe false -> TRUE
    2. ALTE pruef-gifs, Nacht:   2 FEHL  (die Methode findet es)
    3. NEUE pruef-gifs, Nacht:   0 FEHL  (die Reparatur haelt wirklich)

Ohne (2) waere (3) wertlos gewesen: Sechs gruene Laeufe sehen genauso
aus, wenn der Schalter gar nicht ankommt.

AM PRODUKT IST NICHTS GEAENDERT -- eine Pruefdatei, zwei Zahlen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 11:45:16 +02:00
DogFatherGitandClaude Opus 5 7edb9c1bdb Grosscheck: mit Finger messen -- und 0 px ist keine kleine Schrift
AUFTRAG: „mach jetzt den prüf groß check."

ERGEBNIS DES LAUFS: 206 Seiten, 122 551 Elemente, 3 870 bedienbare.
Rechner (1280 px) alle vier Rollen sauber, 33 oeffentliche Seiten auf
beiden Groessen sauber, alle fuenf Gegenproben schlagen an. Vier
Beanstandungen, alle dieselbe Stelle -- und beide Ursachen lagen in der
Pruefung, nicht am Produkt.

1. ER HAT OHNE FINGER GEMESSEN

Die drei Browser-Kontexte setzten nur die Fenstergroesse. Ohne
`hasTouch` meldet Chromium `pointer: fine`, und KEINE Regel aus
`@media (pointer: coarse)` greift -- dort stehen im ganzen Haus die
44-Pixel-Beruehrziele. Ausgerechnet die Zeile `if (breite < 700 &&
r.klein.length)` misst genau diese Beruehrziele. Die Handy-Haelfte
dieses Laufs hat also eine Seite vermessen, die es auf keinem Handy
gibt. Dieselbe Luecke wie am 01.10. bei pruef-breiten, wo von sechs
Befunden nach dem Nachruesten genau einer uebrig blieb; 57 andere
Pruefdateien hatten es laengst, diese nicht.

Jetzt `hasTouch: b <= 860, isMobile: b <= 860` an allen drei Stellen --
gleiche Schwelle, gleiche Schreibweise wie ueberall sonst.

2. „0px" IST KEINE KLEINE SCHRIFT, SONDERN GAR KEINE

Gemeldet wurde viermal (einmal je Rolle) dasselbe:

    kalender.html  zu klein: A.k-pille 0px | A.k-pille 0px
                             | SPAN.k-anlass "🇩🇪 Tag der D" 0px

Am echten Bildschirm nachgemessen statt der Zahl geglaubt:

    Kasten 8x8 · Schrift 0px · Zeilenhoehe 0px
    GEMALTER TEXT: Hoehe 0 -- kein einziger Textkasten
    pointer-events: none · alle Kinder display:none
    title="Tag der Deutschen Einheit — gesetzlicher Feiertag"
    Tagesdialog: „10:00 Uhr Ein absichtlich sehr langer Titel …"

Im schmalen Monatsraster (Zelle 46 px breit) wird ein Termin
absichtlich zu einem farbigen PUNKT. `font-size: 0` ist dort das
Mittel, nicht der Mangel; kalender.css begruendet es ausfuehrlich, und
den Namen nennt der Tagesdialog. Es wird nichts gemalt -- die Frage
„ist dieser Text zu klein zum Lesen?" ist bei 0 px falsch gestellt.

Die Messung fragt jetzt `0 < Groesse < Grenze`. Zwischen 1 und 11,5 px
faellt alles weiterhin auf, und genau dort liegt der echte Mangel.

WAS DAS NICHT IST: ein Freibrief. `font-size: 0` versteckt Text auch
vor dem sehenden Auge -- aber das ist ein FEHLENDER Inhalt, kein zu
kleiner, und mit `display: none` entkaeme er dieser Messung ohnehin
genauso.

3. UND DIE AUSNAHME PRUEFT SICH IN BEIDE RICHTUNGEN

Eine Ausnahme ohne Gegenprobe ist der Anfang einer Liste, die niemand
pflegt. Deshalb zwei neue Gegenproben, direkt neben den fuenf
vorhandenen:

    ein Absatz mit 6 px  -> MUSS gemeldet werden
    ein Punkt mit  0 px  -> darf NICHT gemeldet werden

Ohne die erste waere die neue Regel auch dann gruen, wenn sie gar
nichts mehr findet.

AM PRODUKT IST NICHTS GEAENDERT -- eine einzige Pruefdatei. Kein
Stempel, kein Neustart noetig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 04:56:52 +02:00
DogFatherGitandClaude Opus 5 87a3a14978 pruef-gifs war nicht kaputt, sondern nachts rot -- und ein Weg war ungeprüft
DIE ZWEI FEHLER AUS DEM LETZTEN LAUF WAREN KEINE FEHLER AM PRODUKT.

    FEHL das GIF steht danach wirklich im Verlauf
    FEHL und die Tafel geht zu -- man will sehen, wie es ankommt

Gemessen statt geraten: eine Sonde, die jede Anfrage mitschreibt. Die
Antwort stand sofort da, um 04:30 Uhr:

    POST /workspace/api/chat/raeume/1/gif
    403 {"fehler":"Das Rudel schläft. Ab 06:00 Uhr geht es weiter …",
         "nachtruhe":{"zu":true,"ab":0,"bis":6,"minuten":90}}

Die Pruefung klickte „das erste Gespraech in der Liste". Das erste ist
der TREFF, den der Server beim Start selbst anlegt -- und im Treff gilt
die Nachtruhe von 0 bis 6 Uhr. Also: tagsueber gruen, nachts rot, seit
es diese Pruefung gibt. Es ist derselbe Fehler wie am 06.09. im Shop
(„ein Test, der die Wanduhr als Annahme benutzt, misst irgendwann das
Gegenteil") -- dort ein Oeffnungstermin, hier ein Raum mit Nachtruhe.

Warum es nie jemandem auffiel: Fuer Dogfather Universe gibt es keinen
naechtlichen Pruefdienst (nachgesehen: `systemctl list-timers` kennt nur
`vandiy-pruefung` und `runone-pruefung`). Die Pruefung lief immer nur
dann, wenn jemand sie von Hand startete -- und das war bisher nie
nachts.

WAS GEAENDERT IST

1. pruef-gifs legt ein eigenes Gespraech mit Miss an und oeffnet GENAU
   DAS. Fuer ein normales Gespraech gibt es keine Nachtruhe; die Messung
   gilt jetzt rund um die Uhr. Findet es den Knopf nicht, bricht es laut
   ab statt still auf nichts zu klicken.

   Gemessen, 04:40 Uhr:
     verschicken: {"vorher":0,"nachher":1,"imVerlauf":true,
                   "adressen":["/workspace/api/chat/anhang/1"],
                   "tafelZu":true}
   17 Pruefungen, 0 Fehler. Und nebenbei belegt die Adresse, was der
   Quelltext behauptet: Das GIF wird KOPIERT und haengt danach als
   ganz normaler Anhang an der Nachricht.

2. DIE NACHTRUHE IST NICHT UNTER DEN TISCH GEFALLEN -- sie steht jetzt
   dort, wo sie hingehoert: in pruef-treffchat (110 -> 114). Dort wird
   das Fenster ueber die EINSTELLUNG verschoben, nicht ueber die
   Systemuhr, und beide Zustaende kommen in einem Lauf vor.

   DENN DER GIF-WEG WAR DORT ALS EINZIGER DER DREI UEBERHAUPT NICHT
   GEPRUEFT. Im Quelltext steht neben der Regel woertlich: „Sie nur
   beim Text und beim Anhang zu pruefen hiesse, dass man nachts zwar
   nicht schreiben, aber ein GIF schicken kann -- der Weg, den jeder
   findet, der es einmal versucht." Genau dieser Weg hatte keine
   Pruefung. Der Kommentar hat den Fehler beschrieben und nicht
   verhindert -- dieselbe Luecke wie beim Spaltenverlust vom 11.09.

   Gemessen, beide Zweige in einem Lauf:
     Fenster 7–9 Uhr, jetzt 4 -> 404 „Dieses GIF gibt es nicht mehr."
     Fenster 4–6 Uhr, jetzt 4 -> 403 „Das Rudel schläft. Ab 06:00 …"

   ZWEI ENTSCHEIDUNGEN DABEI, beide mit Grund:
   · Gefragt wird als DogFather, nicht als Gast. Die GIF-Kiste gehoert
     dem Rudel; ein Gast bekaeme seine 403 von der falschen Schranke
     (`nurRudel`) -- gruen, ohne die Nachtruhe je beruehrt zu haben.
   · Mit einer Nummer, die es NICHT gibt. Kommt trotzdem die
     Nachtruhe-Absage, steht die Schranke VOR dem Nachschlagen.
     Stuende sie dahinter, verriete der Server nachts an der Nummer,
     welche GIFs es gibt.

AM PRODUKT IST NICHTS GEAENDERT. Nur zwei Pruefdateien -- kein Stempel,
kein Neustart noetig.

GEPRUEFT: pruef-gifs 17 ok (vorher 15 ok / 2 FEHL) ·
pruef-treffchat 114 ok (vorher 110).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 04:36:14 +02:00
DogFatherGitandClaude Opus 5 34a227605c Der Ausliefer-Helfer haengt nicht mehr an Express -- und ist einzeln prüfbar
GEFUNDEN BEIM MESSEN, NICHT BEIM LESEN.

Nach dem Ausliefern wollte ich auf dem Server nachweisen, dass die DORT
liegende `helfer-ausliefern.mjs` wirklich Teilanfragen beantwortet. Dafuer
habe ich ihr einen winzigen Server vorgesetzt -- `node:http`, eine
Wegwerfdatei in /tmp, eigener Port. Die volle Datei kam sauber
(`accept-ranges: bytes`, `content-length: 1000`). Beim ersten Abschnitt:

    TypeError: res.status is not a function
        at liefereDatei (.../helfer-ausliefern.mjs:134:9)

`res.status()` gibt es nur an einer EXPRESS-Antwort. Alle zehn Aufrufer
SIND Express-Handler, im Betrieb lief also alles richtig -- 146 Pruefungen
in pruef-chat-anhaenge und 33 in pruef-wissen-neu haben es bestaetigt, und
sie hatten recht. Trotzdem ist es ein Mangel: Der Helfer hing an Express,
ohne dass irgendwo stand warum, und liess sich nur noch INNERHALB der
ganzen Anwendung pruefen.

GEAENDERT: Die drei Stellen setzen jetzt `res.statusCode = n` und rufen
`res.end()`. Das kennen beide Antwortarten, und Express aendert daran
nichts -- an der ausgelieferten Antwort ist kein Unterschied messbar.

DAZU EINE PRUEFUNG, DIE OHNE SERVER AUSKOMMT (pruef-struktur, +14):
`bereichLesen()` steht ausdruecklich als eigene, ausgefuehrte Funktion da.
Jetzt wird sie auch einzeln befragt -- ohne Browser, ohne Server, ohne
Datenbank:

    kein Kopf / leerer Kopf          -> volle Datei
    bytes=0-9 · bytes=5- · bytes=-8  -> der richtige Abschnitt
    bytes=0-5000                     -> endet am Dateiende (erlaubt)
    bytes=1000- · 9-5 · -0 · leere Datei -> 416
    mehrere Bereiche · fremde Einheit · Unsinn -> volle Datei

Die letzte Zeile ist die wichtigste: NICHT VERSTANDEN ist etwas anderes als
UNERFUELLBAR. Auf einen Kopf, den der Server nicht liest, gehoert die ganze
Datei -- nie eine falsche Teilmenge und nie eine Absage.

DIE LEHRE, die ich mir aufschreibe: Durch zehn Express-Handler hindurch
waere das nie aufgefallen. Was sich einzeln pruefen laesst, wird einzeln
geprueft -- und ein Helfer, den man nur mit der ganzen Anwendung messen
kann, ist schwerer zu beweisen als einer, dem eine Antwort genuegt.

GEPRUEFT: pruef-struktur 85 -> 99 ok · pruef-chat-anhaenge 146 ok ·
pruef-wissen-neu 22 ok. Danach dieselbe Messung auf dem Server noch einmal,
gegen die ausgelieferte Datei.

BERICHTIGUNG ZUM COMMIT DAVOR: Dort steht „pruef-wissen-neu 33 ok". Das
war keine Messung, sondern geschaetzt -- nachgezaehlt sind es 22 (16 vorher
plus meine 6). Die Zahl stimmte nicht, der Befund schon. Eine Zahl, die man
nicht gezaehlt hat, gehoert nicht in eine Zusammenfassung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 03:46:32 +02:00
DogFatherGitandClaude Opus 5 4324547eb3 Teilanfragen: Sprachnachrichten kommen jetzt auch auf dem iPhone an
Filipe: „zeurst schaust du mal ob es im system liegt dass miss, die modi,
keine sprachnachrichten hoeren kann oder ob es an ihr liegt weil bei allen
anderen klappt nur bei ihr nicht"

ES LAG AM SYSTEM.

WAS GEMESSEN WURDE (nicht vermutet)

1. An ihrer Rolle liegt es nicht. Der Weg `/workspace/api/chat/anhang/:id`
   fragt drei Dinge: gibt es die Nachricht, ist die Person im Raum, hat sie
   das Gespraech weggeraeumt. Keine Rollenpruefung. Gegen eine Wegwerf-
   Datenbank gemessen: `anmelden admin=200 modi=200`, Anhang als modi
   HTTP 200, 40018 Bytes -- genau wie als admin.

2. Sie ist in allen drei Raeumen mit Sprachnachrichten, geloescht_bis = 0,
   alle sechs Tondateien liegen auf der Platte. (Live-Datenbank, nur
   gelesen, auf einer Kopie, Kopie danach geloescht.)

3. Sie ist die EINZIGE im Haus mit Safari. Aus dem Caddy-Protokoll, ueber
   IP und Minute mit ihren Protokolleintraegen abgeglichen: iPhone,
   iOS 18.7, Safari 26.6.1. Alle anderen Geraete der letzten zwei Wochen:
   Android-Chrome 3900 Anfragen, Windows-Chrome/Edge/Firefox 2646.
   Neun von zehn iPhone-IP-Praefixen sind ihre.

4. DER FEHLER: Der Anhang-Weg beantwortete eine Teilanfrage
   (`Range: bytes=0-1`) mit einer vollen HTTP 200 -- ohne `Accept-Ranges`,
   ohne `Content-Length`, als `chunked`. Zum Vergleich dieselbe Anfrage an
   `express.static`: HTTP 206, `accept-ranges: bytes`,
   `content-range: bytes 0-1/2480`.

   Safari verlangt fuer <audio> und <video> zwingend Teilanfragen und
   verweigert die Wiedergabe bei einer 200. Chrome und Firefox nehmen die
   ganze Datei klaglos. Bilder brauchen das nicht -- deshalb sah sie Fotos
   und hoerte nichts, und deshalb fiel es sieben Tage lang nur ihr auf.

WAS GEAENDERT IST

A) EIN GEMEINSAMER AUSLIEFERWEG (server/helfer-ausliefern.mjs)

   Im Haus gaben ZEHN Stellen eine Datei mit `createReadStream(pfad)
   .pipe(res)` hinaus: Chat-Anhang, Chat-GIF, Dateiablage (ansehen und
   laden), Material (ansehen und laden), Steckbriefbild, Buehnenbild,
   Supportbild, Wissens-PDF. Nur eine davon zu reparieren hiesse, eine
   Liste zu fuehren, welche Stelle schon richtig ist -- und die naechste
   neue macht es wieder falsch. Alle zehn gehen jetzt ueber
   `liefereDatei(req, res, pfad)`: `Accept-Ranges`, `Content-Length`,
   206 mit `Content-Range`, 416 mit `bytes */groesse`. Mehrere Bereiche in
   einer Anfrage werden absichtlich nicht bedient (das darf ein Server);
   die Antwort ist dann die GANZE Datei, nie eine falsche Teilmenge.

   WAS ES AUSSER SAFARI BRINGT, gemessen: Eine MP4-Sprachnachricht meldete
   in Chromium ohne Teilanfragen 0,23 s statt 1,96 s Laenge -- der Kopf
   einer MP4 steht am Ende der Datei. Eine Ogg-Datei meldete „Infinity"
   statt 2,02 s. Und ein PDF-Betrachter springt jetzt zu einer Seite, ohne
   die ganze Datei zu holen.

B) AUFGENOMMEN WIRD AAC IN MP4 (workspace/assets/js/chat.js)

   Die Reihenfolge der Behaelter beantwortete bisher die Frage „was kann
   DIESES Geraet am liebsten". Richtig ist „was koennen die ANDEREN
   abspielen" -- die hoeren es.

   Gemessen mit echten Aufnahmen aus echten Browsern, danach in beiden
   Engines abgespielt:
     Chromium nimmt auf: webm/opus, mp4(AAC), mp4(Opus)
     Firefox  nimmt auf: webm/opus, ogg/opus -- MP4 gar nicht
     Beide spielen alle vier Behaelter vollstaendig ab (2 s rein, 2 s raus)

   ZWEI FALLEN, die die Messung gezeigt hat:
   - Ein blankes `audio/mp4` ist nicht AAC: Chromium meldet darauf
     `audio/mp4;codecs=opus` zurueck -- MP4 aussen, Opus innen, fuer Safari
     genauso unbrauchbar wie WebM. Deshalb steht `audio/mp4;codecs=mp4a.40.2`
     VOR dem blanken `audio/mp4`.
   - Firefox kann kein MP4 aufnehmen und faellt sauber auf WebM/Opus
     zurueck. Kein Rueckschritt -- das ist der heutige Stand.

   Die Pruefung nimmt jetzt im echten Browser ueber den echten Knopf auf,
   und der Server erkennt: audio/mp4, 45583 Bytes, 2734 ms.

   BERICHTIGT: In zwei Kommentaren stand „Safari und das iPhone koennen
   nur MP4". Das stimmt nicht. An Miss' eigener Aufnahme nachgemessen:
   Behaelter WebM, Mux-Programm „WebKit", Tonspur A_OPUS. Safari NIMMT
   WebM auf -- ob es WebM abspielt, ist eine andere Frage.

C) EIN AUSWEG STATT EINER VERTROESTUNG (chat.js, chat.css)

   Vorher stand im Fehlerfall „Sprachnachricht laesst sich gerade nicht
   laden". Das war fuer Miss die ganze Auskunft, und es stimmte nicht
   einmal: Die Datei kam an, ihr Browser konnte den Behaelter nicht.
   „Gerade" heisst „gleich nochmal versuchen" -- bei einem fremden
   Behaelter hilft kein Versuch mehr.

   Jetzt wird unterschieden (Fehlercode 4 = Format, alles andere = Laden)
   und daneben steht ein Weg, der wirklich zum Ton fuehrt: Herunterladen,
   44 px hoch, in der Farbe der Blase, mit eigenem Vorlesewort. Nur im
   Fehlerfall -- ein Knopf an jeder Sprachnachricht waere Unordnung fuer
   alle, damit einer Person geholfen ist.

WAS JETZT NACHGEZAEHLT WIRD

- pruef-chat-anhaenge: 119 -> 146 Pruefungen. Dreizehn davon messen
  Teilanfragen am Foto (0-9, -8, 5-, ueber das Ende hinaus, 416, mehrere
  Bereiche, unverstandener Kopf) und vergleichen jeden Abschnitt BYTEWEISE
  mit der vollen Datei -- eine 206 mit den falschen Bytes waere schlimmer
  als keine. Drei Gegenproben legen dieselbe Messlatte an erfundene
  Antworten (die alte volle 200, falsche Gesamtgroesse, falsche Bytes) und
  muessen durchfallen. Neun weitere pruefen die Sprachnachricht selbst und
  den Ausweg.
- pruef-wissen-neu: +6. Der Weg zur PDF war der EINZIGE der zehn, den nie
  eine Pruefung abgerufen hat -- genau der, bei dem eine Umstellung
  unbemerkt danebengeht. Jetzt beide Spielarten (ansehen und laden).
- pruef-struktur: +6. Wer kuenftig wieder `createReadStream(...).pipe(res)`
  schreibt, bekommt einen Befund. Mit Gegenproben in beide Richtungen; die
  Probetexte sind zusammengesetzt, sonst meldet die Wache ihre eigene
  Begruendung als Fund. Pruefdateien sind ausgenommen, weil sie an niemanden
  ausliefern -- dort ist ein Weg ohne Teilanfragen die Gegenprobe.
  (Beim ersten Lauf hat die Wache sofort meine eigene Messdatei gefunden.)

WAS NICHT NACHGESEHEN WERDEN KONNTE

Ob iOS-Safari WebM/Opus ueberhaupt abspielt. Playwrights WebKit startet auf
diesem Rechner nicht (`icuuc77.dll`, auch nach Neuinstallation), und ein
iPhone habe ich nicht. Der dritte Ausgang: konnte nicht nachsehen. Deshalb
sind A, B und C drei Sicherungen hintereinander statt einer Wette auf eine.

GEPRUEFT (alles einzeln, kein Gesamtlauf)

  pruef-chat-anhaenge   146 ok   pruef-material     159 ok
  pruef-wissen-neu       33 ok   pruef-spenden      172 ok
  pruef-struktur         85 ok   pruef-support       63 ok
  pruef-video            74 ok   pruef-steckbrief    65 ok
  pruef-galerie          26 ok   pruef-eintrag-bild  24 ok
  pruef-chat             63 ok   pruef-chat-optik    62 ok
  pruef-haus-trennung   100 ok

Die beiden Haeuser bleiben getrennt (pruef-haus-trennung, 100 ok). Der
Ausliefer-Weg gehoert beiden gleichermassen und traegt nichts Haus-
spezifisches; die Aufnahme betrifft faktisch nur Team Dogi, weil nur dort
der Mikrofonknopf steht („also nur die modis rechte linke hand und
dogfather").

NICHT VON MIR, SCHON VORHER ROT: pruef-gifs meldet zwei Fehler („das GIF
steht danach wirklich im Verlauf", „die Tafel geht zu"). Gegen den
unveraenderten Stand von HEAD nachgemessen -- dieselben zwei Fehler, mit
denselben Zahlen. Ein eigener Befund, kein Nebenschaden dieser Arbeit.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 03:41:18 +02:00
DogFatherGitandClaude Opus 5 365e789cce Tagesgrenze: jetzt auch die Vergleiche, nicht nur das Schreiben
Von den sechzehn Stellen vom 01.10. SCHREIBEN zehn einen Tag in die
Datenbank — die decken die zwei Abschnitte von vorhin ab. Sechs
VERGLEICHEN gegen „heute": ist diese Frist vorbei, faellt dieser
Lead in den Zeitraum, ist dieser Bericht aktuell.

Davon findet ein Schreibtest keinen einzigen. Die Spalte sieht
richtig aus; falsch ist die Zahl, die jemand auf dem Bildschirm
liest.

ZWEI AUFGABEN GENUEGEN. Die Regel im Haus ist `frist < heute`:

    Frist = gestern (Ortszeit)  ->  muss ueberfaellig sein
    Frist = heute   (Ortszeit)  ->  darf es nicht sein

Mit dem alten UTC-Tag geht das in BEIDEN Zweigen der verschobenen
Uhr schief:

    Etc/GMT-14 (Ortstag = UTC+1): heute_alt waere gestern -> 0 statt 1
    Etc/GMT+11 (Ortstag = UTC-1): heute_alt waere morgen  -> 2 statt 1

GEGENPROBE AM ECHTEN CODE: In workspace-aufgaben.js den UTC-Tag
wieder eingebaut -> „genau eine ist ueberfaellig (0)". Zurueckgedreht
und wieder gruen.

UND EIN BEFUND AN MEINER EIGENEN PRUEFUNG, bei genau dieser
Gegenprobe: Die zweite Zahl (`heute`) blieb 1 — aber sie zaehlte die
FALSCHE Aufgabe. Mit dem UTC-Tag galt die von gestern als heute
faellig. Mit zwei Zahlen allein ist das nicht zu trennen; in beiden
Zweigen bleibt sie 1.

Sie steht jetzt ausdruecklich als BEGLEITPROBE da: Sie zeigt, dass
ueberhaupt gezaehlt wird, und der Unterschied kommt aus der Zeile
darueber. Eine Zahl, die aus dem falschen Grund stimmt, soll nicht
aussehen wie ein Beweis.

Dazu im Lauf eine ausgerechnete Zeile, die sagt, WARUM die Zahlen
etwas beweisen: „mit dem UTC-Tag waeren es 0 statt 1".

IM FENSTER NACHGEMESSEN (00:09 bis 00:40 Ortszeit, UTC noch der
Vortag) — die restlichen Pruefungen, die ich gestern zur falschen
Tageszeit geprueft hatte: zuteilung 75, aufgabenbrett 49,
content 45, vorlagen 24, bewerbung 91, scout-zuteilung 37,
unterstuetzung 70, aufbewahrung 45, ics 38, entwicklung 79 — alle 0
Fehler. Zusammen mit den vierzehn von vorhin sind das 24 Dateien,
die diesmal im richtigen Zeitfenster gemessen wurden.

pruef-tagesgrenze: 8 -> 12 Pruefungen, 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 00:36:44 +02:00
DogFatherGitandClaude Opus 5 c5f4d237e3 Tagesgrenze: der Fehler von gestern ist jetzt rund um die Uhr pruefbar
UM 00:09 DES 02.10. WAR DAS FENSTER OFFEN — Ortszeit 2026-10-02,
UTC noch 2026-10-01. Genau die zwei Stunden, in denen der Fehler von
gestern zuschlug.

Ich habe die Reparatur am 01.10. zwischen 10 und 16 Uhr geprueft,
also zu einer Zeit, in der der Fehler GAR NICHT AUFTRETEN KONNTE.
Jetzt nachgeholt — vierzehn Dateien, 1083 Pruefungen, 0 Fehler. Die
entscheidende Zeile in pruef-kreislauf:

    ok   und er liegt HEUTE, nicht gestern (2026-10-02, heute ist 2026-10-02)

Gestern um dieselbe Uhrzeit stand dort 2026-09-30 gegen 2026-10-01.

DABEI IST MIR DAS EIGENTLICHE PROBLEM AUFGEFALLEN

Diese Zeile kann den Fehler nur ZWEI STUNDEN AM TAG ueberhaupt
sehen. Den Rest des Tages sind Ortszeit und UTC-Tag derselbe, und
sie ist wahr, ohne irgendetwas zu beweisen — ein gruener Haken ohne
Aussage, 22 Stunden lang.

pruef-tagesgrenze.mjs wartet deshalb nicht auf Mitternacht, sondern
verschiebt die Uhr. Node uebernimmt eine zur Laufzeit gesetzte TZ
(nachgemessen: Stunde 0 wird zu Stunde 12 unter Etc/GMT-14), und sie
steht VOR allem anderen — sonst haette der Server schon seine
Vorstellung vom heutigen Tag.

Welche Zone, haengt von der Uhrzeit ab: Es gibt keine, in der sich
die zwei Tage IMMER unterscheiden, dafuer braeuchte es 24 Stunden
Versatz. Ab 10 Uhr UTC also Etc/GMT-14, davor Etc/GMT+11; bei 10 Uhr
gehen beide, die Grenze ist kein scharfer Rand.

UND DAS WIRD NACHGESEHEN: Unterscheiden sich die Tage wider Erwarten
nicht, bricht sie mit dem dritten Ausgang ab (3) statt gruen zu
melden. Eine Pruefung ohne ihre Voraussetzung darf nicht bestaetigen
— daran ist am 03.09. die Gitea-Pflichtpruefung wochenlang
vorbeigelaufen.

Geprueft werden die zwei Stellen, die es wirklich getroffen hat: ein
Eintrag OHNE Datum, und die Kette Wunsch -> Termin.

GEGENPROBE: Den alten Weg in workspace-bereiche.js wieder eingebaut
(beide Stellen) -> 8 Pruefungen, 2 Fehler, beide mit dem falschen
Tag im Klartext. Die Zahl bleibt bei 8, nichts wird still
uebersprungen.

ZWEI DINGE AM DETEKTOR IN pruef-struktur

1. EINE AUSNAHME, DIE SICH SELBST PRUEFT. pruef-tagesgrenze benutzt
   den falschen Weg absichtlich und wurde deshalb zu Recht und
   trotzdem falsch gemeldet. Sie ist jetzt ausgenommen — aber nicht
   als stille Liste: Es wird nachgesehen, dass jede ausgenommene
   Datei das Muster WIRKLICH enthaelt. Raeumt es jemand weg, ist die
   Ausnahme veraltet und faellt auf.

2. EINE LUECKE, DIE MIR DABEI AUFFIEL. Der Detektor kannte nur
   `.slice(0, 10)`. Denselben UTC-Tag bekommt man mit
   `.split("T")[0]` und `.substring(0, 10)`. Gemessen schreibt das
   heute niemand so — aber „niemand schreibt es so" ist kein Schutz,
   sondern Glueck. Beide zaehlen jetzt mit, mit eigenen Gegenproben.

GEPRUEFT: pruef-struktur 75 -> 79, pruef-tagesgrenze 8, beide 0
Fehler. Ports nach der neuen Datei: bis 5417, 464 Nummern, 0
Kollisionen. pruef-arbeitsschloss 48/0, pruef-fingermass 5/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 00:17:10 +02:00
DogFatherGitandClaude Opus 5 e8a5d7bc7e pruef-glocke: drei Viertel der Pruefung sind nie gelaufen
8 Pruefungen standen am Ende da. Jetzt sind es 36.

SCHRITT FUER SCHRITT NACHGEMESSEN, statt der Meldung zu glauben:

  1. Drei von acht Rollen scheiterten mit „Zeitsperre nach 25 s"
     beim Warten auf start.html -- hand, linke, modi. Danach brach
     die Datei ab; alles dahinter lief nie.
  2. Nicht an der Last: allein dasselbe Bild.
  3. Was antwortet die Anmeldung im Browser? 401 ungueltig.
  4. Dieselbe Anmeldung ueber die Schnittstelle, am SELBEN laufenden
     Server: 200. Es lag also nie am Server.
  5. Der Unterschied ist die KACHEL. Es gibt zwei Anmeldeseiten:
         workspace/index.html       spicy, admin, manager, scout, creator
         workspace/crew-index.html  admin, hand, linke, modi, gast
     Auf 127.0.0.1 kommt die Agenturseite.

KEIN FEHLER AM PRODUKT. Der Server ist auf einer Pruefadresse
absichtlich grosszuegig, damit Pruefungen alles testen koennen (das
steht so im Quelltext); die zwei Seiten sind die Trennung der zwei
Haeuser.

DER FEHLER WAR EIN NOTNAGEL IN DER PRUEFUNG:

    const kachel = await seite.$(`.rolle[data-rolle="${rolle}"]`)
      || await seite.$(".rolle");

Er sollte verhindern, dass ein Klick auf eine fehlende Kachel
abbricht. Seit die Kachel am 30.09. BINDEND ist (`718b267d`, auf
Filipes Wunsch), macht er aus „diese Kachel gibt es hier nicht"
etwas Schlimmeres: Er klickt irgendeine, der Server weist zu Recht
ab, und heraus kommt eine Zeitsperre nach 25 Sekunden, die wie ein
Fehler an der Glocke aussieht.

EIN NOTNAGEL, DER STILL DAS FALSCHE GREIFT, IST SCHLIMMER ALS EIN
LAUTER ABBRUCH. Jetzt wird die richtige Seite PROBIERT (nicht aus
einer Liste von Crew-Rollen gelesen -- die waere die naechste, die
beim sechsten Eintrag nicht mitwaechst), und findet sich die Kachel
auf keiner der beiden, bricht es mit Namen ab und zeigt, welche
Kacheln dastehen.

DAS IST HEUTE DER ZWEITE FALL DERSELBEN WURZEL. Bei
pruef-crew-wand-bild war es dieselbe Folgewirkung der bindenden
Kachel, nur sichtbarer. Nach jener Aenderung bin ich die Pruefungen
zum Zugang gelaufen und nicht die, die nebenbei eine Anmeldung
brauchen.

DENSELBEN NOTNAGEL GIBT ES NOCH EINMAL, in pruef-gifs. Dort zielt er
auf `creator`, und die Kachel steht auf der Agenturseite -- er
greift heute nie. Das ist Glueck und kein Entwurf, also ist er auch
dort weg.

GEPRUEFT: pruef-glocke 8 -> 36 Pruefungen, 0 Fehler, alle acht
Rollen. pruef-gifs 17 Pruefungen, 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 23:26:01 +02:00
DogFatherGitandClaude Opus 5 775aabef38 Standwache: hat sich der Stand WAEHREND des Laufs geaendert?
Die zweite Sitzung hat die Luecke benannt: Das Arbeitsschloss
schuetzt den Commit und die Stempellaeufe, nicht die Pruefläufe. Wer
misst, waehrend ein anderer speichert, misst einen Stand, den es
nicht mehr gibt.

Am 06./07.09.2026 sind so fuenf Gesamtlaeufe wertlos geworden — rund
zwei Stunden Wartezeit ohne einen einzigen Befund. Am 01.10. dieselbe
Lage, nur anders: zwei Sitzungen im Verzeichnis, eine misst, die
andere speichert. Aufgefallen ist es nur, weil die andere es von
sich aus gesagt hat.

DIE FRAGE IST EINE MESSUNG, KEINE ZUSTAENDIGKEIT

„Haelt jemand das Schloss" waere die falsche Frage, und zwar in
beide Richtungen:

  * Sie blockiert zu viel: Wer zwei Stunden am Schloss sitzt, haette
    in der Zeit keinen Prueflauf mehr. Eine Sicherung, die das eigene
    Arbeiten anhaelt, wird abgeschafft.
  * Sie blockiert zu wenig: Filipe am Editor hat kein Schloss, mein
    eigenes Werkzeug auch nicht.

Gefragt wird deshalb: Ist der Stand am Ende noch derselbe wie am
Anfang? Das trifft jede Quelle.

AN DER NOTBREMSE, NICHT IN 179 DATEIEN

Gemessen: 179 von 231 Pruef- und Messdateien rufen `notbremse`. Das
ist die eine Stelle, an der alle etwas bekommen — dieselbe
Ueberlegung wie bei der Notbremse selbst. Einzelne Dateien
nachzuruesten hilft nur bis zur naechsten ohne.

Fingerabdruck: Anzahl, Gesamtgroesse und juengste Aenderung von
workspace, assets, server, webdesign und den Seiten im
Wurzelverzeichnis — 670 Dateien in 27 ms. Kein Hash ueber den
Inhalt: Gesucht ist „hat jemand gespeichert", und das aendert immer
mindestens die Zeit.

BILDER ZAEHLEN NICHT. Die Messdateien legen selbst welche ab; eine
Wache, die bei jedem Bildschirmfoto anschlaegt, wird weggeklickt und
nimmt die echte Meldung mit.

SIE HAELT NICHTS AN. Ein Lauf, dessen Grundlage sich verschoben hat,
ist nicht „nicht in Ordnung" — er ist NICHT NACHSEHBAR. Deshalb
Rueckgabewert 3 (nicht 1, das waere eine Aussage ueber den Code; und
nicht 0, ein gruener Haken ueber einen Stand, den es nicht mehr
gibt, waere das Schlimmere) und ein ausdruecklicher Satz, dass das
kein Befund ist.

GEPRUEFT — pruef-arbeitsschloss 35 -> 48 Pruefungen, 0 Fehler:

  zwei Aufnahmen hintereinander gleich   (sonst waere jede Meldung
                                          wertlos)
  geaenderte Datei            -> faellt auf
  neue Datei                  -> faellt auf
  neues Bild                  -> faellt NICHT auf
  Lauf mit Aenderung          -> Rueckgabe 3, sagt NICHT NACHSEHBAR
  Lauf ohne Aenderung         -> Rueckgabe 0  (Gegenprobe; sonst
                                 waere „endet mit 3" auch wahr, wenn
                                 sie IMMER 3 meldet)

Alles in einem Wegwerf-Verzeichnis mit gefaelschtem Baum — moeglich,
weil die Wache ihre Wurzel aus ihrem EIGENEN Pfad ableitet. Waere
sie festgeschrieben, liesse sich die Wache nicht pruefen, ohne am
echten Haus zu wackeln.

UND AM ECHTEN LAUF NACHGEWIESEN: Mitten in pruef-arten eine Datei
gespeichert -> Rueckgabe 3, mit Groesse und Uhrzeit. Ohne Eingriff
bei neun Laeufen (treff 85, kalender 141, material 159,
erwaehnung 129, arten, dabei-optik, tagesblick, aufgabenbrett,
kopfleiste, seiteninhalt) kein einziger Fehlalarm.

EINE ERWARTUNG VON MIR WAR FALSCH, und die Wache hatte recht: Ich
hatte im gefaelschten Baum eine zaehlende Datei erwartet, es sind
zwei — die Seite UND die Kopie der Wache selbst im server-Ordner.
Der zaehlt mit, und das ist richtig: Eine geaenderte Pruefdatei
verschiebt den Stand genauso wie eine geaenderte Seite.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 23:18:13 +02:00
DogFatherGitandClaude Opus 5 ec0ee61691 Arbeitsschloss: der Haken bleibt auf LF, und ein Messfehler von mir
ZWEI NACHTRAEGE ZUM SCHLOSS.

1. DER HAKEN HAT KEINE DATEIENDUNG

Damit greift keine der Regeln in .gitattributes von sich aus — und
git kuendigt beim Committen selbst an: „LF will be replaced by CRLF
the next time Git touches it". Auf Linux ist `#!/bin/sh` mit einem CR
dahinter ein Programmname mit einem unsichtbaren Zeichen am Ende
(„bad interpreter"), und die Datei hat schon einen Kommentar genau
darueber.

Eigene Zeile dazu: `tools/git-haken/* text eol=lf`.

NACHGEMESSEN, DAMIT HIER KEINE BEHAUPTUNG STEHT: Auf Windows
blockiert auch ein CRLF-Haken richtig — Rueckgabe 1, null Commits,
richtige Meldung. Das ist also Vorsorge und keine Reparatur. Aber ein
Haken, der still nicht laeuft, ist genau die Sicherung, die aussieht,
als waere sie da.

Zwei neue Pruefungen dazu: der Haken hat reine LF-Enden (byteweise
gemessen), und die Regel steht in .gitattributes.

2. EIN MESSFEHLER VON MIR, UND ER GEHOERT AUFGESCHRIEBEN

Ich habe gemeldet, im Verlauf lägen 73 CR-Bytes. Es war keines.
Gemessen hatte ich mit einer Rohrkette aus `tr` und `od`, und `tr`
nimmt in dieser Shell die Maskierung fuer das Wagenruecklauf-Zeichen
nicht als Zeichen — gezaehlt wurden am Ende Zeilenenden, und die
Datei hat 73.

Mit Python byteweise nachgemessen: 0 im Arbeitsbaum, 0 im Index. Die
Datei war immer LF; git hat nur VORHERGESAGT, was beim naechsten
Auschecken passiert.

Das ist heute der dritte Messfehler aus Shell-Maskierung — nach zwei
`\b`, die als Steuerzeichen in regulaeren Ausdruecken landeten und
dort je eine Pruefung lahmgelegt haben. Und beim Aufschreiben dieser
Lehre ist sie mir ein viertes Mal passiert: Aus dem Kommentar
`tr -d '\r'` wurde `tr -d ''`, die Aussage hat sich selbst bewiesen
und dabei unlesbar gemacht.

DIE REGEL STEHT JETZT, und zwar im Werkzeug selbst: Alles mit einem
Rueckstrich gehoert in eine DATEI, nie in ein Hier-Dokument. Und wer
Bytes zaehlen will, zaehlt Bytes — `readFileSync` ohne Zeichensatz,
und 13 ist 13.

GEPRUEFT: pruef-arbeitsschloss 33 -> 35 Pruefungen, 0 Fehler.
Steuerzeichen im ganzen Haus: 834 Dateien, keines.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 20:07:20 +02:00
DogFatherGitandClaude Opus 5 603319a145 Arbeitsschloss: zwei Sitzungen sehen sich jetzt
HEUTE ZWEIMAL NUR GUTGEGANGEN. Zwei Claude-Sitzungen arbeiteten
gleichzeitig in diesem Verzeichnis, ohne voneinander zu wissen.
Keine hat etwas falsch gemacht — sie konnten es nicht wissen.

  * Beide haben `git add -A` benutzt. Haette die eine
    unfestgeschriebene Arbeit der anderen im Baum gehabt, waere sie
    mitcommittet worden — unter fremdem Namen, in einer fremden
    Begruendung, und niemandem waere es aufgefallen. Nachgesehen:
    diesmal war nichts dabei.
  * Beide haben den Stempellauf gestartet. Der schreibt 45 Dateien
    um. Wer dort eine offen hatte, bekam sie unter den Haenden weg
    geaendert.

Dasselbe hat in RunOne am 03.09.2026 sieben Minuten Ausfall
gekostet. Dort gibt es seitdem `arbeitsschloss.sh`; diese Fassung
uebernimmt seine Lehren.

WAS ES IST UND WAS NICHT. Es ist kein Riegel — wer wirklich muss,
kommt vorbei. Es beantwortet die eine Frage, die heute niemand
beantworten konnte: „arbeitet hier gerade sonst jemand?"

Die teuren Fehler liegen bei einem Schloss alle in derselben
Richtung: Es blockiert zu viel und wird deshalb abgeschafft. Also:

  * Es blockiert NICHT, wenn das Schloss DEINES ist. RunOnes erste
    Fassung fragte „ist abgeschlossen" statt „haelt es jemand
    anders" — damit haette, wer ordentlich abschliesst, nie mehr
    ausliefern koennen. Dafuer gibt es `fremd`.
  * Es VERFAELLT nach zwei Stunden, und dass da jemand war, steht
    beim Uebernehmen dabei.
  * Es blockiert NICHT, wenn es selbst unlesbar ist — dritter
    Ausgang, kein Stillstand.
  * Notausgang: SCHLOSS_ZWANG=ja git commit …

DAS PROBLEM, AN DEM RUNONE HAENGT, IST HIER GELOEST. Dort faellt die
Kennung im Zweifel auf den Systembenutzer zurueck, und zwei
Claudian-Sitzungen laufen BEIDE als `claudian` — die Sicherung griff
ausgerechnet zwischen den zwei Faellen nicht, fuer die sie gebaut
wurde. Deshalb ist dort `export ARBEITER=…` Pflicht, und Pflicht
heisst: man vergisst es.

Hier steht `CLAUDE_CODE_SESSION_ID` in jeder Sitzung und ist je
Sitzung verschieden (nachgesehen, 36 Zeichen UUID). Zwei Sitzungen
auf demselben Windows-Benutzer unterscheiden sich damit von selbst,
ohne dass jemand etwas tun muss. Reihenfolge: ARBEITER, dann
Sitzungskennung, dann Benutzername MIT Warnung.

NIEMAND MUSS DARAN DENKEN:
  * die beiden Stempelwerkzeuge nehmen es selbst und geben es selbst
    frei — auch nach einem Absturz und bei Strg+C (wie `bauen.sh` im
    Shop; eines, an das man denken muss, wird vergessen und ab da
    umgangen)
  * `tools/git-haken/pre-commit` bricht jeden Commit ab, solange
    jemand ANDERS das Schloss haelt. Das ist die Stelle, die heute
    gefehlt hat: `git add -A` ist der Griff, den man ohne Nachdenken
    macht, und gegen einen Reflex hilft keine Regel auf Papier.
  * `core.hooksPath` statt `.git/hooks` — letzteres ist nicht
    versioniert und waere nach einem Klon genau dann leer, wenn es
    gebraucht wird.

GEPRUEFT, server/pruef-arbeitsschloss.mjs: 33 Pruefungen, 0 Fehler —
in einem WEGWERF-Verzeichnis mit eigenem git, damit kein echtes
Schloss angefasst wird. Darunter am echten git:

    ohne Schloss committen        -> geht      (0)
    mit dem EIGENEN Schloss       -> geht      (0)
    mit einem FREMDEN Schloss     -> bricht ab (1), nennt wer und warum
    und es stehen genau ZWEI Commits da, nicht drei
    SCHLOSS_ZWANG=ja              -> kommt vorbei
    ohne das Schloss-Werkzeug     -> laesst durch

Dazu: verfallenes Schloss laesst durch, mit laengerer Frist blockt
dasselbe Schloss wieder (Gegenprobe), unlesbarer Inhalt und
unlesbarer Zeitstempel blockieren nicht.

Portnummern nach der neuen Pruefdatei nachgemessen: Pruefbereich bis
5415, 462 Nummern, 0 Kollisionen. pruef-struktur 75/0,
pruef-fingermass 5/0, pruef-ports 10/0.

In DEPLOY.md steht es jetzt an erster Stelle — eine Sicherung, von
der nur der weiss, der sie gebaut hat, ist die erste, die umgangen
wird.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 20:04:28 +02:00
DogFatherGitandClaude Opus 5 1afb38258c Anfuehrungszeichen: drei Loecher in meiner eigenen Wache
Die zweite Sitzung hat in den Praesentationen des Vaults 83 Stellen
gefunden — und damit auf Loecher in MEINER Wache gezeigt. Ich habe
sie nachgemessen, alle drei waren echt.

LOCH 1 — SIE SAH NUR `server/workspace-*.js`

Also ausgerechnet nicht `workspace.js`, die groesste Datei des
Hauses. Der weitere Filter fand dort sofort drei Stellen:

    workspace.js:3404  Kanal „Chat-Moderation"     (Protokoll)
    workspace.js:3422  „${titel}" -> ${haus}       (Protokoll)
    workspace.js:3304  „${… || "ohne Titel"}"      (siehe Loch 3)

Das ist heute der DRITTE Fall von „die Wache kennt nur die halbe
Menge" — nach pruef-struktur (sah nur die Pruefdateien, nicht die
Anwendung) und pruef-fingermass (sah keine variable Breite).

LOCH 2 — IHR AUSDRUCK GILT JE ZEILE

Ein `„`, das erst in der naechsten Zeile mit einem geraden `"`
schliesst, fiel durch. Gemessen: drei echte Faelle, zwei davon
sichtbarer Seitentext.

    workspace/leistung.html       „nicht / zugeordnet"
    workspace/leistung.html       „Backstage-Tabelle / einfuegen"
    workspace/assets/js/ampel.js  „noch ' + 'verbessern"

Beim letzten laeuft das Zitat ueber eine Zeichenketten-Verkettung.

Neue Regel ohne Fehlalarme: erst alle RICHTIG geschlossenen Paare
entfernen (auch mehrzeilig), dann die einzeilig falschen (die meldet
die alte Regel). Was danach noch ein `„` hat und binnen drei Zeilen
ein gerades `"`, ist ein Fund. Ausgenommen bleibt das Zeichen ALS
WERT — in `content: "„";` und in den Sprachtabellen ist das `„` der
Inhalt und das `"` die Begrenzung.

LOCH 3 — EINE AUSNAHME, DIE EINEN FUND VERSCHLUCKT HAT

Gefunden durch die ZAHL, nicht durch den Blick: Der weitere Filter
liess die Ausnahmen von 12 auf 13 steigen. Die dreizehnte war

    `… „${zeile[titelSpalte] || "ohne Titel"}"`

Der Ausdruck blieb am `"` von `"ohne Titel"` haengen; als Inhalt kam
`${zeile[titelSpalte] || ` heraus, das endet auf ein Leerzeichen,
und damit galt „der Satz geht in der naechsten Zeile weiter". Das
echte Schlusszeichen stand hinter dem `}` und war gerade.

Dass die Ausnahmen gezaehlt UND genannt werden, hat ihn gefunden.
Genau dafuer steht die Zeile dort.

Behoben an der Wurzel: Anfuehrungszeichen innerhalb einer Einsetzung
`${…}` werden vorher durch X ersetzt, bei gleicher Laenge und
gleichen Zeilenumbruechen. Das nimmt einer ganzen Sorte
Fehlklassifizierung die Grundlage — die Ausnahmen fielen danach von
13 auf 11.

UND EIN VIERTES, BEIM MESSEN AUFGEFALLEN

`„Van-Van”` in assets/js/data-modis.js schliesst mit U+201D, dem
ENGLISCHEN Zeichen. Zweimal, beide Male in einer DEUTSCHEN Zeile
(`de:` und `"de-CH":`) — nicht einmal eine Uebersetzung, in der es
richtig waere. Eigene Regel dafuer, die keine Ausnahme braucht: Wer
mit `„` oeffnet, schliesst deutsch; in fremdsprachigen Zeilen steht
gar kein `„` am Anfang.

DIE AUSNAHMEZAHL IST JETZT STRENG. Sie stand auf „hoechstens 12" —
damit waere der Anstieg auf 13 nicht aufgefallen. Jetzt genau 11,
wie bei den Grundlinien in pruef-fingermass.

GEPRUEFT: pruef-struktur 68 -> 75 Pruefungen, 0 Fehler. Dazu
leistung-optik 59/0, ampel 47/0, treff 85/0, modi-verborgen 87/0 —
die Seiten, deren Text ich angefasst habe. Steuerzeichen: 830
Dateien, keines. Beide Stempel gesetzt.

Sechs echte Stellen berichtigt, vier neue Gegenproben.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 19:28:38 +02:00
DogFatherGitandClaude Opus 5 2b3f140112 Wissen: ein gerades Schlusszeichen, von der Wache gefunden
workspace/assets/js/wissen.js:556

    ... legst du unter „Dateien" ab.      (gerades ")
    ... legst du unter „Dateien“ ab.

Die Zeile ist nicht von mir: Sie kam heute um 14:33 aus einer
zweiten Sitzung im selben Verzeichnis (Commit 63a56980). Die Wache,
die ich heute Vormittag gebaut habe (95c4599b), hat sie beim ersten
Lauf danach gemeldet — mit Datei und Zeile.

GENAU DAFUER WAR SIE DA. 199 Stellen aufzuraeumen ist nichts wert,
wenn am Abend desselben Tages die naechste dazukommt. Zwischen
Aufraeumen und erstem Rueckfall lagen fuenf Stunden.

NACH DEM FREMDEN ZUSAMMENLAUF DAS HAUS DURCHGESEHEN:
  pruef-struktur         68 / 0   (nach dieser Berichtigung)
  pruef-wissen-formular  30 / 0   (neu, aus der anderen Sitzung)
  pruef-dateien-liste    15 / 0   (neu, aus der anderen Sitzung)
  pruef-wissen-neu       16 / 0
  pruef-ports            10 / 0   460 Ports durchprobiert
  pruef-portnummern      41 / 0
  pruef-fingermass        5 / 0

UND EINE WACHE, DIE ICH BEWUSST NICHT GEBAUT HABE: Nach den zwei
Zeitbomben von heute (pruef-dabei-optik, pruef-chat-anhaenge — beide
gruen allein, rot unter Last) habe ich die ganze Klasse
nachgemessen. 850 feste Wartezeiten in 139 Dateien — dagegen eine
Wache zu bauen waere eine Warnung, die immer kommt.

Das engere Merkmal der beiden echten Faelle: Sie lasen einen Wert,
den eine Ueberblendung DURCHLAEUFT (`opacity`), nach einer festen
Wartezeit. Gemessen: 8 solche Stellen im ganzen Haus, 4 ohne
Absicherung — drei davon sind die heute reparierten, und die vierte
(pruef-glocke:380) ist keine: Am `.glocke__strich` haengt gar keine
Blende, nur an Rahmen, Hintergrund und Farbe.

Also null echte Funde und vier Fehlalarme. Eine solche Wache ist
schlechter als keine. Die Lehre steht stattdessen ausfuehrlich an
den zwei Stellen, an denen sie etwas gekostet hat.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 19:17:49 +02:00
DogFatherGitandClaude Opus 5 159036efc9 helfer-port: die gemessene Zahl ist ein Stand, keine Wahrheit
Im Kommentar stand „die Pruefungen reichen bis 5409, Luft fuer 245
weitere Dateien". Noch am selben Tag kamen zwei Pruefdateien aus
einer anderen Sitzung dazu, und daraus wurde 5413/243.

Das ist kein Fehler, sondern der Beweis, dass die Umstellung von
gestern richtig war: Zwei fremde Dateien haben alle Nummern
dahinter verschoben, und es musste niemand etwas nachtragen.
Nachgemessen danach: 460 Nummern, alle verschieden, 0 Kollisionen,
pruef-ports 10/0 und pruef-portnummern 41/0.

Aber eine Zahl im Kommentar, die sich am Tag ihrer Messung schon
bewegt hat, ist genau die Sorte, der jemand spaeter glaubt. Sie
steht jetzt als STAND da, mit dem Hinweis, wo die heutige zu holen
ist: pruef-portnummern sagt sie bei jedem Lauf.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 15:50:41 +02:00
DogFatherGitandClaude Opus 5 b2b016f8dd Reaktion auf 320 px: das Bild ist ganz zu sehen
NACHGEMESSEN, wie es nach dem Einklappen der Reiterleiste wirklich
aussieht — und zwar im ANFANGSZUSTAND, mit zugeklappter Leiste:

                Regie   Raum   Kino   Schiene   Pult   Transport
    412x915       45     620    232     388      128     181
    390x844       45     549    219     330      128     181
    360x640       45     345    203     143      128     181
    320x568       45      75     75       1      198     181

Die Regieleiste ist von 101 bzw. 148 auf 45 Pixel geschrumpft — auf
320 px sind das 197 gewonnene Pixel.

EIN MESSFEHLER VON MIR, hier festgehalten: Mein erster Lauf zeigte
die Leiste GROESSER als vorher (148, 195, 242). Das Werkzeug klappt
sie weiter oben selbst auf, um die Register zu messen, und ich habe
danach die Hoehen gelesen. Gemessen war also der falsche Zustand —
eine Messung, die veraendert, was sie misst.

WAS AUF 320 BLEIBT: Der Raum hat 75 Pixel, das Bild wollte 180. Das
Pult liegt mit `z-index: 60` darueber, die Knoepfe sind also
erreichbar — und genau deshalb melden pruef-breiten und pruef-handy
nichts. Zu sehen war vom Bild trotzdem nur das obere Drittel.

`max-height: 100%` an der Leinwand. Gemessen wird der Kasten damit
320x75; die Breite bleibt, das Verhaeltnis gilt nicht mehr — aber
der Spieler darin setzt das Bild mittig mit Balken links und
rechts. Es ist klein und GANZ, statt gross und zu zwei Dritteln
hinter dem Pult.

(Im Kommentar stand zuerst, die Breite folge dem Verhaeltnis. Tut
sie nicht — 320x75, nicht 133x75. Berichtigt, bevor jemand sich
darauf verlaesst.)

AUF 360, 390 UND 412 AENDERT SICH NICHTS: Dort ist der Kasten
hoeher, als 16:9 verlangt, und `max-height` greift nicht ein.
Nachgemessen: 203, 219, 232 — wie vorher.

GEPRUEFT: pruef-handy 186/0, pruef-breiten 23/0, pruef-reaktion
421/0, mess-quer „NICHTS rollt seitlich".

EHRLICH BLEIBT: 320x568 zeigt weiterhin keinen Chat (Schiene 1 px),
und das Bild ist dort 320x75. Mit 198 Pixeln Pult und 181 Pixeln
Transport von 499 geht nicht mehr. Das waere ein eigener Schritt
und eine eigene Entscheidung — kein Nebenbei.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 15:47:14 +02:00
DogFatherGitandClaude Opus 5 63a56980c0 Wissen: Eine Absage ohne Alternative ist eine halbe Antwort
Wer eine Nicht-PDF in die Ablage zieht, bekam "Das ist keine
PDF-Datei." -- richtig, aber eine Sackgasse. Filipe hat heute eine
HTML-Praesentation dort abzulegen versucht, keinen Weg genannt
bekommen und den Fehler bei sich gesucht.

Jetzt steht dabei, wohin sie gehoert: unter "Dateien". Das ist die
einzige Stelle, an der wir es ueberhaupt sagen koennen -- beim
Auswaehlen ueber den Knopf greift schon `accept`, und die Datei ist
im Fenster des Betriebssystems gar nicht erst anklickbar.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 14:33:06 +02:00
DogFatherGitandClaude Opus 5 5a9f1f467c Wissen und Dateien: Zwei Raster, die ihren Inhalt verloren haben
Filipe zum Bildschirmfoto der Wissen-Seite: "erstens sieht das richtig
scheisse aus". Zu sehen war die Kategorie-Auswahl quer ueber "STUFE",
"GERAET", "VEROEFFENTLICHT AM" und den Tag-Knoepfen. Text auf Text.

Zweimal dieselbe Ursache, zweimal ein Raster, das Kinder in eine Zelle
zwingt, die zu klein ist:

1. FORMULAR "NEUE ANLEITUNG" (aufgaben.css). Die Regel
   `grid-template-rows: 1fr 44px` gibt jeder Zelle eine feste
   Eingabezeile -- richtig und wichtig, damit alle Felder auf einer
   Linie sitzen. Die Zelle "Hauptkategorie" enthaelt aber keine
   Eingabe, sondern sechs Gruppen mit zwanzig Knoepfen. Gemessen: der
   Kasten 40 px hoch, sein Inhalt 442 -- 402 px liefen ueber alles
   darunter. Unter 560 px passierte das nicht, weil dort ohnehin
   `auto auto` gilt; deshalb sah es am Handy richtig aus.
   Es ist exakt die Falle, die eine Regel darueber schon einmal
   zugeschnappt ist ("EINE ZELLE, DIE AUFKLAPPT ..."). Dieselbe
   Antwort, diesmal fuer .katwahl.

2. DATEILISTE (dateien.css). .datei hat die Spalten `34px 1fr auto`.
   Die ersten vier Kinder sitzen richtig; alles danach wird automatisch
   platziert und landet in der ZEICHENSPALTE. Gemessen: "Fassung 2" in
   34 px Breite, 22 px herausragend; auf 390 px zusaetzlich die
   Metazeile ("1,9 MB" in zwei Zeilen) und "Sichtbar fuer" mit 49 px
   Ueberstand.

ZWEI NEUE PRUEFUNGEN, und die erste war erst selbst falsch:
pruef-wissen-formular.mjs verglich zunaechst den KASTEN der Auswahl mit
dem der Zelle und meldete gruen, waehrend das Bildschirmfoto die
Ueberlappung deutlich zeigte. Der Kasten ist ja brav 44 px hoch --
herausgelaufen ist sein INHALT. Jetzt wird scrollHeight gegen
clientHeight gemessen, und die Pruefung wird rot, bevor der Fix
dazukommt. pruef-dateien-liste.mjs legt bewusst zweimal dieselbe Datei
ab, damit "Fassung 2" und "nicht mehr aktuell" ueberhaupt entstehen.

Versionsstempel neu gesetzt (202610011426, 670 Verweise in 45 Dateien).
Ohne ihn liegt die Korrektur auf dem Server und kommt bei niemandem an:
Cache-Control steht auf immutable.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 14:27:44 +02:00
DogFatherGitandClaude Opus 5 3a07c52499 Die drei dauerhaft roten Pruefungen sind gruen
Alle drei waren vorbestehend (Gegenprobe gegen den alten Stand
derselben Datei), und keine der drei war ein Fehler am Haus.

1. pruef-browser — WINDOWS BLOCKIERT WEBKIT

   Schritt fuer Schritt nachgegangen:
     - gemeldet: „WebKit: browserType.launch: Target page, context
       or browser has been closed"
     - nicht an paralleler Last; allein dasselbe Bild
     - `npx playwright install --force webkit` nennt den Grund:
       „Full list of missing libraries: icuuc77.dll"
     - die Datei IST da, 1,8 MB, im Ordner webkit-2336
     - Playwrights eigenes Werkzeug sagt, warum:
           PrintDeps.exe icuuc77.dll
           -> Eine Anwendungssteuerungsrichtlinie hat diese Datei
              blockiert.

   Smart App Control laesst die unsignierte Bibliothek nicht laden.
   Das ist eine Einstellung des Rechners, kein Fehler im Haus — und
   sie zu aendern waere Filipes Entscheidung, keine meine. (Smart
   App Control laesst sich nur ABschalten; wieder einschalten geht
   ohne Windows-Neuinstallation nicht. Fuer einen Testbrowser ist
   das der falsche Preis.)

   Der Kommentar im Code sagte es schon richtig — „eine Maschine,
   die nicht startet, ist nicht ,in Ordnung', sie ist nicht
   nachgesehen" —, umgesetzt war es als FEHLER. Jetzt drei
   Ausgaenge wie in pruef-ports: „BESTANDEN — 0 Fehler, 2 nicht
   nachsehbar". Damit daraus kein stiller Freispruch wird, haengen
   zwei Dinge daran: mindestens ZWEI Maschinen muessen wirklich
   gemessen haben, und die Zahl der Seitenaufrufe wird aus den
   gelaufenen Maschinen abgeleitet statt gegen eine feste 3
   geprueft.

2. pruef-crew-wand-bild — MEINE EIGENE FOLGEWIRKUNG VOM 30.09.

       anlegen("Marina", "modi", "CODE-MODI-0001");
       { name: "Modi", rolle: "creator", code: "CODE-MODI-0001" }

   Marina ist ein `modi`, angemeldet wurde sie mit der Kachel
   `creator`. Bis zum 30.09. war das egal; seit `718b267d` („die
   gewaehlte Kachel ist bindend", auf Filipes ausdruecklichen
   Wunsch) wird es zu Recht abgelehnt. Drei Befunde aus einer
   Wurzel, und mir ist es damals entgangen, weil ich nach der
   Aenderung die Pruefungen zum Zugang gelaufen bin und nicht die
   zum Wandbild.

   Die Ursache war aber nicht die falsche Zeile, sondern dass es
   ZWEI gab. `anlegen` gibt jetzt zurueck, was es angelegt hat, und
   die Anmeldung nimmt genau das. Nebenbei beweist die Pruefung
   damit zum ersten Mal, was sie beweisen sollte: Der Modi bekommt
   „Aufgaben · Team Dogi" und seine eigenen Zeichen.

3. pruef-chat-anhaenge — EINE BLENDE, DIE UNTER LAST STILLSTEHT

   Gemeldet: „0 Figuren" und „[]" statt der zwei Schriftzuege —
   aber nur mit vier parallelen Pruefungen. Allein zweimal gruen.

   Gemessen, was wirklich dasteht: lage „hoch", Figuren da, Bilder
   geladen (447x450), Deckung exakt „0". Nicht 0,3 oder 0,7 — die
   Ueberblendung hatte nicht ANGEFANGEN. Unter Last wird
   `requestAnimationFrame` gedrosselt, und eine Blende, die auf
   Bildern laeuft, steht still.

   Mein erster Versuch — „warten, bis sich nichts mehr aendert" —
   war genauso falsch wie die feste Wartezeit davor: Stillstand
   heisst auch „noch nicht angefangen", und er kam sofort zurueck.
   Auf eine Bewegung zu warten, die nicht laeuft, geht nicht.

   Also wird sie abgeschaltet. Das Haus hat die Regel schon:
   `@media (prefers-reduced-motion: reduce)` nimmt genau diesen
   beiden Dingen die Blende. Der Endwert steht damit sofort da, die
   Messung haengt nicht mehr am Rechner, und der Weg wird nebenbei
   zum ersten Mal wirklich begangen.

   Nicht tautologisch: Die Gegenprobe „und OHNE die zwei Figuren —
   die gehoeren ins Hochformat (0)" steht weiter und bleibt gruen.

GEPRUEFT, und zwar unter DERSELBEN vierfachen Last, die sie vorher
rot gemacht hat:

  pruef-chat-anhaenge  123 / 0     pruef-material      159 / 0
  pruef-crew-wand-bild  45 / 0     pruef-kalender      141 / 0
  pruef-browser       11+2 offen   pruef-start-ansicht 160 / 0
  pruef-dabei-optik     23 / 0     pruef-neue-seiten   109 / 0

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 13:54:45 +02:00
DogFatherGitandClaude Opus 5 2f3b8630c7 Messungen am Telefon: 37 bekommen ihren Finger
Gestern Mittag gemessen: 39 `newContext`-Aufrufe nehmen ihre Breite
aus einer Variablen und setzen kein `hasTouch`. Ohne das meldet der
Browser `pointer: fine`, und KEINE Regel aus `@media (pointer:
coarse)` greift — dort stehen im ganzen Haus die 44-Pixel-
Beruehrziele, die ausgeblendeten Tastenkuerzel und die
eingeklappte Reiterleiste.

Was das anrichtet, war an pruef-breiten zu sehen: drei Befunde auf
320, 390 und 430 Pixeln, die mit dem Finger allesamt verschwanden —
Messfehler, keine Fehler.

37 DAVON HABEN IHN JETZT. Nur `hasTouch`, nicht `isMobile`: Gefragt
ist genau die eine Sache, um die es geht. `isMobile` waere eine
zweite Aenderung in derselben Zeile, und wenn danach etwas anders
aussieht, wuesste niemand, welche von beiden es war.

ZWEI BLEIBEN STEHEN, beide in pruef-grosscheck.mjs. Die Aenderung
dort waere ein Zweizeiler; sie zu pruefen hiesse, 206 Seiten ueber
vier Rollen laufen zu lassen, und das braucht Filipes Zusage. Eine
Aenderung, die ich nicht pruefen darf, liefere ich nicht aus.
Grundlinie in pruef-fingermass steht deshalb auf 2.

JEDE EINZELN NACHGELAUFEN — 37 Laeufe:

  34 gruen, darunter pruef-handy 186, pruef-material 159,
  pruef-start-ansicht 160, pruef-kalender 141, pruef-erwaehnung 129,
  pruef-bewerbung-aufgaben 163, pruef-neue-seiten 109

  3 mit Befunden, ALLE DREI VORBESTEHEND (Gegenprobe: alter Stand
  derselben Datei, gleicher Lauf, gleiche Zahl):
    pruef-browser        3  (WebKit startet auf diesem Rechner nicht)
    pruef-chat-anhaenge  2
    pruef-crew-wand-bild 3

EINE ZEITBOMBE GEFUNDEN UND ENTSCHAERFT

pruef-dabei-optik meldete „Haekchen: 0 -> 0" — aber nur, wenn vier
andere Pruefungen gleichzeitig liefen. Allein: „0 -> 1", gruen.

Dort stand `waitForTimeout(250)` mit der Begruendung „250 ms sind
reichlich ueber den 160" (der Dauer der Blende). Auf einem Rechner,
auf dem nebenher vier Browser messen, sind sie es nicht. Die
Pruefung war damit gruen, solange nichts anderes lief, und rot im
Gesamtlauf — also genau dann, wenn niemand sie einzeln nachstellen
kann.

Eine Wartezeit ist eine Annahme ueber den Rechner. Gewartet wird
jetzt auf das, worauf es ankommt: dass das Haekchen da ist. Laeuft
die Frist ab, faellt die Pruefung mit dem ECHTEN Wert um und nicht
mit einem Messfehler. Gegenprobe: unter derselben vierfachen Last,
die sie vorher rot gemacht hat, jetzt gruen.

UND DIE GRUNDLINIE WIEDER STRENG. Ich hatte sie kurz auf
„hoechstens" gestellt — damit haette ein Rueckgang stillschweigend
Platz fuer die naechste Suende gedeckt. Genau davor warnt der Kopf
derselben Datei bei der anderen Grundlinie, und ich habe es eine
Stunde spaeter selbst falsch gemacht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 13:37:05 +02:00
DogFatherGitandClaude Opus 5 dc5e6a0c24 Reaktion: das Chatfeld bekommt einen Namen
pruef-handy-teamdogi meldete auf beiden Geraeten und fuer Modi und
Community dasselbe:

    reaktion.html: Bedienelement ohne Namen — input#chat-feld

Sein einziger Name war der PLATZHALTER. Der ist keiner: Er
verschwindet in dem Moment, in dem jemand anfaengt zu tippen, und
danach hat das Feld fuer ein Vorleseprogramm gar keinen Namen mehr.
Der Knopf daneben macht es seit jeher richtig
(`aria-label="Senden"`).

UND DER NAME WANDERT MIT. Das Feld sagt im Platzhalter, warum
gerade nicht geschrieben werden kann — „Der Chat ist gerade zu",
„Du bist gerade stumm geschaltet", „Gerade schreibt nur das Team".
Ein fester `aria-label` haette genau diese Auskunft fuer das Ohr
verschluckt. Er kommt deshalb aus derselben Zeile wie der
Platzhalter: eine Angabe, zwei Ausgaben. Zwei getrennte Texte
waeren die naechste Stelle, an der einer gepflegt wird und der
andere nicht.

GEPRUEFT

  pruef-handy-teamdogi  8 Befunde -> 14 Pruefungen, 0 Fehler
  pruef-reaktion        421 Pruefungen, 0 Fehler
  pruef-handy           186 Pruefungen, 0 Fehler

Damit ist die Liste der alten offenen Punkte abgearbeitet:
  - pruef-fingermass (Grundwert 1)       unveraendert 1, dazu eine
                                         zweite Zahl fuer die
                                         Faelle, die sie bisher gar
                                         nicht sehen konnte
  - Regie/Transport auf 320x568          die Ueberlappung ist weg
  - report.html 27x18 px                 gibt es nicht mehr; beide
                                         Messungen melden die Seite
                                         sauber (Notiz war veraltet)
  - Ueberstand pruef-handy-teamdogi      0 Befunde

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 12:24:46 +02:00
DogFatherGitandClaude Opus 5 48470961c7 Reaktion am Handy: nichts liegt mehr auf einem Knopf
AUSGANGSLAGE, gemessen: pruef-breiten meldete auf sechs von neun
Breiten einen verdeckten Knopf, pruef-handy auf allen drei
Telefonen. Immer dieselbe Stelle: „Offen", „Team" und „Zu" aus dem
Chatkopf lagen auf dem Pult. Wer sie antippte, traf „Starten" oder
„Beenden".

ENDSTAND: pruef-breiten 23 Pruefungen / 0 Befunde, pruef-handy 186 / 0,
mess-quer „NICHTS rollt seitlich", pruef-reaktion 421 / 0.

Davon waren DREI VON VIER Befunden Messfehler. Gefunden, weil ich
ihnen nachgegangen bin statt ihnen zu glauben.

1. pruef-breiten MASS OHNE FINGER

   newContext({ viewport: { width: breite, height: hoehe } })

   Ohne `hasTouch` meldet der Browser `pointer: fine`, und KEINE
   Regel aus `@media (pointer: coarse)` greift — dort stehen die
   44-Pixel-Beruehrziele, die ausgeblendeten Tastenkuerzel und seit
   heute die eingeklappte Reiterleiste. Auf 320, 390 und 430 Pixeln
   wurde also eine Seite vermessen, die es auf keinem Telefon gibt.
   Aufgefallen, weil mess-quer dieselbe Seite auf 390 MIT Finger
   misst und dort nichts findet. -> 390 und 430 sofort gruen.

2. DER VERDECKUNGS-FINDER KANNTE KEINE ROLLKAESTEN

   Gemeldet war auf 1024, 1920 und 2560 immer ein Feld AUS DEM PULT
   „unter" der Transportleiste — und auf 1280 und 1440 nichts. Das
   Pult rollt in sich; was unten herausgerollt ist, liegt
   rechnerisch dort, wo die Leiste steht, und `elementFromPoint`
   liefert die Leiste. Verdeckt ist da nichts — es ist weggerollt.
   Das Fenster kannte der Finder schon (`r.bottom < 0`), den
   Rollkasten nicht. -> drei Breiten gruen, die Gegenproben
   („ein echtes Hindernis wird gefunden") schlagen weiter an.

3. UND EIN ECHTER BEFUND, ZWEIMAL

   a) Reiterleiste: acht Register brauchen am Handy zwei Zeilen
      (100 px auf 360, 148 auf 320). Sie klappen jetzt hinter ihren
      eigenen Wert — dasselbe Muster wie die Tempo-Gruppe, und der
      Knopf zeigt, wo man steht. NICHT einzeilig zum Wischen:
      Filipes Ansage vom 28.09. galt dem Wischen nach links und
      rechts, und mess-quer misst genau das.

   b) Bei OFFENER Regie nimmt das Pult auf 412x915 vierhundert-
      vierundachtzig der 846 Pixel. Nebeneinander stehen Regie und
      Bild erst ab 1100 px. Am Fingergeraet tritt die Chatschiene
      deshalb beiseite, solange die Regie offen ist — wer etwas
      einstellt, muss das Bild im Auge behalten, den Chat nicht, und
      der ist einen Fingertipp entfernt.

4. MEINE EIGENE WACHE WAR BLIND — ZWEIMAL

   pruef-fingermass sucht `width: <Zahl>`. In pruef-breiten stand
   `width: breite`, eine Variable: kein Treffer, also „in Ordnung".
   Sie meldete dabei brav „1 blindes Fenster, unveraendert zur
   Grundlinie" — und war selbst blind. Jetzt gibt es einen dritten
   Ausgang: Breite vorhanden, aber keine Zahl, und kein hasTouch
   daneben -> „konnte nicht nachsehen", mit eigener Grundlinie.

   Beim ersten Anlauf meldete der 45 Faelle, darunter jedes
   `newContext({ permissions: [...] })`. Zu grob: Ein Fenster ganz
   OHNE Breite ist das Standardfenster, kein Telefon. Enger gefasst.

   UND DANN WAR DIE ZAHL 0 — WEIL DER AUSDRUCK TOT WAR. Das `\b` in
   `/\bwidth\s*:/` war ein echtes Rueckschritt-Zeichen (0x08),
   unsichtbar im Quelltext. Heute Nacht hat mich dasselbe schon
   einmal eine halbe Stunde gekostet. Mit heilem Ausdruck sind es
   39 echte Faelle — etwa mess-chat-liste bei 390 px ohne Finger.
   Als Grundlinie festgehalten: Sie darf nur sinken, jede neue
   faellt auf, und abgearbeitet wird Datei fuer Datei mit je einem
   Lauf danach.

   Ein Werkzeug sucht jetzt alle Steuerzeichen im ganzen Haus
   (tools, gitignoriert). Es fand zwei: meins von heute und ein
   aelteres in pruef-reaktion.mjs — `!/^none\b/.test(wert)` hat
   dort nie gegriffen. Beide bytewise ersetzt, 830 Dateien sauber.

5. mess-quer MUSS TUN, WAS EIN MENSCH TUT

   Es wartete auf eine SICHTBARE Reiterleiste und lief in die
   Zeitsperre, seit sie zugeklappt startet. Jetzt wartet es auf die
   Leiste und klappt auf — vor jedem Register, denn ein Klick
   schliesst sie wieder.

WAS NICHT GEMACHT WURDE, UND WARUM: Die Messwerte im Pult („Sehen
zu", „Mit Bild", „Gaeste", „Upload") standen in meiner eigenen
Auswahl als Sparposten. Beim Nachsehen steht beim Upload die
Begruendung im Quelltext: „der Unterschied zwischen ,es ruckelt bei
euch?' und ,ich sehe, dass es zu viel wird'". 26 Pixel gegen echte
Information waehrend der Sendung — falscher Tausch.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 12:10:29 +02:00
DogFatherGitandClaude Opus 5 d7f3e747b8 Reaktion: 26 Pixel zurueck fuer den Chat am Handy
Die rechte Gruppe der Transportreihe war auf 360x640 gemessen
122 Pixel hoch statt 44: In der dreispaltigen Reihe bekommt sie nur
89 Pixel Breite und bricht dreimal um. Das Wort „Weiter" davor ist
dabei eine Beschriftung fuer zwei Knoepfe, die ihre Aufgabe selbst
draufstehen haben — „Naechstes" und „Wechseln zu <Titel>".

Dieselbe Ueberlegung wie beim Wort „Tempo", das aus genau diesem
Grund schon weg ist. Am Rechner bleibt es: Dort ist Platz, und dort
ordnet es die Leiste.

GEMESSEN (mess-quer, mit Finger, Regie zugeklappt):

    Transportleiste   207 -> 181 px   auf 320, 360, 390 und 412
    Chatschiene       +26 px          auf jedem Handy
    Rechner/Tablet    unveraendert    (158 / 185)

WAS DAS NICHT LOEST, UND DAS SAGE ICH LIEBER GLEICH: Die verdeckten
Chatstufen auf 320 und 360 sind weiterhin da. pruef-breiten und
pruef-handy melden unveraendert 6 bzw. 3 Befunde. Der Grund steht
jetzt mit Zahlen im Quelltext — es ist eine Platzfrage, keine
Regelfrage, und sie braucht eine Entscheidung.

EIN VERSUCH, DER ZURUECKGENOMMEN WURDE: `minmax(0, auto)` statt
`auto` an der Kinozeile des Raums. Gemessen null Pixel Unterschied
(Kino vorher wie nachher 203 px). Der Raum ragt naemlich nicht aus
eigener Kraft ueber seine Zeile — die Zeile hat auf 360 px noch
161 Pixel, und ein 16:9-Video auf 360 px Breite will 203. Keine
Angabe an DIESEN Zeilen aendert daran etwas. Die Begruendung steht
jetzt dort, damit es niemand ein zweites Mal probiert.

Eine Regel, die nur aussieht, als taete sie etwas, ist schlimmer
als keine.

GEPRUEFT: pruef-handy 186 Pruefungen (3 Befunde, unveraendert),
pruef-breiten 23 (6 Befunde, unveraendert) — also keine neue
Beanstandung und keine verschwundene Pruefung. Stempel gesetzt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 10:46:22 +02:00
DogFatherGitandClaude Opus 5 95c4599baa Anfuehrungszeichen: 199 falsche Schlusszeichen im sichtbaren Text
Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.

Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.

WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE

Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus

    „<a href="https://…                  ein kaputtes Attribut
    /^["'„»\s]+|["'“«.\s]+$/              einen kaputten Ausdruck
    "… nichts „mal " + "eben …"          eine kaputte Zeichenkette

DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE

  < > = dazwischen        -> HTML-Marke oder Attribut
  endet auf Leerzeichen   -> die Zeichenkette hoert hier auf, der
                             Satz geht in der naechsten Zeile weiter.
                             Ein deutsches Schlusszeichen steht NIE
                             hinter einem Leerzeichen.
  Rueckstrich mittendrin  -> regulaerer Ausdruck
  ${ ohne }               -> mitten in einem Ausdruck

Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.

Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.

Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.

KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.

DIE WACHE DAZU

pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.

GEPRUEFT

  pruef-struktur   59 -> 68 Pruefungen, 0 Fehler
  node --check auf allen geaenderten JS-Dateien
  nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben

  gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
  reaktion 421, support 63, content 45, terminregel 35, treff 85

  Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
  ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
  Ausgabe, keine einzige ein Vergleich mit dem Seitentext.

GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.

Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:56:15 +02:00
DogFatherGitandClaude Opus 5 7dc5356c80 Datum: der Tag kommt aus der Ortszeit, nicht aus UTC
GEFUNDEN UM 01:22, von pruef-kreislauf -- und nur, weil nachts
gearbeitet wurde.

Die Pruefung legt einen Wunsch mit dem HEUTIGEN Datum an und macht
daraus einen Termin. Gemessen:

    geschickt:   datum = 2026-10-01   (heuteLokal auf dem Server)
    gespeichert: datum = 2026-09-30

Der Termin lag also in der Vergangenheit. Auf „Was ansteht" sortiert
er sich damit in den Abschnitt „Vorbei" ein -- und der ist mit
Absicht zugeklappt. Beim Wunsch stand „Daraus wurde ein Termin", auf
dem Brett war er nicht zu sehen. Zwei Stunden jede Nacht (im Winter
eine), genau in den Stunden, in denen hier gearbeitet wird.

URSACHE

    const jetzt = () => new Date().toISOString();   // UTC
    ... jetzt().slice(0, 10) ...                     // UTC-Tag

ES WAR NICHT EINE STELLE. Nachgemessen: SECHZEHN in vierzehn Modulen,
davon ZEHN, die den falschen Tag in die Datenbank schreiben --
Termine, Wuensche, Highlights, Talente, Leads, Videos, Vorlagen --
und sechs, die „heute" vergleichen (ueberfaellige Aufgaben, Berichte,
die Frist einer Entwicklungsaufgabe).

NICHT ANGEFASST, WEIL RICHTIG: Rechnungen auf einem Datumstext mit
fester Uhrzeit (`Date.parse(tag + "T12:00:00Z") + n * 86400000`). Die
bleiben in jeder Zeitzone am selben Kalendertag -- kalender, teamlage
und serien machen es so, und das bleibt.

WARUM ES NIEMAND GEMERKT HAT

pruef-struktur sucht dieses Muster seit dem 06.09.2026. Aber:
  - sie sah NUR in die `pruef-*.mjs`, nie in die Anwendung
  - sie kannte die Schreibweise ueber eine FUNKTION nicht
    (`const jetzt = () => ...` statt `const jetzt = ...`)

Die Wache stand vor den Pruefungen, nicht vor dem Haus -- derselbe
Fehler wie heute Nacht bei den Messports: eine Sicherung, die nur die
halbe Menge kennt, faellt in der anderen Haelfte aus, und zwar
lautlos, denn sie meldet ja „nichts gefunden".

Jetzt sieht sie in beides und kennt beide Schreibweisen. Beim ersten
scharfen Lauf fand sie sofort 23 weitere Stellen in den Pruefdateien
selbst -- dieselben Zeitbomben, gegen die sie gebaut worden war.

EINE ZWEITE WACHE, WEIL ICH SELBST HINEINGELAUFEN BIN

Mein Umbauwerkzeug hat in elf Modulen `heuteLokal()` eingesetzt und
die Einfuhr weggelassen: Es hat erst ersetzt und DANN gefragt, ob der
Name schon in der Datei steht -- da stand er, mein eigener Aufruf.
`node --check` sagt dazu nichts, „Laedt jedes Server-Modul?" auch
nicht: Die Datei ist syntaktisch tadellos. Erst der Aufruf faellt um
mit `ReferenceError: heuteLokal is not defined`. Gefunden hat es
pruef-video, zufaellig. Die anderen zehn waeren durchgerutscht.

Deshalb neu: „Ruft ein Modul etwas, das es nie eingefuehrt hat?" --
die Namen des Hauses aus den export-Zeilen gelesen, nicht
aufgezaehlt. 460 Aufrufe in 350 Dateien, alle mit Einfuhr.

pruef-kreislauf STELLT JETZT DIE RICHTIGE FRAGE

Sie war rot und hat den Fehler dabei nur gestreift: „`.kette` wird
nicht sichtbar", Zeitsperre nach 15 s. Das klingt nach der Anzeige
und schickt einen zur falschen Stelle. Neu:
  - eine Zeile fragt das DATUM (ohne Browser, nennt den Fehler beim
    Namen)
  - der Browserteil klappt zu, was zu ist, und misst dann die Kette;
    „gar nicht da" wird von „da und unsichtbar" unterschieden

NEBENBEFUND IN pruef-ics

Die Probe „fast richtig" war `echt.slice(0, -1) + "A"`. Der
Schluessel ist base64url; sein letztes Zeichen ist eines von
sechzehn. Endet er auf „A", IST die Probe der echte Schluessel, der
Server antwortet zu Recht mit 200, und die Pruefung meldet ein Loch,
das es nicht gibt -- einmal je sechzehn Laeufe. Heute Nacht zweimal
hintereinander, und die Suche ging eine halbe Stunde in eine
Aenderung, die damit nichts zu tun hatte.

GEPRUEFT

  pruef-struktur   44 -> 59 Pruefungen, 0 Fehler
  pruef-ics        37 -> 38, 0 Fehler
  pruef-kreislauf  Absturz bei Nr. 17 -> 25 Pruefungen, 0 Fehler

  und gruen geblieben: treff 85, arten 28, video 74, uebernahme 39,
  entwicklung 79, content 45, vorlagen 24, zuteilung 75,
  scout-zuteilung 37, unterstuetzung 70, aufbewahrung 45,
  bewerbung 91, treff-start 42, uebergang 65, nachwuchs 262,
  auskunft 46, modi-ideen 30, neue-seiten 109, spicy 85,
  wege-nach-draussen 67, aufgabenbrett 49, agentur 62,
  bereiche-lesend 37 -- beide Haeuser

GEGENPROBEN, DIE WIRKLICH ROT WERDEN

  - den UTC-Tag im `daraus`-Weg wieder eingebaut: pruef-kreislauf
    meldet „er liegt HEUTE, nicht gestern (2026-09-30, heute ist
    2026-10-01)", 25 Pruefungen, 1 Fehler -- und der Browserteil
    bleibt gruen, weil er jetzt aufklappt. Jede Frage bei ihrer
    eigenen Pruefung.
  - eine Einfuhr aus workspace-video.js entfernt: die neue Wache
    meldet „workspace-video.js: heuteLokal() (aus helfer-tag.mjs)"
  - beide Erkennungen je gegen einen gebauten Rueckschritt geprueft
    (Funktion, Variable, zwei Schritte, Date.now-Rechnung) und gegen
    das, was NICHT anschlagen darf (UTC-Mittag, voller Zeitstempel,
    fremdes Date, Name im Kommentar, Eigenschaft am Objekt)

EIN FEHLER BEIM UMBAU, HIER FESTGEHALTEN: Mein erster Lauf ueber die
Pruefdateien hat stumpf ersetzt und dabei KOMMENTARE umgeschrieben --
in sieben Dateien stand die alte Schreibweise als Beleg in der
Begruendung, und daraus wurde das Gegenteil. Bemerkt hat es der
Vergleich der Zahlen (34 Stellen statt der gemessenen 24), nicht die
Absicht. Zurueckgenommen und mit Schutz fuer Kommentare und
Zeichenketten wiederholt.

Datenbank vorher gesichert. Keine Schemaaenderung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:45:16 +02:00
DogFatherGitandClaude Opus 5 f6225d7341 Ports: auch die Messwerkzeuge leiten ihre Nummer ab
GEMESSEN, BEVOR ICH ETWAS ANGEFASST HABE:

  204 pruef-Dateien, abgeleitete Ports 5000-5409 (dicht belegt)
  24 mess-Dateien, Nummern von Hand: 4471 bis 5493
  davon IM Pruefbereich: 5387, 5397, 5397, 5399, 5399, 5401, 5403, 5405
  untereinander doppelt: 5397, 5399, 5461, 5483, 5491

Aufgefallen ist es, weil mess-buehne und mess-reaktion beide auf 5483
lagen und ein haengengebliebener Lauf gestern einen ganzen Messlauf
gekostet hat.

Beim Umbau kam das Groessere heraus: EINUNDZWANZIG der 24 Messdateien
hatten nicht nur eine Nummer von Hand, sondern ueberhaupt keinen
Waechter -- schlicht `const PORT = 5397;`. Liegt dort schon ein
Server, startet der eigene still nicht, und gemessen wird ab da ein
fremder Stand. Genau der Fehler, gegen den helfer-port.mjs gebaut
wurde; die Messdateien standen die ganze Zeit ausserhalb.

WAS JETZT GILT

Zwei Sorten, zwei Bereiche, beide abgeleitet aus der Stelle im
Alphabet -- jede Sorte unter ihresgleichen, sonst verschoebe eine
neue Pruefung die Nummern aller Messungen. Pruefungen ab 5000,
Messungen ab MESS_BASIS = 5900.

5900 und nicht 5500: dazwischen bleibt Platz fuer 245 weitere
Pruefdateien (bei 5500 waeren es 45). Nach oben 5948 + AUSWEICHEN
4000 = 9948, also unter 10080, der naechsten gesperrten Nummer.
Nachgerechnet, nicht geschaetzt -- eine geschaetzte 4500 hatte bei
BASIS schon einmal danebengelegen.

Eine Wache dazu: Waechst der Pruefbereich bis an MESS_BASIS heran,
bricht die Ableitung ab und sagt, was zu tun ist. Eine stille
Ueberschneidung waere genau der Fehler, den das hier beseitigt.

Die zweite Nummer kommt ueber nr=1, nie ueber `PORT + 1`:
portNummer ueberspringt gesperrte Nummern, deshalb kann die naechste
Zahl die Nummer der naechsten DATEI sein, sobald einmal eine Sperre
dazwischenliegt. Heute liegt dort keine -- das ist Glueck, kein
Entwurf.

NEBENBEI GEFUNDEN UND MIT REPARIERT

Sechs bild-*.mjs riefen den Waechter und warfen seine Antwort weg:

    await portMussFreiSein(4315, "das Bildwerkzeug");
    process.env.PORT = "4315";
    const BASIS = "http://127.0.0.1:4315";

Er lief, meldete nichts und wirkte nicht. Gibt das System den Port
dauerhaft nicht her, weicht er auf Port + 4000 aus und GIBT DIE NEUE
NUMMER ZURUECK -- diese Werkzeuge hoerten danach trotzdem auf der
alten und stuerzten mit `listen EACCES` ab, also mit genau dem
Fehler, gegen den er gebaut wurde. Dazu stand die Zahl dreimal je
Datei. Jetzt einmal, und die Antwort wird benutzt.

tiktok-videos.mjs hatte den Waechter ABGESCHRIEBEN -- eine kurze
eigene Fassung ohne den dritten Ausgang: Bei EACCES meldete sie
"belegt" und brach ab, statt auszuweichen. Auf diesem Rechner ist
genau das am 23.09. eingetreten (Port 5040, Windows-Dienst).
mess-fokus und mess-notizblock hatten dieselbe Abschrift. Eine
abgeschriebene Sicherung ist dieselbe Falle wie eine abgeschriebene
Liste.

GEPRUEFT

  pruef-portnummern  15 -> 41 Pruefungen, 0 Fehler
  pruef-ports         8 -> 10 Pruefungen, 456 statt 408 Ports geprobt
  node --check auf allen 33 geaenderten Dateien

Gegenproben, die wirklich rot werden:
  - eine Messdatei auf eine feste Nummer zurueckgesetzt -> 2 FEHL,
    danach wieder 41/0
  - die Wache: in einem Wegwerf-Ordner mit 452 pruef-Dateien bricht
    eigenerPort ab statt still zu ueberlappen; eine Datei knapp
    darunter bekommt weiter ihre Nummer (5846)
  - die Erkennungen fuer feste Nummern, PORT + 1 und weggeworfene
    Waechterantworten je gegen einen gebauten Rueckschritt

Am echten Verhalten gemessen:
  - mess-chat-liste und mess-alle-einzelsicht (beide vorher 5397)
    GLEICHZEITIG gestartet: 5906 und 5900, beide exit=0. Vorher war
    das unmoeglich.
  - die 48 neuen Nummern 5900-5947 auf diesem Rechner durchprobiert:
    keine belegt, keine vom System gesperrt
  - bild-chat.mjs durchgelaufen, drei Bilder, exit=0

Kein Eingriff am laufenden Dienst: helfer-port.mjs wird von index.js
und workspace.js nicht geladen (nachgesehen), nur von Pruef- und
Messwerkzeugen. Beide Haeuser unberuehrt -- es wird keine Zeile
angefasst, die eine Seite ausliefert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:19:41 +02:00
DogFatherGitandClaude Opus 5 8e18c7bcf0 Aufgaben: eine Bewerbung auf eine erledigte Aufgabe ist keine mehr
Beim Durchsehen der echten Daten am 30.09. gefunden, Filipe am
01.10.: „mach alles los."

GEMESSEN: Zwei Bewerbungen von Miss standen auf „beworben" -- an
Aufgaben, die laengst `erledigt` bzw. `review` waren. Bei ihr stand
weiter „wartet auf Antwort", und in der Liste der Leitung stand eine
Entscheidung an, die es nicht mehr gibt.

DAS MUSTER GIBT ES IM HAUS SCHON. `uebernahmeAbschliessen` raeumt
genau so die offenen Bewerbungen weg, wenn jemand anderes eine
Pool-Aufgabe bekommt -- samt dem Kommentar daneben: „Ohne diese Zeile
blieb eine Bewerbung auf ,beworben' stehen, nachdem jemand anders die
Aufgabe bekommen hat." Derselbe Fall, ein anderer Ausloeser, dieselbe
Behandlung.

ZWEI WEGE FUEHREN IN DEN ENDZUSTAND -- erledigen und abbrechen. Beide
rufen jetzt dieselbe Funktion; nur einen zu bedienen waere die
Haelfte, die man spaeter sucht. Der SATZ ist verschieden:
„abgebrochen" ist nicht „erledigt", und wer gewartet hat, soll den
Unterschied lesen koennen.

KEIN `entschieden_von`. Niemand hat entschieden, die Frage hat sich
erledigt. Dadurch faellt die Zeile auch aus der Absagen-Uebersicht
von gestern heraus (die fragt `entschieden_von IS NOT NULL`) --
richtig, es ist keine Absage an diesen Menschen.

KEINE BENACHRICHTIGUNG. „Deine Bewerbung: diesmal nicht" waere
falsch -- es hat niemand nein gesagt. Der Satz steht an der Zeile.
Wenn Filipe hier doch eine Meldung will, ist es eine eigene Art mit
eigenem Wortlaut, kein Anhaengsel an die bestehende.

GEPRUEFT -- pruef-bewerbung-aufgaben 163/0 (9 neue):

  bewirbt sich          -> „beworben"
  Aufgabe erledigt      -> faellt weg, mit Satz, ohne Entscheider
  Aufgabe abgebrochen   -> ebenso, mit anderem Satz
  Aufgabe noch offen    -> Bewerbung bleibt   <- die Gegenprobe

Ohne die letzte Zeile hiesse „faellt weg" womoeglich nur, dass jede
Bewerbung wegfaellt.

Ein eigener Messfehler unterwegs: Mein Lesehelfer fragte
`/api/aufgaben/:id` und bekam `undefined` -- die Antwort dort hat eine
andere Form. Vier Pruefungen waren rot, waehrend der Mechanismus im
Protokoll nachweislich lief. Jetzt ueber die Liste, die in dieser
Datei erprobt ist.

Die eine vorhandene Zeile wird nachgetragen; auf einer Kopie der
echten Datenbank durchgespielt (danach 0 offene Bewerbungen auf
durchgelaufenen Aufgaben, integrity_check ok).

pruef-zuteilung, pruef-aufgabenbrett, pruef-zwischenspeicher.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 00:59:27 +02:00
DogFatherGitandClaude Opus 5 09375047a7 Entwicklung: die Karte geht auf dem Zugetragenen auf
Filipe, 01.10.2026: „mach alles los." Damit auch der offene Punkt von
gestern: „Zugetragen" ist beim Aufmachen einer Karte die Vorgabe.

DER ALTE EINWAND BLEIBT GUELTIG -- er steht jetzt in der Bedingung
statt im Weg. Er lautete: „Ein Filter, der beim Öffnen schon etwas
versteckt, lässt einen Punkte suchen, die gestern noch da waren."
Richtig, und er trifft genau EINEN Fall: den, in dem gar nichts
zugetragen ist. Dann zeigte „Zugetragen" eine LEERE Karte, und eine
leere Karte sieht aus wie ein Fehler.

Also: Hat dieser Mensch zugetragene Punkte, steht der Filter darauf.
Hat er keine, steht er auf „Alle". Beides ist eine Auskunft, keines
ist eine Suche.

UND ER WIRD JE MENSCH NEU ENTSCHIEDEN. Bisher blieb der Filter ueber
den Personenwechsel hinweg stehen -- richtig, solange er eine
Erwartungsstufe meinte („wer Fortgeschritten gewaehlt hat, will das
weiter sehen"). „Zugetragen" ist eine Aussage UEBER DIESE PERSON;
sie mitzunehmen waere die falsche Frage.

GEMESSEN, beide Faelle:

  Diene (2 zugetragen)   -> Filter „Zugetragen", 2 Punkte
  VanVan (nichts)        -> Filter „Alle", 68 Punkte
  ein Klick auf „Alle"   -> wieder 68

Die Messung fragt jetzt, WAS DASTEHT, bevor jemand etwas anfasst.
Vorher klickte sie auf den Filter und zaehlte nach -- seit er die
Vorgabe ist, haette derselbe Klick ihn AUSgeschaltet. Sie haette das
Gegenteil gemessen und trotzdem eine Zahl gemeldet.

ZWEI NACHZUEGLER AUS DEM SICHT-WEG VON GESTERN

`pruef-werdegang` wurde rot: „403 Forbidden" im Browser, ohne Adresse.
Die Meldung sagte nicht, WORAN sie scheitert -- also sagt sie es
jetzt (`403 /workspace/api/chat/sicht`).

Die Ursache lag in der Messumgebung, nicht im Programm: Der
HTTPS-Vorbau der Pruefung reicht den Host OHNE Port weiter, waehrend
der Browser seine Herkunft MIT Port schickt. `gleicheHerkunft`
vergleicht beides und antwortet folgerichtig 403. mess-reaktion macht
es seit Langem richtig; zwei Dateien nicht.

Aufgefallen ist es erst jetzt, weil der Sicht-Weg der erste
zustandsaendernde POST ist, der auf JEDER Seite laeuft. Live stimmen
Herkunft und Host ueberein (beide ohne Port, Caddy reicht den Host
durch) -- nachgemessen.

GEPRUEFT
pruef-werdegang 103/0 (war 103/1), pruef-entwicklung 79/0,
pruef-entwicklung-kacheln 34/0, mess-vanvan-karte ohne ein einziges
ACHTUNG, pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 00:52:07 +02:00
DogFatherGitandClaude Opus 5 718b267de0 Zugang: die gewaehlte Kachel ist bindend
Filipe, 01.10.2026: „mach das. es soll fest sein."

VanVan hatte gemeldet: „Man kann sich mit seinem Zugangscode immer
noch über jeden Button der Startseite anmelden, egal welche Rolle man
hat."

ICH HATTE ZUERST ABGERATEN -- und lag falsch, weil ich eine alte Lage
beschrieben habe.

Am 09.09.2026 hatte Filipe entschieden, dass die Modis KEINE eigene
Eingangskachel bekommen: „damit die von der workspace auch nicht mal
sehen dass die modis von mir einen eigenen zugang haben." Wer keine
Kachel hat, muss irgendeine nehmen koennen -- daher der stille Zugang.

SEIT DER HAUSTRENNUNG AM 24.09.2026 STIMMT DAS NICHT MEHR. Auf
`crew.` steht laengst ein eigener Kachelsatz mit ALLEN fuenf Rollen:
DogFather, rechte Hand, linke Hand, Modi, Community (CREW_KACHEL, und
crew-index.html zeigt sie). Das Verbergen leistet seither die ADRESSE
-- wer sie nicht kennt, findet die Wand nicht; wer sie kennt, liest
die Rollennamen ohnehin offen darauf.

Der stille Zugang war damit ein Rest. Er hat niemanden mehr
geschuetzt und nur dafuer gesorgt, dass die Kachelwahl folgenlos
blieb. Nachgesehen habe ich das erst, NACHDEM Filipe widersprochen
hat; die Kachelsaetze standen die ganze Zeit im Quelltext.

WAS SICH NICHT AENDERT: Auf der Agenturwand war der stille Zugang nie
aktiv. Ein Team-Dogi-Code verhaelt sich dort weiterhin wie ein
erfundener -- gleiche Antwort, gleicher Weg, gleiche Dauer. Das ist
jetzt ausdruecklich gemessen.

EINE PRUEFADRESSE IST KEINE WAND. `127.0.0.1` ist weder crew. noch
Agentur. Ohne den stillen Zugang gaelte dort der Agentursatz -- und
kein Modi kaeme mehr herein. Fuenfzig Pruefdateien melden Team-Rollen
ueber diese Adresse an. Auf einer Adresse ohne Wand gibt es deshalb
ALLE Kacheln; welche auf welcher ECHTEN Wand steht, misst
pruef-modi-verborgen mit ausdruecklichem Host-Kopf.

GEPRUEFT -- pruef-modi-verborgen 87/0 (war 85; die fuenf Zeilen „jede
Kachel geht" sind durch sieben ersetzt, die die neue Regel und ihre
Gegenproben messen). Die Anzahl ist Zeile fuer Zeile verglichen.

  Modi-Kachel + Modi-Code      -> herein
  admin/hand/linke/gast        -> abgewiesen
  rechte Hand auf ihrer Kachel -> herein
  Agenturwand + Modi-Code      -> wie ein erfundener

pruef-crew-adresse 169/0 (unveraenderte Anzahl, zwei Zeilen
umgedreht).

SECHZEHN PRUEFDATEIEN MELDETEN SICH UEBER FREMDE KACHELN AN -- ein
Rest derselben Zeit. Systematisch gesucht statt einzeln entdeckt:
Waere ich dem roten Lauf hinterhergelaufen, haette ich beim zwoelften
aufgehoert.

UND DABEI EIN EIGENER FEHLER: Mein erster Durchlauf las die Rolle am
CODENAMEN ab (CODE-MODI- -> modi). Das ging gut, bis „Nane" kam: ein
Modi mit dem Code CODE-NANE-0001. Zwei Pruefungen wurden rot, und
zwar an einer Stelle („die Stimmen stimmen"), die mit Anmeldung
nichts zu tun hat. Ein Codename ist eine Beschriftung, keine
Tatsache -- die Rolle steht in `anlegen()`. Danach abgeleitet blieben
genau zwei Abweichungen uebrig, und beide sind absichtliche
Gegenproben.

Drei Browserpruefungen tippten die Creator-Kachel mit einem
Modi-Code. Die Modi-Kachel gibt es nur auf der Crew-Wand, und ein
Browser auf 127.0.0.1 bekommt die Agenturwand; sie melden sich jetzt
ueber die Schnittstelle an und bekommen den Keks. Gemessen werden
soll dort, was ein Modi SIEHT -- nicht, wie er hereinkommt.

Grün: pruef-modi-verborgen, pruef-crew-adresse, pruef-treff 85/0,
pruef-galerie, pruef-kanaele, pruef-modi-katalog 150/0,
pruef-modi-ideen, pruef-modi-kategorien, pruef-modi-checkliste 75/0,
pruef-modi-livecheck, pruef-kachelraster, pruef-team-ampel 32/0,
pruef-team-stufen 47/0, pruef-wunschliste, pruef-bremse,
pruef-gespraech, pruef-personen-formular 43/0, pruef-start-ansicht,
pruef-community-sicht, pruef-reaktion 421/0, pruef-abzeichen,
pruef-chat, pruef-chat-kanaele 81/0, pruef-entwicklung 79/0,
pruef-bewerbung-aufgaben 154/0, pruef-struktur,
pruef-zwischenspeicher 34/0.

`code_kennung` wird weiter geschrieben, aber nicht mehr gelesen --
sie war der Suchschluessel des stillen Weges. Stehen gelassen: Eine
Spalte zu entfernen ist eine Schemaaenderung mit Sicherung, und sie
kostet nichts.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 00:22:55 +02:00
DogFatherGitandClaude Opus 5 4503b1a475 Benachrichtigungen: nicht "Verbindung offen", sondern "sieht jemand hin"
Diene im Support (vor 5 Tagen): „Die Benachrichtigungen werden nicht
angezeigt, wenn neue Nachrichten reinkommen. Erst, wenn man die App
öffnet."

ERST GEMESSEN, WAS NICHT DAS PROBLEM IST. Am echten Bestand
nachgesehen: Diene HAT ein angemeldetes Geraet (Android, Chrome, seit
dem 29.09.), und alle zehn Geraete im Haus melden `fehler = 0`. An
der Zustellung liegt es nicht.

DANN NACHGESTELLT (pruef-abzeichen, Abschnitt 8):

    Verbindung offen  ->  KEINE Benachrichtigung
    Verbindung zu     ->  sie kommt

Genau sein Befund.

DER GEDANKE WAR RICHTIG, DIE FRAGE FALSCH. Im Quelltext stand:

    if ((zuschauer.get(personId) || new Set()).size) continue;

und daneben die Begruendung -- „wer die Seite offen hat, sieht die
Nachricht ohnehin; ihm auch noch eine Meldung aufs Handy zu schicken
ist der schnellste Weg, dass er Benachrichtigungen abschaltet." Das
stimmt. Nur beantwortet `zuschauer` eine ANDERE Frage: ob eine
VERBINDUNG offen ist. Ein Handy mit der App im Hintergrund haelt sie
weiter -- und der Server hielt Diene fuer anwesend, waehrend sein
Bildschirm schwarz war.

DIE SEITE WEISS ES, DER SERVER NICHT. `document.visibilityState` ist
die einzige Stelle, die den Unterschied kennt. Also sagt sie es --
ueber einen winzigen Weg (`/api/chat/sicht`), beim Aufbau, bei jedem
Wechsel und mit `keepalive` beim Weggehen.

MIT VERFALL, und das ist der wichtige Teil: Ein Geraet, das
abstuerzt, im Funkloch steht oder eingefroren wird, sagt gar nichts
mehr. Ohne Verfall bliebe es fuer immer „sichtbar" und fuer immer
still. Wer nicht widerspricht, gilt nach zweieinhalb Minuten als weg
-- eine Meldung zu viel ist laestig, eine zu wenig ist genau der
Fehler, den Diene gemeldet hat. Dazu alle Minute ein Lebenszeichen,
solange die App vorn liegt.

EINE STELLE FUER DIE FRAGE. Sie wurde an zwei Orten gestellt:
`siehtZu()` und eine Abschrift mitten in `chatEreignis`. Die
Abschrift war die kaputte. Jetzt fragen beide dieselbe Funktion.

GEPRUEFT -- vier Lagen, und die zweite ist die wichtigere
Gegenprobe:

  App liegt hinten        -> Meldung kommt      (war: nichts)
  sieht wirklich hin      -> KEINE Meldung      (Absicht bleibt)
  App weggelegt           -> Meldung kommt wieder
  gar keine Verbindung    -> Meldung kommt

Ohne die zweite Zeile hiesse die Reparatur nur „jetzt kommt immer
eine", und das waere der schnellste Weg, dass jemand
Benachrichtigungen abschaltet.

UND EINE PRUEFUNG HAT DEN FEHLER MITGETRAGEN. pruef-anruf-klingelt
hielt den WORTLAUT der kaputten Zeile fest -- genau das, wovor ihr
eigener Kommentar drei Zeilen darueber warnt („Die Pruefung hat den
alten Wortlaut bestaetigt statt sein Verhalten"). Sie prueft jetzt
beides: dass gefragt wird, und dass die Frage die richtige ist.

pruef-abzeichen 26/0, pruef-chat, pruef-anruf 132/0,
pruef-chat-kanaele 81/0, pruef-anruf-klingelt 25/0, pruef-push-ziel
38/0, pruef-arten 28/0, pruef-reaktion 421/0, pruef-struktur,
pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 23:48:49 +02:00
DogFatherGitandClaude Opus 5 d4a3e54a72 Aufgaben: dauerhafte Aufgaben, die nicht abgehakt werden
VanVan im Support: „Man kann bei den Aufgaben, wenn man sie verteilt,
ob selbst erstellt oder über die Vorlage noch nicht festlegen, dass
die Aufgabe dauerhaft sein soll und somit nicht vom Modi in den Status
erledigt gesetzt werden kann."

Nachgesehen: Das Wort kam im Aufgabenmodul kein einziges Mal vor. Es
war keine vergessene Zeile, es fehlte ganz.

WAS EINE DAUERHAFTE AUFGABE IST: keine, die man abarbeitet, sondern
eine, die man TUT. „Neue begruessen" ist nicht fertig, wenn man es
einmal gemacht hat.

ZWEI FOLGEN, und die zweite faellt leicht durchs Raster

1. Der Zugeteilte kann sie nicht auf „erledigt" setzen. Die Sperre
   steht im SERVER -- ein fehlender Knopf ist eine Bitte, abgelehnt
   wird an der Route. „Ich fange an" bleibt erlaubt: Auch eine
   stehende Aufgabe hat einen Anfang.

2. SIE HAT KEINE FRIST. Eine dauerhafte Aufgabe mit Frist waere ab
   dem naechsten Tag fuer immer ueberfaellig -- und eine Warnung, die
   immer kommt, ist keine mehr. Die Frist wird GELOESCHT, nicht
   ignoriert: Ein Datum, das dasteht und nicht gilt, ist schlimmer
   als keins.

WER DARF DAS SETZEN: nur, wer verteilt. Koennte der Zugeteilte seine
eigene Aufgabe dauerhaft machen, waere das eine Ausrede; koennte er
es zuruecknehmen, waere die Sperre ein Knopf weiter offen. Beides
nachgemessen.

UND SIE LAESST SICH BEENDEN. Eine Pflicht, die niemand mehr beenden
kann, waere eine Falle statt einer Regel.

DREI STELLEN, KEINE VIERTE: das Anlegeformular auf „Aufgaben", das
auf „Eure Aufgaben" (dort wird verteilt) und das Bearbeiten-Feld.
Ueber das letzte laeuft VanVans „oder ueber die Vorlage" -- eine
Vorlagen-Aufgabe entsteht ohne Formular, ein Schalter im
Vorlagenbrett waere eine vierte Stelle fuer dieselbe Frage.

An der Karte steht die Marke fuer ALLE, nicht nur fuer den
Zugeteilten: Wer sie ansieht, soll wissen, warum dort kein „Fertig"
steht. Ein fehlender Knopf ohne Erklaerung liest sich wie ein Fehler.

GEPRUEFT
pruef-bewerbung-aufgaben 154/0 (10 neue) mit vier Gegenproben: eine
GEWOEHNLICHE Aufgabe laesst sich sehr wohl abhaken (sonst hiesse 409
nur, dass niemand je etwas abhaken kann), „Ich fange an" geht
weiterhin, der Zugeteilte setzt und nimmt „dauerhaft" nicht, und nach
dem Beenden durch die Leitung geht das Abhaken wieder.

ZWEI EIGENE FEHLER, beide von Pruefungen gefunden

* Ein BACKTICK in einem Kommentar -- mitten in einem Template-String
  (`SPALTEN`). Er hat ihn beendet, die Datei war syntaktisch kaputt.
  Dieselbe Familie wie die deutsche Anfuehrung in einem
  Anfuehrungsstring: ein Zeichen, das in der Umgebung etwas bedeutet.
* `toISOString().slice(0,10)` fuer „morgen" -- pruef-struktur hat es
  noch am selben Abend gefunden. Zwischen 00:00 und 02:00 liegt der
  UTC-Tag noch auf gestern; die Pruefung haette nachts falsch
  angeschlagen. Jetzt ueber `tagLokal()`.

Und einer, den nur die Messung zeigen konnte: `holen()` in
workspace-zuteilung liest die Aufgabe mit einer eigenen, kurzen
Spaltenliste. Ohne `dauerhaft` darin fragte die Sperre `a.dauerhaft`
und bekam `undefined` -- sie war still wirkungslos, und im Quelltext
daneben sah alles richtig aus.

pruef-aufgabenbrett, pruef-zuteilung, pruef-aufgaben-vorlagen,
pruef-entwicklung 79/0, pruef-css-klassen, pruef-deutsche-texte,
pruef-struktur, pruef-zwischenspeicher 34/0.

Schemaaenderung: ADD COLUMN dauerhaft. Datenbank vorher gesichert und
geprueft (integrity_check ok, 12 Aufgaben).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 18:52:37 +02:00
DogFatherGitandClaude Opus 5 5e26df788b Bewerbungen: die Absage bleibt lesbar, und ihr seht, was ihr absagt
VanVan im Support: „Wenn sich ein Modi auf eine Aufgabe bewirbt und
man diese ablehnt, dann bekommt der Modi keine Benachrichtigung
darüber, dass die Aufgabe abgelehnt wurde und sieht somit auch eine
eventuelle Begründung nicht. Außerdem haben auch wir nirgendwo eine
Übersicht, bei wem wir welche Aufgaben schon abgelehnt haben."

GEMESSEN, BEVOR GEBAUT WURDE -- die Pruefungen standen zuerst da und
waren rot:

    Frida sieht ihre beantwortete Bewerbung     0
    Die Leitung sieht die Absagen               0

DIE URSACHE. `vorlagenBewerbungenFuer` fragt `WHERE zustand =
'beworben'`. Sobald jemand antwortet, faellt die Zeile aus JEDER
Ansicht heraus -- mitsamt der Begruendung, die Filipe am 23.09.
ausdruecklich verlangt hat („mit einem text als notiz"). Der Satz
wird also verlangt, geschrieben und weggesperrt.

Bei einer ZUSAGE fiel das nicht auf: Dort entsteht eine Aufgabe, und
an ihrer Karte steht die Antwort. Bei einer ABSAGE entsteht nichts.

Die Benachrichtigung selbst gab es (`bewerbung_antwort`, vorgabe an,
fuer ja und nein). Sie war nur das EINZIGE -- wer sie wegwischt oder
kein Geraet angemeldet hat, erfuhr nie, warum.

WAS JETZT DASTEHT

* BEIM BEWERBER: „Antwort auf deine Bewerbung" mit dem Ergebnis, dem
  Satz und dem Namen dessen, der geantwortet hat. Beide Ausgaenge --
  eine Liste, die nur Absagen sammelt, waere eine andere Sache. Nach
  14 Tagen verschwindet sie von selbst.

  SIE STEHT VOR DEM ZUKLAPPEN. Beim ersten Anlauf lag sie im Koerper
  des Vorlagenbretts, und der wird nur gebaut, wenn das Brett
  aufgeklappt ist -- zugeklappt ist die Vorgabe. Eine Antwort, die
  man erst aufklappen muss, ist wieder keine.

* BEI DER LEITUNG: „Schon abgesagt", 90 Tage, mit der Zahl JE MENSCH
  vorn. Das ist VanVans eigentliche Frage („falls sich jemand immer
  wieder bewirbt und immer wieder abgelehnt wird"), und die
  beantwortet eine Liste von zwanzig Zeilen nicht.

  AUS BEIDEN WEGEN -- Vorlage und Aufgabe. Eine Uebersicht, die nur
  die Haelfte zeigt, ist schlimmer als keine: „Frida wurde nie
  abgelehnt" waere dann eine Auskunft, die stimmt und taeuscht.

  ZUGEKLAPPT. Eine Liste von Absagen, die immer offen steht, ist eine
  Sammlung von Nein-Sagen ueber Menschen, mit denen man morgen wieder
  arbeitet.

  KEIN ROT. Eine Absage ist kein Fehler; „diesmal nicht" ist keine
  Aussage ueber den Menschen. Gedeckter Bernstein, und ab zwei Malen
  faellt die ZAHL auf -- nicht nur die Farbe.

ZWEI EIGENE FELDER UND NICHT `bewerbungen` ERWEITERT. Dort heisst
eine Zeile im Browser „wartet auf Antwort", samt Zurueckziehen-Knopf.
Beantwortete Zeilen hineinzumischen haette bei einer Absage genau das
gezeigt. Ein Feld, dessen Bedeutung sich aendert, bricht seine Leser
lautlos.

`entschieden_von IS NOT NULL` TRENNT DREI DINGE, die alle `abgelehnt`
heissen: die Leitung hat eine Bewerbung abgelehnt (das hier), die
Person hat eine Zuteilung abgelehnt (ihre Entscheidung), und „von
jemand anderem uebernommen" (gar keine Absage).

Haustrennung gilt: `darfSchreibenMit` je Zeile.

GEPRUEFT
pruef-bewerbung-aufgaben 144/0 (weitere 10 Pruefungen plus zwei
Bilder). Die Gegenprobe steht in der Vorgeschichte: Dieselben
Pruefungen waren vor dem Bau rot.

Zwei eigene Messfehler, beide im Text festgehalten: Ich habe zuerst
auf dem AUFGABENbrett gemessen -- der Vorlagenkatalog steht aber auf
„Eure Aufgaben" (`zweig: 'team'`). Und `innerText` liefert den Text
so, wie er DASTEHT: Die Zeile suchte „Diesmal nicht" und fand
„DIESMAL NICHT", weil das Schild `text-transform: uppercase` traegt.

pruef-vorlagen 24/0, pruef-aufgaben-vorlagen, pruef-zuteilung,
pruef-aufgabenbrett, pruef-entwicklung 79/0, pruef-css-klassen,
pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 18:34:24 +02:00
DogFatherGitandClaude Opus 5 c933bcd2e0 Aufgaben: wer verantwortlich ist, hat auch eine Zuteilung
VanVan im Support: „Wenn ein Modi sich auf eine Aufgabe beworben hat
und die von uns angenommen wurde, dann steht beim Modi unter der
Aufgabe immer noch ich bewerbe mich."

GEMESSEN AN DEN ECHTEN DATEN (lesend, auf einer Kopie):

    Aufgaben mit Verantwortlichem:     11
    davon OHNE Zuteilungszeile:         8

Darunter Marinas zwei offene Vorlagen-Aufgaben -- genau die zwei
Karten aus ihrem Bildschirmfoto.

DIE URSACHE. `katalogAufgabeAnlegen()` schrieb eine Zeile in
`aufgaben` mit `verantwortlich_id` und KEINE in
`aufgaben_zuteilung`. Das Brett liest aber die Zuteilung, nicht den
Verantwortlichen: Es fand nichts, lieferte `meine_zuteilung: null`,
und die Bedingung im Browser beginnt mit `!mein` -- also bot sie an,
sich auf die eigene Aufgabe zu bewerben.

ES WAR NICHT NUR EIN FALSCHER KNOPF. „Ich fange an" und „Fertig"
haengen an derselben Zeile. Der Mensch bekam eine Karte, auf der er
das Falsche tun konnte und das Richtige nicht.

WARUM DIE VORHANDENE PRUEFUNG ES NICHT FAND: Sie geht den Weg ueber
das AUFGABENbrett -- dort war alles in Ordnung, nachgemessen steht
nach dem Annehmen richtig „Ich fange an | Fertig". VanVans Weg ist
der ueber das VORLAGENbrett, und der endet in einer neu angelegten
Aufgabe. Dieser Weg war nie geprueft.

DREI TEILE

1. `katalogAufgabeAnlegen` legt die Zuteilungszeile mit an. Mit
   Zustand: `angenommen`, wenn die Person darum gebeten hat und die
   Bitte angenommen wurde; `offen`, wenn die Leitung zutraegt -- dann
   steht bei ihr Annehmen/Ablehnen, und das ist richtig, sie hat noch
   nicht ja gesagt.

2. Eine Umstellung traegt nach, was schon dasteht. Wiederholbar
   (`NOT EXISTS` + `INSERT OR IGNORE`), deshalb bei den Umstellungen
   und nicht in einem Skript, das jemand vergisst. Auf einer KOPIE
   der echten Datenbank durchgespielt: 8 Zeilen angelegt, danach 0
   ohne Zuteilung, `integrity_check ok`. Marinas Karten stehen
   danach auf `angenommen`.

3. Ein Riegel im Browser: Auf eine Aufgabe, die mir schon gehoert,
   bewirbt man sich nicht. Er faengt jeden weiteren Weg ab, der eine
   Aufgabe ohne Zuteilungszeile anlegt -- nicht alle acht kamen aus
   der Vorlage.

GEPRUEFT
pruef-bewerbung-aufgaben 126/0 (15 neue: der ganze Vorlagenweg von
der Bewerbung bis zum Knopf auf ihrem Brett). NACHGESTELLT: Ohne die
Zuteilungszeile wird sie rot („und sie hat eine Zuteilungszeile
(undefined)"). pruef-zuteilung, pruef-aufgabenbrett, pruef-vorlagen
24/0, pruef-aufgaben-vorlagen, pruef-struktur, pruef-zwischenspeicher
34/0.

Ein Messfehler unterwegs, im Text festgehalten: Mein neuer Block
bewarb sich auf dieselbe Vorlage wie ein spaeterer Abschnitt und
nahm ihm damit seine -- zehn Pruefungen wurden rot, ohne dass am
Programm etwas falsch war.

Datenbank vor der Umstellung gesichert und geprueft
(integrity_check ok, 12 Aufgaben, 3 Zuteilungen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 18:08:36 +02:00
DogFatherGitandClaude Opus 5 721ad262df Entwicklung: „Deine Karte" laesst sich einklappen
VanVan im Support: „Deine Karte im Bereich eure Aufgaben kann man
noch nicht einklappen, was die Seite unnötig lang zieht und
unübersichtlich macht."

GEMESSEN (mess-vanvan-karte, Abschnitt 5). Es ist IHRE eigene Karte,
nicht die eines Modi -- im Bildschirmfoto steht an jedem Punkt „1 ×
Läuft", also eine einzige Antwort. Ueber die rechte Hand urteilt nur
DogFather; bei ihr ist deshalb alles durch und die Karte entsprechend
lang, waehrend ein Modi zwei Stimmen braucht.

                       vorher     nachher
  Seite gesamt         7245 px    2945 px
  davon „Deine Karte"  6186 px    1886 px   (zugeklappt 636 px)
  Schalter                   0    6 + „Alle"-Knopf

6186 von 7245 px waren eine einzige Karte -- 85 Prozent der Seite,
68 Zeilen am Stueck, kein einziger Schalter.

DERSELBE MECHANISMUS WIE NEBENAN, NICHT EIN ZWEITER. Die Karte der
Leitung klappt seit Langem auf und zu. Die Klassen, der Pfeil, die
Zahl im Kopf und der Knopf „Alle auf-/zuklappen" sind dieselben --
ein zweiter Mechanismus daneben waere der, der beim naechsten Umbau
vergessen wird, und er saehe anders aus, obwohl er dasselbe tut.
Herausgeloest als `klappAbschnitt()`.

WELCHER STEHT OFFEN: Was ich TUN soll („Deine Aufgaben"), und von den
Beobachtungen die erste Kategorie. Alle zu hiesse „jedes Mal erst
suchen", alle auf waere der Zustand von vorher.

DIE ZAHL STEHT IM KOPF (14, 12, 12, 11, 10, 9). Eine zugeklappte
Kategorie, die nicht sagt, wie viel in ihr steckt, klappt man einmal
auf und danach nie wieder zu.

GEPRUEFT
Die Messung ist jetzt selbst die Wache: Sie schlaegt an, wenn es
keine Schalter gibt (so ist der Befund entstanden), wenn Zuklappen
weniger als die Haelfte spart, und wenn es Schalter ohne
„Alle"-Knopf gibt. Alle drei nachgestellt. Ein ANTEIL und keine feste
Pixelzahl -- 68 Punkte heute, vielleicht 90 naechstes Jahr.

pruef-entwicklung 79/0, pruef-werdegang 103/0,
pruef-entwicklung-kacheln 34/0, pruef-css-klassen,
pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 17:33:37 +02:00
DogFatherGitandClaude Opus 5 51c47e1c07 Entwicklung: der Mensch sieht, was seine Aufgabe ist
VanVan im Support: „wenn ich … Aufgaben an einen Modi zutrage und
auch bewerte, dann sieht der Modi zwar die Auswertung im Bereich
Entwicklung, aber bei ihm taucht nichts im Bereich eure Aufgaben
unten in der Karte auf."

IHR FALL NACHGESTELLT (server/mess-vanvan-karte.mjs): VanVan traegt
einem Modi zwei von 68 Punkten zu und bewertet sie, Filipe bewertet
nichts -- genau wie bei ihr.

  vorher   seine Karte: fertig=0 von 68, alles „offen",
           Punktzeilen sichtbar: 0
           Kennt sie das Wort „zugetragen"?  NEIN
  nachher  Abschnitt „Deine Aufgaben (2)" mit beiden Titeln

ZWEI DINGE WAREN EINS, DIE GETRENNT GEHOEREN

  Was er TUN soll   die Zuteilung. Die gibt es, bevor jemand etwas
                    bewertet, und sie gehoert ihm.
  Was man SIEHT     die Bewertung. Die erscheint erst, wenn alle
                    hingesehen haben.

`meine-karte` kannte nur das Zweite. War die Bewertung noch nicht
vollstaendig, stieg die Anzeige mit `return` aus -- und damit sah er
gar nichts, obwohl ihm zwei Aufgaben zugetragen waren. Das ist die
falsche Reihenfolge: Wer seine Aufgabe erst sieht, wenn sich zwei
Leute ueber ihre Ausfuehrung einig sind, kann sie nicht angehen.

Die Regel fuer die Bewertung bleibt unveraendert. Eine einzelne
Meinung soll bei einem Menschen nicht als Urteil des Teams ankommen.

UND DIE ANDERE HAELFTE: NIEMAND SAGTE ES IHR

VanVan hatte gesetzt, es kam nicht an, und nichts auf ihrem
Bildschirm erklaerte das. Ein Mensch, der das zweimal erlebt, hoert
auf zu setzen. An einem Punkt, den sie bewertet hat, steht jetzt
leise: „Er sieht das noch nicht -- es fehlt noch eine Einschaetzung."
Ohne Namen: Wer fehlt, waere eine Aufforderung, jemanden
anzutreiben.

IHRE ZWEITE BEOBACHTUNG, EBENFALLS GEMESSEN

„bei jedem Punkt läuft obwohl auch wenn die Aufgaben darüber gar
nicht zugetragen wurde." Stimmt: Die vier Bewertungsknoepfe („✓
Läuft ↗ Wächst ! Da hakt es – Kann ich nicht sagen") stehen an allen
68 Punkten, auch an den nicht zugetragenen. Beim Durchscrollen liest
man deshalb ueberall „Läuft".

DIE 68 BLEIBEN. Filipe am 25.09.2026 ausdruecklich: „dogfather und
die rechte hand sollen immer noch die 68 sachen sehen wie vorher …
unsere sicht soll sich nicht aendern." Also kein Ausblenden und
keine neue Vorgabe, sondern ein Knopf mehr in der Leiste, die es
schon gibt: „Zugetragen · 2". „Alle" bleibt, was beim Aufmachen
gilt. Gemessen grenzt er auf 2 Punkte ein, Kopf „2 / 2".

NEBENBEI ZUSAMMENGEFUEHRT
* `beurteilerZahl()` an einer Stelle -- beide Enden stellen dieselbe
  Frage, zwei Abschriften saegten irgendwann Verschiedenes ueber
  denselben Punkt.
* `antwortReihe()` und `punkteGefiltert()` ebenso.
* Die Abfrage stand zuerst je Punkt in der Schleife: 68
  Datenbankfragen fuer eine Zahl, die sich nicht aendert.

GEPRUEFT
pruef-entwicklung 79/0 (9 neue, mit Gegenproben in beide Richtungen:
ein nicht zugetragener Punkt traegt die Marke nicht, und sobald der
zweite Beurteiler setzt, geht es durch). mess-vanvan-karte ohne
ACHTUNG. pruef-werdegang 103/0, pruef-entwicklung-kacheln 34/0,
pruef-deutsche-texte, pruef-css-klassen, pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 16:48:24 +02:00
DogFatherGitandClaude Opus 5 a3ec558127 Reaction: der Gleichlauf rechnet ohne Uhrenvergleich
Filipe: „die videos muessen perfekt laufen wenn ich die reaktions
mache."

GEMESSEN, NICHT VERMUTET. mess-reaktion hat einen neuen Abschnitt:
Er liest den Spielstand am ECHTEN <video>-Element in beiden Browsern,
rechnet beide auf denselben Augenblick um und sagt, wie weit Host und
Zuschauerin auseinanderliegen. Die vorhandene Pruefung fragte nur, ob
Start und Stopp ANKOMMEN -- zwei Videos koennen beide laufen und
trotzdem zwanzig Sekunden auseinander sein.

                        vorher      nachher
  Im Lauf                0,12 s     -0,04 s
  Wer dazukommt          6,91 s      0,26 s
  Uhr des Geraets 30 s vor  -29,97 s      0,26 s

DER SCHLIMMSTE FUND: DIE UHR DES TELEFONS

Der Zuschauer rechnete `Date.now() - stand.gesendet`. `gesendet` kam
vom SERVER, `Date.now()` vom Geraet -- zwei Uhren, die nie jemand
verglichen hat. Wessen Telefon dreissig Sekunden vorgeht, schaute
29,97 s weiter als alle anderen. Kein langsames Netz, kein schwaches
Handy. Oertlich faellt das nie auf: Auf einem Rechner sind beide
Uhren dieselbe.

Jetzt geht ein ALTER hinaus statt einer Uhrzeit -- die Differenz
zweier SERVERzeiten. Der Empfaenger braucht dafuer gar keine Uhr,
nur eine Stoppuhr (`performance.now()`), und die haelt Zeitumstellung
und Aufwachen aus dem Ruhezustand aus.

WEITER

* WER DAZUKOMMT, STEIGT RICHTIG EIN. Der gespeicherte Stand war so
  alt wie der letzte Takt des Hosts; neue Spalte `sekunde_am` sagt,
  wann er galt.
* DAS TEMPO GEHT IN DIE HOCHRECHNUNG EIN. Bei 2x laeuft die Videouhr
  doppelt so schnell -- das fehlte ganz.
* JEDER RECHNET ALLE ZWEI SEKUNDEN SELBST NACH statt nur auf Zuruf.
  Wer stockte, hing bis zu fuenf Sekunden hinterher.
* DER VORLAUF NACH EINEM SPRUNG WIRD GEREGELT, nicht geschaetzt --
  gemessen wird der Rest, nicht die Ladezeit (siehe unten).
* PUFFERN IST AUCH IN DER TRANSPORTLEISTE KEINE PAUSE. Der
  Start-Stopp-Knopf rief beim Puffern `playVideo()` -- wer Pause
  drueckte, bekam „weiter". Lampe und Knopfbild flackerten bei jedem
  Stocken. Die Lektion vom 29.09. war nur an einer Stelle eingeloest.
* DAS TEMPO MELDET UEBER `hostMelden()` statt ein zweites Mal von
  Hand -- es hielt Puffern fuer Stillstand und hielt damit beim
  Umschalten alle an.
* EIN VIDEO-RUNDRUF STATT DREI ABSCHRIFTEN. Beim Einbau hatte ich
  zuerst nur eine nachgezogen; das Tauschen der beiden Videos haette
  still die alte Rechnung weiterverschickt.

DREI EIGENE FEHLER UNTERWEGS, alle im Text festgehalten

1. ZWEI FUNKTIONEN HIESSEN `springen`. Meine „spring AUF die Stelle"
   wurde von der vorhandenen „spring UM so viele Sekunden"
   ueberschrieben -- lautlos, rueckwirkend fuer die ganze Datei. Ein
   Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird, waere
   bei 201 gelandet. DREI BROWSERLAEUFE HABEN ES NICHT GEFUNDEN:
   oertlich blieb der Abstand unter der Sprungschwelle, die Zeile
   lief kein einziges Mal. Gefunden hat es erst eine Frage an die
   ganze Datei -- die steht jetzt als Pruefung drin und wurde
   nachgestellt (rot).
2. DER VORLAUF LERNTE NUR NACH OBEN. Gemessen wurde die Ladezeit
   nach einem Sprung; ein Sprung in geladenes Material dauert aber
   gar nicht, meldet nichts und lehrt nichts. Ergebnis: -0,95 s
   stabil. Jetzt wird das ERGEBNIS geregelt statt der Ursache.
3. DER AUSREISSER VON 39,84 s WAR MEINE EIGENE MESSUNG -- der Block
   davor schickt eine erfundene Position. Jetzt wird abgeklungen und
   in Messreihenfolge ausgegeben.

GEPRUEFT
pruef-reaktion 421/0 (14 neue, darunter der Riegel gegen die
Rueckkehr der Uhrenrechnung und die Wache gegen doppelte
Funktionsnamen -- beide nachgestellt), mess-reaktion ohne ein
einziges ACHTUNG, pruef-buehne 38/0, pruef-struktur, pruef-
zwischenspeicher 34/0.

Schemaaenderung: ADD COLUMN sekunde_am. Datenbank vorher gesichert
und geprueft (integrity_check ok, 20 Personen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 15:45:48 +02:00
DogFatherGitandClaude Opus 5 d5c84c7608 Abzeichen am App-Symbol - und die Zahl gehoert zu einem Haus
Die kleine Zahl auf dem Symbol des Startbildschirms. Sie fehlte ganz;
setAppBadge kam im Haus kein einziges Mal vor.

ZWEI ENTSCHEIDUNGEN

Sie zaehlt ungelesene Nachrichten und nichts sonst. Ein Abzeichen
muss weggehen koennen: Nachrichten verschwinden, sobald man sie
liest; eine ueberfaellige Aufgabe verschwindet nicht dadurch, dass
man die App oeffnet. Eine Zahl, die dauerhaft dasteht, ist nach drei
Tagen kein Hinweis mehr, sondern ein Fleck.

Und sie hat EINE Quelle. Im Browser haengt sie an chatZahlZeigen() -
der einen Stelle, an der die Zahl ohnehin gesetzt wird (beim Laden,
aus dem Ereignisstrom, beim Lesen). Fuer die geschlossene App - wo
das Abzeichen ueberhaupt erst etwas wert ist - reist dieselbe Zahl in
der Benachrichtigung mit. Der Service Worker setzt sie nur, wenn eine
dabei ist: Eine Aufgabenerinnerung mit "0" haette dem Chat sein
Abzeichen weggenommen.

DER FUND NEBENBEI

Beim Herausloesen der Abfrage fiel auf, dass sie nie nach dem Haus
gefragt hat - die Raumliste zwanzig Zeilen darueber tut es laengst.
Gemessen: Eine Managerin schreibt DogFather an der Agenturwand an,
sein Zaehler auf crew. springt von 0 auf 1. Seit dem 24.09. soll das
nicht mehr sein. Aufgefallen ist es erst jetzt, weil dieselbe Zahl ab
heute auf dem Startbildschirm steht - und eine Zahl, die etwas
Falsches zeigt, ist schlimmer als keine.

Behoben ueber hausWo(); die Meldung nimmt das Haus des Raums, aus dem
sie stammt.

GEPRUEFT

server/pruef-abzeichen.mjs, 19 Messungen am echten Weg: ein
nachgebauter Browser macht die Benachrichtigung mit seinem privaten
Schluessel auf und liest die Zahl heraus. Mit Gegenproben - ohne Zahl
kommt keine mit, NaN rutscht nicht durch, eine echte Sieben schon.
Und Abschnitt 7 wird rot, sobald man die Hausregel wieder herausnimmt
(nachgestellt).

Zwei eigene Messfehler unterwegs, beide im Text festgehalten: eine
Suche, die im Kommentar landete statt im Code (jetzt ueber
jsOhneKommentar), und Aufrufe ohne Host-Feld - ueber 127.0.0.1 gibt
es kein Haus, die Pruefung mass also eine Regel an einer Verbindung,
die sie gar nicht kennt.

helfer-push-aufmachen.mjs: das Entschluesseln stand als lokale
Funktion in pruef-push-weg; zwei Abschriften waeren die, die
auseinanderlaufen.

Nachbarlaeufe gruen: push, push-ziel, push-weg (17 unveraendert),
arten, portnummern, struktur, chat, chat-kanaele, chatkachel, anruf,
haus-trennung, zwischenspeicher.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 13:39:55 +02:00
DogFatherGitandClaude Opus 5 2c12b8706e Highlights des Teams sind sofort zu sehen -- ohne zweiten Klick
Filipe, 30.09.2026: „wenn wir bei highlights videos rein setzen will
ich nicht mehr dass wir sie freigeben muessen, sobald die reingesetzt
wurden sollen die sofort zu sehen sein."

=====================================================================
WAS ICH NICHT GETAN HABE, UND WARUM NICHT
=====================================================================
Die naheliegende Loesung waere gewesen, `highlight` aus
`TREFF_FREIGABE_BRETTER` zu streichen. Das waere falsch: Auf diesem
Brett laedt auch die COMMUNITY hoch -- Clips, Bilder, Fanart. Im
Quelltext steht woertlich daneben:

    „Zwei Schloesser, weil hier fremde Inhalte hochgeladen werden --
     Urheberrecht und Anstand sind nichts, was man nachtraeglich
     klaert."

Filipe meint nicht das. Er meint: „wenn WIR videos rein setzen".

=====================================================================
DIE UNTERSCHEIDUNG STAND SCHON IM HAUS -- nur nicht im Code
=====================================================================
Derselbe Quelltext sagt ueber die zwei Freigabe-Bretter
Verschiedenes:

    ansteht    -> der SCHALTER „Im Treff zeigen". Das Team entscheidet
                  JE TERMIN, ob die Community ihn sieht.
    highlight  -> der Urheberrechts- und Anstandsfilter.

Ein Filter fragt „hat das jemand angesehen?". Wenn der, der ihn
bedienen darf, den Eintrag SELBST anlegt, ist die Antwort ja. Genau
diese Begruendung steht seit dem Uebernehmen aus dem Katalog im
Haus: „Eine zweite daneben waere keine Sicherheit, sondern ein
Klick."

Ein Schalter dagegen ist eine Entscheidung je Fall. Termine bleiben
deshalb unberuehrt -- sonst stuende jeder interne Termin sofort im
Treff, und danach hat niemand gefragt.

Neu: `FREIGABE_IST_FILTER` und `sofortFreigeben()` in
workspace-treff.js. DIE BEDINGUNG FRAGT DIE ROLLE, NICHT DEN WEG --
waere der Weg gefragt, waere aus dem Filter ein Loch geworden, sobald
jemand einen zweiten Weg baut. Ein Community-Mitglied ab „Stamm"
darf weiterhin einstellen; sein Eintrag wartet auf das Team.

Gerufen an ZWEI Stellen: beim Videoweg und beim Anlegen von Hand.
Beide hatten es bisher nicht.

=====================================================================
DIE VORHANDENE FREIGABEPRUEFUNG BEWEIST DAS NICHT
=====================================================================
Sie blieb nach der Aenderung gruen -- und das zu Recht: Sie legt ihre
Eintraege unmittelbar in der Datenbank an und prueft damit den
Mechanismus, nicht den Weg. Haette ich mich darauf verlassen, waere
eine Aenderung ausgeliefert worden, fuer die keine Zeile spricht.

pruef-treff (+5): DogFather legt ueber den echten Weg an -> die
Community sieht es SOFORT. Ein TERMIN bleibt verborgen. Die Regel
gibt fuer eine Rolle von aussen NICHT frei und fuer Termine
ueberhaupt nicht.

pruef-video (+3): Der Videoweg hat seinen eigenen Aufruf -- genau
dort wird einer vergessen. Geprueft wird die Freigabezeile selbst,
samt Gegenprobe „freigegeben ist nur, was auch angelegt wurde".

Meinen Abschnitt hatte ich erst HINTER das Abschalten des
nachgebauten TikTok-Dienstes gehaengt -- der Kopf der Datei warnt
woertlich davor („das Abschalten steht ganz hinten"). Gelesen habe
ich ihn, als es rot wurde.

UND `pruef-struktur` HAT MEINE EIGENE NEUE ZEILE GEFANGEN: Sie bildete
das Datum aus UTC statt Ortszeit -- zwischen Mitternacht und zwei Uhr
waere es der falsche Tag gewesen. Behoben mit `tagLokal()`, Minuten
nach dem Schreiben.

Gemessen: pruef-treff 85/0 (war 80), pruef-video 74/0 (war 71),
pruef-highlights 31/0, pruef-bereiche-lesend 37/0,
pruef-treff-werkzeuge 73/0, pruef-alle-sehen-es 43/0,
pruef-community-sicht 10/0, pruef-wege-nach-draussen 67/0,
pruef-struktur 44/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 12:55:12 +02:00
DogFatherGitandClaude Opus 5 f53791cefd Dritte Schicht: auch die Webdesign-Seiten trugen Stempel vom August
Nach den 35 oeffentlichen Seiten und den zwei Zahlen des Service
Workers lag dieselbe Faeulnis noch eine Ebene tiefer:

    webdesign/*.html   49x ?v=20260823wd20   (23. August)
                        1x ?v=20260825wd51   (25. August)

Zwei VERSCHIEDENE Stempel in 13 Seiten, und beide aus dem August. Sie
verweisen auf dieselben Dateien wie die Startseite -- darunter
`main.css` --, und der Server schickt dazu ein Jahr `immutable`. Wer
den Bereich seit August besucht hatte, hatte sie eingefroren, ganz
unabhaengig vom Service Worker.

Dritte Schicht desselben Fehlers an einem Vormittag. Alle drei hatten
dieselbe Ursache: eine Zahl, die ein Mensch pflegen sollte.

Der Stempler nimmt die 13 Seiten jetzt mit -- 554 Verweise in 48
Seiten, EIN Stempel. `workspace/` bleibt ausgenommen: Dort arbeitet
`workspace-stempel.mjs`, und zwei Werkzeuge auf demselben Ordner
waeren zwei Antworten auf dieselbe Frage.

Und die Wache liest sie mit. Haette sie nur die Wurzel gelesen, waere
sie gruen gewesen und haette die Haelfte geprueft -- genau die Sorte
gruener Haken, die nichts bedeutet.

Viermal heute ist mir beim Schreiben ein Backslash durch die Shell
verlorengegangen (`\1` wurde zum Steuerzeichen, `\\` zu nichts).
Die Hausnotiz sagt das seit Langem; ich habe es viermal trotzdem
gemacht. Ab jetzt: alles mit Backslash geht durch das Werkzeug, nicht
durch die Befehlszeile.

Gemessen: pruef-zwischenspeicher 34/0, pruef-bewegung 9/0,
pruef-css-klassen 37/0, pruef-struktur 44/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 12:14:23 +02:00
DogFatherGitandClaude Opus 5 12fa0e45ba Der Webdesign-Bereich lieferte seit dem 27.08. ein veraltetes main.css aus
Der Stempel-Fund von eben hatte eine Fortsetzung: DEPLOY.md verlangte
seit dem 26.08. „ZWEI Zahlen hochzaehlen", mit Begruendung und
Messwerten daneben.

    webdesign/sw.js       const CACHE_NAME = "dogfather-webdesign-v64"
    assets/js/wd-core.js  .register("/webdesign/sw.js?v=64", …)

Gemessen am 30.09.2026 standen beide seit dem 27.08. auf v64 --
waehrend SIEBEN Commits die Dateien geaendert hatten, die der Service
Worker vorhaelt. Er haelt sechs vor, und `/assets/css/main.css` ist
eine davon.

Wer den Webdesign-Bereich einmal geoeffnet hatte, bekam sie seither
aus seinem Zwischenspeicher. Auch die Behebung von heute Vormittag
waere dort nicht angekommen.

EIN KOMMENTAR, DER VOR EINEM FEHLER WARNT, VERHINDERT IHN NICHT. Die
Anleitung war richtig, ausfuehrlich und begruendet. Getan hat es
trotzdem niemand -- fuenf Wochen lang. Das ist dieselbe Lehre wie am
11.09., als ein Warnhinweis neben einer abgeschriebenen Spaltenliste
stand und drei Spalten mit Inhalt trotzdem verlorengingen.

DESHALB MACHT ES JETZT DAS WERKZEUG. `tools/seiten-stempel.mjs`
setzt beide Zahlen auf denselben Stempel wie die Seiten. Passt eines
der zwei Muster nicht mehr, bricht es ab, statt stillschweigend
weiterzulaufen -- sonst waere die Zahl ab da wieder von Hand
gepflegt, und das merkt niemand.

Dass der Vorrat bei jedem Stempeln neu aufgebaut wird, ist Absicht:
sechs kleine Dateien kosten nichts, ein unbemerkt alter Stand fuenf
Wochen.

UND EINE WACHE DAZU. `pruef-zwischenspeicher` prueft jetzt:
  · beide Zahlen stehen da
  · sie sind GLEICH -- sonst wird der Vorrat geleert, aber der
    Service Worker gar nicht erst neu geladen (Cloudflare ersetzt
    sein `no-cache` durch vier Stunden)
  · die Zahl ist nicht aelter als die vorgehaltenen Dateien

Die Liste der vorgehaltenen Dateien wird AUS DEM SERVICE WORKER
gelesen, nicht abgeschrieben -- eine zweite hier waere die, die beim
naechsten Eintrag auseinanderlaeuft.

Gegenprobe gemacht: die zwei Zahlen um eine Minute auseinander ->
rot, zurueck -> gruen.

DEPLOY.md sagt jetzt, dass es automatisch geht, und nennt den Befund
im Wortlaut daneben.

Gemessen: pruef-zwischenspeicher 34/0 (war 30/0), pruef-bewegung 9/0,
pruef-css-klassen 37/0, pruef-struktur 44/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 12:12:32 +02:00
DogFatherGitandClaude Opus 5 d087d02a63 Seit fuenf Wochen kam keine Aenderung der Website bei einem wiederkehrenden Besucher an
Gefunden beim Nachgehen des letzten roten Pruefstand-Eintrags. Eine
Kette aus drei Funden, und der dritte wiegt am schwersten.

=====================================================================
1. EINE ENDLOSE BEWEGUNG AUF ETWAS UNSICHTBAREM
=====================================================================
`pruef-browser` scheiterte in WebKit daran, dass der Anmeldeknopf nie
ruhig wurde. Eine der zwei laufenden Bewegungen war:

    .knopf__laden { opacity: 0; animation: dreh .8s linear infinite; }

Dieselbe Suche, ueber ALLE 42 Stilvorlagen beider Haeuser und der
Website, fand einen zweiten: An jedem Navigationspunkt der
oeffentlichen Seite lief eine elf Sekunden lange Aurora -- unsichtbar
bis zum Ueberfahren, auf jeder Seite, fuer immer.

Beides faellt niemandem auf: Nichts stuerzt ab, nichts sieht falsch
aus. Es kostet nur Rechenzeit und Akku.

NEU: `server/pruef-bewegung.mjs` mit sechs Gegenproben -- darunter
die wichtigste, dass ein Beispiel IM KOMMENTAR nicht zaehlt (genau
diese Falle hat am 29.09. eine andere Pruefung dreimal getroffen).
Der Gesamtlauf findet sie von selbst, sie laeuft ab heute Nacht mit.

=====================================================================
2. DIE WEBSITE HATTE KEINEN STEMPLER
=====================================================================
Fuer den Workspace gibt es `workspace-stempel.mjs` seit Langem. Die
oeffentliche Website hatte nichts -- dort stand ein VON HAND
gepflegter Stempel, und der ist gealtert wie jede von Hand gepflegte
Liste.

NEU: `tools/seiten-stempel.mjs`, 447 Verweise in 35 Seiten. Er kennt
beide Schreibweisen (mit und ohne fuehrenden Schraegstrich) UND die,
die in einem `style="…url(…)"` stehen -- zwei Hintergrundbilder auf
streamplan.html waeren sonst ein Jahr lang die alten geblieben.
Dieselbe Luecke gab es im Workspace-Stempler schon einmal, dort bei
den App-Symbolen.

=====================================================================
3. UND DESHALB KAM SEIT DEM 27.08. NICHTS MEHR AN
=====================================================================
    Stempel in allen 35 Seiten:     ?v=20260828e  (zuletzt 27.08.)
    Aenderungen an assets/ seither: 6 Commits
    Der Server dazu:  Cache-Control: max-age=31536000, immutable

`immutable` heisst: Der Browser fragt nicht einmal nach. Wer die
Seite einmal geladen hatte, behielt Stilvorlagen und Skripte bis zu
EINEM JAHR.

Darunter der Partnercode DOGI10 auf der gepraegten Muenze und der
komplette Sprachumbau der Oberflaeche. Sie lagen auf dem Server, sie
waren ausgeliefert, und niemand sah sie.

Dieselbe Sorte Fehler wie am 09.09. im Workspace („eine Aenderung ist
nicht gemacht" -- sie war es, nur unsichtbar) und dieselbe wie bei
VanVans Shop: Eine Aenderung ist erst fertig, wenn sie auf der
Adresse ankommt, die der Nutzer benutzt.

`pruef-zwischenspeicher` fragt jetzt nicht mehr nur, OB ein Stempel
dasteht, sondern ob er NEUER ist als die Dateien, auf die er zeigt.
Gegenprobe gemacht: eine Datei zwei Stunden in die Zukunft gesetzt ->
rot, Zeit zurueck -> gruen. Eine Stunde Nachsicht, damit die Wache
nicht bei zwei Minuten anschlaegt und weggeklickt wird.

DEPLOY.md hat jetzt einen Schritt 0 mit beiden Stemplern und dem
Grund dafuer -- der Befund steht im Wortlaut daneben.

Gemessen: pruef-bewegung 9/0, pruef-zwischenspeicher 30/0,
pruef-browser 17/0, pruef-css-klassen 37/0, pruef-struktur 44/0,
pruef-workspace-umzug 3/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 11:38:38 +02:00
DogFatherGitandClaude Opus 5 8e4e525861 Auf der Zugangswand drehte sich ein unsichtbarer Kreisel -- fuer immer
`pruef-browser` stand seit dem 28.09. rot, und zwar NUR in WebKit
(Safari): „element is not stable" beim Klick auf den Anmeldeknopf, 53
Versuche ueber 30 Sekunden. Chromium und Firefox waren gruen.

Nachgemessen wanderte sein Kasten pro Bild um ein Fuenftel Pixel:

    746.12,575.12  ->  745.10,575.60   (sechs Bilder)

Zwei Bewegungen liefen dabei -- `.buehne__bild` und `.knopf__laden`.
Beide sind echte Befunde.

=====================================================================
1. DER KREISEL DREHT SICH FUER IMMER, UNSICHTBAR
=====================================================================
    .knopf__laden { opacity: 0; animation: dreh .8s linear infinite; }
    .knopf[disabled] .knopf__laden { opacity: 1; }

Nur die Deckkraft schaltete um. Die Drehung lief ab dem Laden der
Seite, auf JEDEM Knopf, fuer immer -- nicht zu sehen und trotzdem
jedes Bild neu gerechnet. Auf einer Seite, auf der man einen Code
eintippt, ist das reine Verschwendung.

Und sie kannte die Hausregel nicht: Wer weniger Bewegung eingestellt
hat, bekam sie trotzdem. Jetzt dreht sie nur, wenn der Knopf
wirklich arbeitet -- und bei `prefers-reduced-motion` steht der Ring
still, statt zu verschwinden: Ein unsichtbarer Kreisel sagt gar
nichts, ein stehender sagt „ich arbeite noch".

=====================================================================
2. DIE KARTE BEWEGTE SICH, WENN MAN NACH IHR GRIFF
=====================================================================
Die Parallaxe folgte dem Zeiger AUCH ueber der Anmeldekarte. Wer die
Maus zum Codefeld fuehrt, bewegte damit das Feld. Bei zehn Pixeln
keine Katastrophe -- aber genau verkehrt herum: Was man bedienen
will, soll stillstehen.

Und es war der Grund fuer den Wettlauf: Der Zeiger bewegte das Ziel,
das er treffen wollte. Ein Wettlauf zwischen Maus und Karte ist auch
fuer einen Menschen keiner, den er gewinnen soll.

Ueber der Karte wird das Ziel nicht mehr nachgefuehrt. Die
Annaeherung laeuft aus und haelt an; verlaesst man die Karte, geht es
weiter. Das Bild lebt, die Bedienung steht.

=====================================================================
WARUM ES NUR WEBKIT GEZEIGT HAT
=====================================================================
Chromium liefert fuer eine zusammengesetzte Verwandlung oft den
Layout-Kasten OHNE die laufende Bewegung -- dort sah der Knopf ruhig
aus, obwohl er es nicht war. WebKit sagt die Wahrheit. Eine Maschine,
die frueher „gruen" meldet, hat nicht recht; sie sieht nur weniger.

Nachher: pruef-browser 17/0 (war 14/1), alle vier Maschinen.
Dazu gruen: crew-adresse 169/0, crew-wand-bild 45/0, kachelfarben
31/0, css-klassen 37/0, struktur 44/0, tippziele 13/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 11:27:57 +02:00
DogFatherGitandClaude Opus 5 69517e090b Wer im Chat hochscrollt, wird nicht mehr ans Ende gerissen
`pruef-chat-ausbau` stand seit dem 28.09. rot:

    FEHL die Stelle bleibt stehen (0 -> 3574)
    FEHL der Knopf sagt, wie viel wartet ("Zum neuesten")
    FEHL er zaehlt mit ("Zum neuesten")
    FEHL pruef-chat-ausbau: unbehandelter Fehler   (der Knopf blieb verborgen)

WAS WIRKLICH PASSIERTE: Man liest weiter oben im Verlauf, jemand
schreibt -- und man wird ans Ende gerissen. Der Zaehler „1 neue
Nachricht" wurde dabei zurueckgesetzt, der Knopf blieb verborgen.

DIE URSACHE STAND AN DER FALSCHEN STELLE. Die Zuhoerer, die „der
Mensch hat selbst gescrollt" feststellen (Rad, Finger, Taste,
Zeiger), hingen INNERHALB des Sprung-Blocks zur Neu-Linie -- wurden
also erst angehaengt, NACHDEM einmal gesprungen worden war.

In einem ruhigen Gespraech passiert das nie: Wer alles gelesen hat,
bekommt keine Neu-Linie, der Block laeuft nicht, die Zuhoerer gibt es
nicht. Scrollt er hoch und es kommt eine Nachricht, entsteht die
Linie ZUM ERSTEN MAL -- der Block laeuft, haengt die Zuhoerer an und
springt im selben Atemzug. Das Hochscrollen von vorhin hat nie jemand
bemerkt.

Das ist der haeufigste Fall ueberhaupt: ein stiller Chat, man liest
etwas weiter oben nach, jemand schreibt.

Die Wache steht jetzt beim Aufbau, bei den uebrigen Zuhoerern des
Verlaufs. Ausdruecklich NICHT am `scroll`-Ereignis: Der Sprung
scrollt selbst, und `scroll` unterscheidet nicht, wer gescrollt hat.
Rad, Finger, Taste und Zeiger tut nur ein Mensch.

UND DIE PRUEFUNG HAT SELBST GEMOGELT. Sie schrieb `e.scrollTop = 0`
-- das setzt die Bildlaufposition zu, ohne dass ein Rad gedreht
wurde. Damit haette sie den Fehler auch nach der Behebung noch
gemeldet. Sie dreht jetzt das Rad (`mouse.wheel`) und prueft, dass
sie oben angekommen ist. Dieselbe Lehre wie bei den Fingergeraeten:
`setViewportSize` macht aus einer Maus keinen Finger, und
`scrollTop = 0` macht aus einem Programm keinen Leser.

Nachher: 70 Pruefungen, 0 Fehler (vorher 24/4).
    die Stelle bleibt stehen (0 -> 0)
    der Knopf sagt, wie viel wartet ("1 neue Nachricht")
    er zaehlt mit ("2 neue Nachrichten")
    wer unten steht, wird weiterhin mitgenommen -- ohne Knopf

Alle sieben Chatpruefungen gruen: anhaenge 123/0, aufloesen 126/0,
ausbau 70/0, kanaele 81/0, neu-stelle 63/0, neu 36/0, optik 62/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 11:22:14 +02:00
DogFatherGitandClaude Opus 5 75ede0a8e9 Die Rueckfragen sagten nicht, was passiert -- und eine lief gar nicht ueber den Hausdialog
`pruef-nachfrage` und `pruef-call-kategorien` standen seit dem 28.09.
rot und waren als „nicht angesehen" vermerkt. Nachgemessen sind es
drei echte Befunde und eine Zeitbombe.

=====================================================================
1. FALSCHE FELDNAMEN -- die Erklaerung erschien NIE
=====================================================================
Der Hausdialog kennt `was:` und `ja:`. An vier Stellen in
`reaktion.js` stand `text:` und `knopf:`. Beides wird stillschweigend
ignoriert.

Wer „Sendung beenden?" las, bekam den Titel und zwei Knoepfe -- genau
die Frage, die `confirm()` stellt und derentwegen `nachfrage.js`
ueberhaupt gebaut wurde. Es stuerzt nichts ab, es fehlt nur; so etwas
findet keine Syntaxpruefung und kein Blick auf den Bildschirm, wenn
man den Satz nicht vermisst.

Alle vier tragen jetzt `was` UND `bleibt`. Beim Beenden steht dabei
das, was seit heute frueh dazugekommen ist: dass Titel, Video,
Vorschaubild, Beginn und das zweite Video geleert werden -- wer das
nicht weiss, traegt danach alles noch einmal ein und haelt es fuer
einen Fehler.

=====================================================================
2. `window.frageNachText` GIBT ES NICHT
=====================================================================
Nur EINE Zeile im ganzen Haus nennt es; gesetzt hat es niemand. Die
Abfrage „Wie viele Dogen waren es?" lief damit IMMER ueber den
nackten `prompt()` -- ausgerechnet die, die am haeufigsten vorkommt.

Der Hausdialog kann das laengst besser: `zahl:` ist ein Zahlenfeld
mit Obergrenze und eigener Fehlerzeile.

=====================================================================
3. DER DOPPELTE NOTNAGEL WAR TOTER CODE
=====================================================================
`window.frageNach ? await frageNach(...) : confirm(...)` -- der
Notnagel fuer Browser ohne <dialog> steckt schon IN `nachfrage.js`,
und zwar mit DEMSELBEN Text. Meiner daneben zeigte im Ernstfall
WENIGER und im Normalfall nie etwas.

Im Notizblock stand sogar der nackte `window.confirm()`, ganz ohne
Hausdialog. Er fragt „bist du sicher"; der Hausdialog sagt, was
passiert und was bleibt. Beim endgueltigen Loeschen ist das `bleibt`
der halbe Grund fuer die Rueckfrage: Der Papierkorb ist der Ort, an
dem man etwas wiederfindet.

=====================================================================
4. EINE ZEITBOMBE IN pruef-call-kategorien
=====================================================================
Sie legte eine Wiederholung „in vier Tagen" an -- im Quelltext stand
woertlich „also mit grosser Wahrscheinlichkeit dieser Monat". Heute
ist der 30.; vier Tage spaeter ist Oktober. Der Server verlangt
kuenftig UND im laufenden Monat, und am Monatsende gibt es beides
zusammen nicht.

DIE ANWENDUNG HATTE RECHT. Dieselbe Art wie bei `pruef-kalender` am
29.09. Die nahe Auspraegung wird jetzt GERECHNET (letzter Tag des
Monats, 23:59) statt gehofft -- plus ein benannter dritter Ausgang
fuer die eine Minute im Monat, in der es keinen kuenftigen Termin
mehr in diesem Monat gibt. Die drei anderen Aussagen werden auch dann
geprueft; ein Ausstieg, der gar nichts mehr misst, waere ein stiller
Aussetzer.

Gemessen: pruef-nachfrage 53/0 (war 51/2), pruef-call-kategorien 22/0
(war 21/1), pruef-notizen 79/0, pruef-reaktion 407/0, mess-reaktion
0/0, pruef-css-klassen 37/0, pruef-struktur 44/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 11:10:49 +02:00
DogFatherGitandClaude Opus 5 9125583120 Die geschlossene Reaction-Kachel sah aus wie die Aufgaben-Kachel
`pruef-kachelfarben` stand seit dem 28.09. rot und war als „nicht
angesehen" vermerkt. Nachgemessen ist sie ein echter Befund:

    engstes Paar 0.0191 (Aufgaben <-> Reaction), Grenze 0,02

Beide sind entsaettigtes Blaugrau -- die Aufgaben tragen Silber, die
Reaction im Zustand „zu" ein blaues Grau. Genau darueber ging Filipes
Klage vom 22.09.: „ich seh immer wieder sehr viele die einfach
komplett die gleiche farbe haben."

GESUCHT, NICHT GERATEN. Was auf dem Schirm ankommt, ist der Ton UNTER
Sternenfeld, Vignette und Wolke -- aus den 32 gewoehnlichen Kacheln
laesst sich die Abbildung schaetzen. Sie traf auf einen Punkt genau:

    #8b93a4 -> geschaetzt rgb(64,67,84), gemessen rgb(65,68,83)

Damit vorgesiebt, danach wirklich gerendert und nachgemessen -- eine
Schaetzung allein waere eine Rechnung, die plausibel aussieht.

UND NICHT „AM WEITESTEN WEG". Der groesste Abstand ist ein
Rechenergebnis, keine Gestaltung; er fuehrte zu dunklen Lilatoenen.
Gesucht wurde unter denen, die gleich hell bleiben wie bisher UND
warm sind: Die geschlossene Kachel ist die erste Stufe einer Folge
(zu -> gleich -> live, grau -> bernstein -> rot). Ein warmes Grau
fuehrt dorthin, ein blaues steht quer dazu.

    #b8a8a0   Abstand 0,0481 statt 0,0191, Kontrast 8,7:1

Nachher am echten Bildschirm: 31 Pruefungen, 0 Fehler, engstes Paar
jetzt 0,0277 (Willkommen <-> Notizen). Die Rohfarbe kommt zu 56,5 %
an (vorher 38,9 %).

NEBENBEI: `pruef-zentrale-ring` war ebenfalls als rot vermerkt und ist
inzwischen gruen (14/0) -- der Eintrag im Pruefstand war veraltet.
Eine Bestandsliste altert, auch die eigene.

Gemessen: pruef-kachelfarben 31/0, pruef-zentrale-ring 14/0,
pruef-crew-wand-bild 45/0, pruef-css-klassen 37/0, pruef-struktur
44/0, pruef-haus-seiten 38/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 10:52:46 +02:00
DogFatherGitandClaude Opus 5 0031091bdd Die Eckenwahl zeigt, wo die Kameras stehen -- der letzte offene Punkt der Werbung
Offener Punkt vom 29.09.2026:

  „Die Eckenbilder koennen auf den Kamerakacheln landen. 'Unten
   rechts' liegt im Layout 'Kino' genau dort, wo die Kameras stehen.
   ... aber die Regie warnt dich (noch) nicht vorher."

GEZEIGT, NICHT VERBOTEN. Ein Platz, den die Regie ablehnt, waere
falsch: Vielleicht soll das Logo genau dorthin, weil die Kameras
gleich umziehen oder weil auf „nur Video" geschaltet wird. Wer
entscheidet, braucht die Auskunft -- nicht die Entmuendigung.
Dasselbe Verhaeltnis wie beim Tempo-Knopf, der grau wird statt zu
verschwinden.

NUR IM LAYOUT „KINO". `kamera_ecke` steuert den Stapel nur dort; bei
„gleich" und „Kamera gross" stehen die Kameras woanders, bei „nur
Video" gar nicht. Ein Hinweis, der auch dann erschiene, warnte vor
etwas, das nicht passieren kann -- und so etwas gewoehnt man sich ab.

AM KNOPF UND IM VORLESETEXT. Ein gestrichelter Rahmen in Bernstein
(40 %, gedaempft -- was gewaehlt ist, muss lauter sein als was zu
bedenken ist) UND der Satz „hier stehen gerade die Kameras" in
`title` und `aria-label`. Eine Markierung, die nur eine Farbe ist,
gibt es fuer den nicht, der Farben schlecht unterscheidet oder die
Seite vorlesen laesst.

Gemessen in mess-reaktion, mit Gegenprobe -- eine Markierung, die
immer da ist, ist keine:
    Eckenwahl im Kino (Kameras unten rechts): ur:benannt
    Gegenprobe bei „nur Video": 0 Hinweis(e)
„benannt" prueft ausdruecklich, dass der Satz dransteht und nicht nur
die Farbe.

Gemessen: mess-reaktion 0/0, pruef-reaktion 407/0, pruef-css-klassen
37/0, pruef-struktur 44/0, pruef-tippziele 13/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 10:43:42 +02:00
DogFatherGitandClaude Opus 5 6a42ce3432 Der Vorhang geht von selbst wieder auf -- ein offener Punkt weniger
Offener Punkt vom 29.09.2026, woertlich aus der Projektnotiz:

  „Nimmst du jemanden aus der Sendung und machst es gleich wieder
   rueckgaengig, steht bei ihm weiter 'Du bist aus dieser Sendung'.
   ... Das sauber zu loesen hiesse, ihm waehrend der Sperre eine
   langsame Nachfrage zu lassen -- das ist ein eigener Umbau."

Der Umbau ist seit gestern da: Der geschlossene Saal fragt alle 15
Sekunden nach. Dasselbe Muster passt hier -- nur die FRAGE muss eine
andere sein.

NUR DIE EINE FRAGE, NICHT DER GANZE STAND. `rausgenommen()` schliesst
den Ereignisstrom mit Absicht: Wer draussen ist, soll die Sendung
nicht weiterverfolgen. `/api/reaktion` braechte Video, Titel und
Stand mit -- genau das, was ihr genommen werden soll. `POST /dabei`
beantwortet dagegen nur „bin ich wieder dabei?" und verraet nichts:

    403 aus_der_sendung / treff_massnahme  -> weiter warten
    409 saal_zu                            -> weiter warten
    200                                    -> neu laden

NEU GELADEN WIRD NUR BEI 200, und das ist der Unterschied zwischen
einer Loesung und einer Schleife. Schliesst der Saal, waehrend jemand
draussen ist, kaeme 409; wuerde darauf neu geladen, stuende die
Massnahme danach immer noch, der Vorhang kaeme wieder, und die Seite
laedt sich im Kreis.

ZWANZIG SEKUNDEN. Eine Massnahme ist kein Anruf -- wer
zurueckgeholt wird, ist eine halbe Minute spaeter wieder da.

UND WARUM NEU LADEN STATT WEITERZEICHNEN: `rausgenommen()` hat die
Verbindungen abgebaut, den Strom geschlossen und den Vorhang
angehaengt. Das im Betrieb wieder aufzubauen waere viel Zustand --
und genau das tut ein Neuladen in einem Schritt. Bisher haben wir
den Menschen darum GEBETEN.

DIE PRUEFUNG FEHLTE, und das ist der eigentliche Punkt: Der Mangel
stand seit dem 29.09. in der Notiz und hatte keine. So bleibt ein
bekannter Mangel fuer immer bekannt -- niemand merkt, wenn er behoben
ist, und niemand merkt, wenn er zurueckkommt. Jetzt wartet
mess-reaktion nach der zurueckgenommenen Massnahme darauf, dass der
Vorhang von selbst verschwindet. Gegenprobe gemacht: Nachfrage
abgeschaltet -> „Der Vorhang hebt sich von selbst: NEIN", Rueckgabe 1.

Gemessen: mess-reaktion 0/0, pruef-reaktion 407/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 10:37:05 +02:00
DogFatherGitandClaude Opus 5 0fbd3f45b3 Die Chatkiste steht immer, Beenden raeumt auf -- und eine verspaetete Antwort ueberschreibt nichts Neueres mehr
Filipe, 30.09.2026: „wenn eine sendung beendet wurde soll alles
automatisch verschwinden. also von daten die da stehen von dem video."
Und: „mach dass ich die chat kiste immer sehe bitte und nicht erst
wenn das video läuft."

=====================================================================
1. BEENDET HEISST LEER
=====================================================================
Beim Beenden verschwinden Titel, YouTube-Adresse, Videotitel,
Vorschaubild, Beginn und das zweite Video samt seiner Stelle. Vorher
blieb alles stehen; beim naechsten Aufmachen stand dort der Titel der
letzten Sendung und ein Beginn, der in der Vergangenheit liegt.

ERST DER VERLAUF, DANN DAS LEEREN -- weg vom Schreibtisch heisst
nicht weg aus der Welt. Geprueft: Nach dem Beenden steht die Sendung
weiter in `reaktion_verlauf`, samt Video.

EINE FELDLISTE, NICHT ZWEI. Die Aufzaehlung stand in der
Zuruecksetzen-Route; jetzt brauchen sie zwei Wege. `SENDUNGS_FELDER`
und `PULT_EINSTELLUNGEN` stehen einmal oben. Zwei Abschriften waeren
die, die beim naechsten Spaltenzuwachs auseinanderlaufen -- genau so
sind am 11.09. drei Spalten mit Inhalt verlorengegangen.

Einstellungen (Bildaufteilung, Kameragroesse, Chatmodus) und die
Warteschlange bleiben -- die raeumt weiter nur „Alles zuruecksetzen"
ab. Wer nur vorbereitet und dann zumacht, behaelt seine Eingaben; das
ist eine Entscheidung und steht als Pruefung fest.

DAS FELD „BEGINN" WURDE NIE GELEERT, nur gefuellt:
`if (lage.beginnt_am && document.activeElement !== …)` -- zwei Fragen
in einer Bedingung, und die zweite beantwortete die erste
stillschweigend mit nein. Das betraf auch den vorhandenen
Zuruecksetzen-Knopf.

=====================================================================
2. DIE CHATKISTE STEHT IMMER
=====================================================================
Sie lag in `#teil-live` und ging mit ihm weg. Im Wartebereich stand
dabei woertlich „Der Chat ist schon offen" -- der Satz stimmte sogar,
der Server laesst dort schreiben (201, geprueft); zu sehen war der
Chat trotzdem nicht.

Die drei Haeute und die Schiene liegen jetzt in einem gemeinsamen
`.raum`: links wechseln die Haeute, rechts steht der Chat. Das Raster
wandert mit nach oben statt sich zu verdoppeln -- zwei
`grid-template-columns` fuer dieselbe Frage waren am 28.09. der
Grund, warum die Regieleiste null Pixel bekam.

Wer nicht schreiben darf, liest den Grund im Feld („Der Saal ist zu").

DER UMBAU HAT DREI DINGE VERSCHOBEN, und die Messung hat sie sofort
gefunden: Kino 0 px hoch, Spendenkarte bei x=1458 ausserhalb des
Bildes, „Kamera aus"-Schild kam nicht an. Eine Ursache: Zwei Regeln
(`grid-row: 2` und `grid-area: kino`) zielten auf die drei Haeute,
weil die frueher unmittelbar im Saal lagen. Gefunden hat das nicht
das Nachdenken, sondern eine Messung der ganzen Kette --
`teil-live 0x0 @1440,742` lag NEBEN dem Raum.

=====================================================================
3. „DER SAAL IST ZU" ERREICHTE NIEMANDEN, DER DARIN SASS
=====================================================================
`stand: "zu"` loescht `reaktion_dabei`, und `melden()` suchte seine
Empfaenger erst danach -- in genau dieser geleerten Liste. Die Seiten
der Zuschauer blieben auf „live" stehen.

`melden(art, daten, wer)` nimmt jetzt eine Empfaengerliste; die
Stand-Route bestimmt sie VOR jeder Aenderung.

UND EINE ZUGESPERRTE SEITE VERSTUMMTE FUER IMMER. War der Saal zu,
hielt `anschliessen()` jeden Takt an -- und Rundrufe bekam sie auch
keine, weil sie in keiner Liste mehr stand. Machte der Host wieder
auf, passierte bei allen mit offenem Reiter NICHTS. Nicht in dreissig
Sekunden: nie, bis jemand von Hand neu lud. Gemessen: Server sagt
„vorbereitung", die Seite zeigt „zu", auch nach 40 Sekunden.
Jetzt fragt eine geschlossene Seite alle 15 Sekunden nach -- der
ruhigste Zustand des Hauses, dort kostet das nichts.

=====================================================================
4. WER ZULETZT ANTWORTET, HAT NICHT RECHT
=====================================================================
Eine Abfrage ist Sekundenbruchteile unterwegs. Kommt in dieser Zeit
ein Rundruf an, ist SEIN Stand der neuere -- und trotzdem hat die
verspaetete Antwort ihn ueberschrieben.

Gemessen: Die Regie schaltet die Werbung auf Wechsel, der Rundruf
zeichnet ihn, und einen Augenblick spaeter steht wieder Laufband.
Eine feste Wartezeit in der Messung hatte das zugedeckt; es fiel
erst auf, als ich sie durch eine echte Bedingung ersetzte.

Drei Runden Raten lagen daneben. Gefunden hat es eine Mitschrift der
Aufrufkette:

    wechsel:2  @ EventSource
    laufband:2 @ zeichnen < standHolen < signalEmpfangen

Eine Folgenummer zaehlt jeden angekommenen Rundruf. Jede Abfrage, die
die Seite VON SICH AUS macht -- nach dem Verbindungsaufbau, im
geschlossenen Saal, bei jedem Verbindungssignal, nach einer
abgelehnten Anwesenheitsmeldung, und der Anwesenheitstakt selbst --
verwirft ihr Ergebnis, wenn inzwischen etwas Neueres da war.

Abfragen nach einer EIGENEN Handlung bleiben hart: Sie bringen Dinge
mit, die kein Rundruf traegt (die Verwaltungslisten der Regie). Die
Unterscheidung steht am Aufruf, nicht in einer Bedingung im Inneren.

=====================================================================
5. UND DIE MESSUNGEN WARTEN NICHT MEHR AUF DIE UHR
=====================================================================
Feste Wartezeiten sind dieselbe Falle wie feste Schwellen: Sie
stimmen, bis daneben etwas langsamer wird, und melden dann einen
Fehler, den es nicht gibt. Zweimal ist mir das in einer Stunde
passiert -- einmal beim Band, einmal bei den Ecken. Der Werbeblock
wartet jetzt fuenfmal auf das Ergebnis, der Gleichlauf viermal; laeuft
eine Frist ab, ist es ein echter Befund.

Die erste Fassung der Gleichlauf-Gegenprobe war selbst falsch -- sie
hielt das Selbstheilen des Systems fuer einen Fehler.

Neu in mess-reaktion: „Die Chatkiste in jedem Zustand" (alle drei,
mit Breite und Sperrgrund) und „Beendet heisst leer (im Pult)" -- dort
gemessen, wo Filipe hinsieht, nicht in der Datenbank.
Neu in pruef-reaktion (+14): das Leeren, die Gegenprobe „vorher war
etwas drin", der Verlauf bleibt, und die Entscheidung „wer nur
vorbereitet hat, behaelt seine Eingaben".

Gemessen: mess-reaktion dreimal hintereinander 0/0, pruef-reaktion
407/0, pruef-css-klassen 37/0, pruef-struktur 44/0, pruef-tippziele
13/0, pruef-haus-seiten 38/0, pruef-haus-trennung 100/0, pruef-buehne
38/0, pruef-push-ziel 38/0, mess-buehne 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 02:36:13 +02:00
DogFatherGitandClaude Opus 5 2c3b90d75c Benachrichtigungen: die Reaction laedt ein, jedes Haus bekommt sein Gesicht -- und die Videos haengen nicht mehr
Filipe, 29.09.2026: „ich will dass du die benarichtigungen
perfektionierst. dogfather und alle anderen sollen die
benachrichtigungen perfekt von dieser seite hier bekommen."
Und dazu, mit einer Bildschirmaufnahme: „wieso haengen die videos
immer."

=====================================================================
1. WARUM DIE VIDEOS HAENGEN -- zwei Zeilen, eine Rueckkopplung
=====================================================================

Die Aufnahme zeigt den YouTube-Zaehler bei 0:13 von 31:34, vier
Bilder lang unbewegt, in der Mitte der Ladering.

  a) `hostMelden()` rechnete `laeuft = getPlayerState() === 1`.
     Zustand 3 heisst PUFFERN -- „ich will spielen, mir fehlen
     gerade Daten". Der Host meldete in diesem Moment „laeuft
     nicht", und zwar sofort, weil `onStateChange` bei jedem
     Zustandswechsel meldet.

  b) `empfaenger()` fuegt den Host ausdruecklich hinzu, und
     `folgen(d)` lief im Ereignisstrom fuer alle -- auch fuer ihn.
     Seine eigene Meldung kam zurueck und traf dort auf
         if (!stand.laeuft && getPlayerState() === 1) pauseVideo();

  Der Host puffert eine halbe Sekunde, laeuft weiter -- und wird vom
  Echo seines eigenen Pufferers angehalten. Bei einem 31-Minuten-Video
  passiert das in den ersten Sekunden zuverlaessig.

  Und alle Zuschauer bekamen bei JEDEM Pufferer des Hosts ein
  Pause-Play-Paar. Das ist das Ruckeln, das man fuer die eigene
  Leitung haelt.

GEMESSEN, NICHT HERGELEITET (mess-reaktion):
    vorher   Host=laeuft -> Pufferer gemeldet -> Host=pause
    nachher  Host=laeuft -> Pufferer gemeldet -> Host=laeuft

Drei neue Messungen, jede mit Gegenprobe:
  · ein gemeldeter Pufferer haelt den Host nicht an
  · ein ECHTES Anhalten (Knopf gedrueckt) haelt alle an -- sonst
    waere das Erste mit kaputtem Gleichlauf bezahlt
  · beim Puffern meldet der Server `laeuft=true`; ist der Pufferer
    nicht herzustellen, sagt die Messung das (dritter Ausgang)
Beide Behebungen einzeln zurueckgenommen: beide Male rot.

Die erste Fassung der Gegenprobe war selbst falsch -- sie hielt das
Selbstheilen des Systems (der Host meldet alle fuenf Sekunden die
Wahrheit) fuer einen Fehler. Deshalb drueckt sie jetzt den Knopf,
statt eine Meldung zu faelschen.

=====================================================================
2. DIE REACTION LAEDT EIN
=====================================================================

Nachgemessen war `workspace-reaktion.js` STUMM: kein einziges
`benachrichtige`. Beim Einschalten lief `melden("reaktion", ...)`
ueber den Ereignisstrom -- also nur an Leute, die die Seite ohnehin
offen haben. Das Kino machte auf, und die Einladung verliess das Haus
nie. Dasselbe Muster wie bei den Bewerbungen am 23.09.

Neue Art `reaktion_live`, eigene neben `dogfather_live`: Das eine ist
sein Stream auf TikTok, das andere das Kino hier im Haus.

Eingeladen wird, wer ein Geraet hat und NICHT der Agentur gehoert
(`AGENTUR_ROLLEN` -- keine neue Liste). Nicht der, der eingeschaltet
hat. Und nicht zweimal: Eine Bremse von zwei Stunden faengt den
Neustart ab, denn das Merkmal haengt an `gestartet_am` und das wird
bei jedem Wechsel nach live neu gesetzt.

Geprueft Ende zu Ende in pruef-reaktion (+10): Sendung geht auf
Sendung, danach stehen genau fuenf Einladungen in `push_verschickt` --
rechte Hand, linke Hand, Modi, zweimal Community. Nicht der Host,
nicht die Creatorin. Einladung abgeschaltet: sechs Fehlschlaege.

=====================================================================
3. DARF DAS NACHTS KOMMEN? -- drei Antworten statt zwei
=====================================================================

Hier stand `art === "test" || art === "anruf"`, 230 Zeilen von der
Artenliste entfernt. Am 18.09. hat das eine Nacht lang alle Anrufe
verschluckt; behoben wurde damals dieser eine Fall.

GEMESSEN AM LIVE-BESTAND: `dogfather_live` ging an allen sieben
Abenden vom 22. bis 28.09. zwischen 20:28 und 21:06 raus -- jedes Mal
knapp vor der Sperre um 22 Uhr. Geht Filipe einmal um 22:05 live,
bekommt niemand etwas, und niemand erfaehrt warum.

`ruhe` steht jetzt an der ART:
  "immer" (Vorgabe)  22-7 gesperrt   -- Erinnerungen
  "spaet"            nur 1-7         -- etwas laeuft GERADE
  "nie"              nie             -- ein Mensch wartet am Hoerer

=====================================================================
4. JEDES HAUS BEKOMMT SEIN GESICHT
=====================================================================

Im Service Worker stand fest `workspace-192.png`. Von acht Menschen
mit angemeldetem Geraet gehoeren fuenf ins Crew-Haus -- die sahen auf
jeder Benachrichtigung das Symbol des Hauses, in dem sie nicht
arbeiten.

Entschieden wird es auf dem SERVER: Im Browser stuende sonst die
verborgene Crew-Adresse in einer Datei ohne Anmeldung (der Befund vom
21.09. bei crew-haus.css) -- und es waere eine zweite Fassung der
Hausteilung. Zweistufig, beide Stufen gibt es schon: die Zielseite,
wenn sie in genau ein Haus gehoert (GEHOERT_ZU_ADRESSE), sonst
`AGENTUR_ROLLEN`.

=====================================================================
5. DAS ABZEICHEN WAR EIN KLOTZ
=====================================================================

`badge` ist das winzige Zeichen in der Statusleiste; Android benutzt
davon NUR den Alphakanal. Dort stand dasselbe `workspace-192.png` --
ein vollflaechiges Quadrat ohne Transparenz, also ein ausgefuellter
Klotz. Es stuerzt nichts ab, deshalb faellt es niemandem auf.

`assets/img/abzeichen-96.png`: der gefuellte Umriss des Huskys, weiss
auf durchsichtig, 3,5 KB. Drei Fassungen gebaut und bei ECHTER Groesse
(24 px) verglichen -- Strichbild zerfaellt, Flaeche traegt.

`server/helfer-png-alpha.mjs` liest den Alphakanal wirklich (PNG
auspacken, Zeilenfilter zuruecknehmen). Eine Pruefung auf „die Datei
gibt es" haette den Klotz nie gefunden -- die Datei gab es ja. Die
Gegenprobe ist der alte Zustand selbst: dieselbe Rechnung meldet auf
`workspace-192.png` „kein Alphakanal, Deckung 100 %".

Das Abzeichen liegt NICHT bei den App-Symbolen -- `pruef-struktur`
verlangt dort zu jedem Namen den vollen Satz und hielt es fuer eine
neue App. Der Waechter hat recht: Ein Abzeichen ist kein App-Symbol.

=====================================================================
Gemessen: mess-reaktion 0, pruef-reaktion 393/0 (+10),
pruef-push-ziel 38/0 (+27), pruef-push 24/0, pruef-push-weg 20/0,
pruef-glocke 36/0, pruef-struktur 44/0, pruef-haus-trennung 100/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 23:48:46 +02:00
DogFatherGitandClaude Opus 5 a43bc565ec Werbeeinblendung fuer die Reaction: Laufband, Wechsel und zwei Bilder in sechs Ecken
Filipe: „ich will dass du auch perfektionierst dass ich einen banner
durchlaufen kann, oder erscheinen lassen kann mit werbung drauf ...
und ich will auch das dogfather logo oben rechts links unten rechts
links genau wie oben unten mittig, so dass ich es wegmachen und
hinzufuegen kann."

DAS BAND
Zwei Arten: „Laufband" zieht die Botschaften von rechts nach links,
„Wechsel" blendet sie nacheinander ein. Ein Rabattcode gehoert in den
Wechsel -- wer „DOGI10" lesen will, waehrend es wandert, hat es
gesehen und nicht behalten. Die Dauer ist einstellbar (8-40 s).

Das Band ist Glas, kein Balken: ein Verlauf, unten dicht genug, dass
jede Schrift traegt, oben offen. Man sieht das Video weiter.

Das Laufband traegt die Folge ZWEIMAL. Wandert es um 50 % seiner
Breite, steht die zweite dort, wo die erste war, und der Sprung
zurueck ist unsichtbar. Ohne sie entstuende am Ende jedes Durchlaufs
eine leere Flaeche -- das sieht aus, als sei die Einblendung
abgestuerzt.

Wer weniger Bewegung eingestellt hat, bekommt den Wechsel statt des
Laufbands -- nicht „aus". Die Werbung soll auch dann wirken.

DER RABATTCODE
Ein Wort in Grossbuchstaben mit einer Ziffer darin bekommt eine
eigene Flaeche in Bernstein: „DOGI10" ja, „VANVAN" nicht. Er ist der
einzige Teil der Botschaft, den jemand ABSCHREIBEN soll.

Der Text wird NIE als HTML eingesetzt -- die Kennzeichnung baut echte
Elemente. Sonst waere ein Eingabefeld im Regiepult ein Weg, fremdes
Markup in den Stream zu bringen.

DIE ZWEI BILDER
Der Husky und der Haekelhase aus der Zusammenarbeit mit VanVan,
jeweils an einem von sechs Plaetzen: oben und unten, je links,
mittig, rechts -- oder aus. Links und rechts mittig gibt es mit
Absicht nicht: dort liegen bei jedem Videodienst die Bedienelemente.

Zwei Bilder auf denselben Platz werden abgelehnt (400 ecke_belegt) --
sie laegen uebereinander, und man saehe von beiden nichts.

Wer unten steht, weicht dem Band aus, und die Spendentafel rueckt
hoch. Beides im Stilblatt ueber `:has`, nicht im Programm: Das Band
kennt die Tafel nicht und die Tafel kennt das Band nicht.

Der Husky ist schwarz und laege auf dunklem Videobild als Silhouette
in der Nacht. Zwei weiche helle Schatten legen eine Kontur darum --
dieselbe Loesung, die jeder Sender fuer sein Wasserzeichen nimmt.

IM STREAM, NICHT NUR IM SAAL
Beides laeuft in der Buehnenquelle mit, die OBS abfilmt, mit
groesserer Schrift (24 statt 18 px) -- ein Stream wird auf einem
Handy gesehen, oft in einem Viertel des Bildes. Gemessen: die OFFENE
Quelle zieht eine Umschaltung nach, ohne dass die Szene neu geladen
werden muss.

EINE QUELLE, NICHT DREI
`werbungStand()` steht in `reaktion-tabellen.js` und wird von Saal,
Regie und Buehne aufgerufen. Erst standen dort drei Abschriften
derselben Abfrage; beim Entfernen der Datenbank-Kennung habe ich eine
davon geaendert, und die anderen lieferten sie weiter.

WAS DIE PRUEFUNGEN DABEI GEFUNDEN HABEN
- pruef-buehne: Die Botschaften trugen ihre Datenbank-Kennung in die
  OBS-Auskunft, die nur mit einem Schluessel geschuetzt ist. Die
  Feldliste geht jetzt zwei Ebenen tief -- eine abschliessende Liste,
  die nur die oberste kennt, laesst sich umgehen, indem man ein Feld
  in ein vorhandenes Objekt legt.
- pruef-reaktion: Acht Fehlerwoerter ohne deutschen Satz. Nachgetragen
  im Hausstil: was nicht geht UND was stattdessen.
- pruef-struktur: Die Suche nach totem CSS las `buehne.html` und
  `tafel.html` nicht -- eine Ausnahme, die fuer eine ganz andere
  Frage gemacht war (Startbildschirm-Symbol). Sie haette verlangt,
  `.obs-buehne` zu loeschen, also die Regel, die den Stream traegt.
- pruef-tippziele: Erkannte als Anhebung nur `min-height`/`min-width`.
  Ein `height: 44px` im Fingerblock hebt genauso -- Fehlalarm, und
  ein Fehlalarm an einer Pruefung, die nach jeder Aenderung laeuft,
  wird weggeklickt. Zwei Gegenproben dazu.
- mess-reaktion: Die Regieleiste am Handy wurde gegen die feste Zahl
  SIEBEN geprueft und meldete „zeigt nur 8 von 7". Sie zaehlt jetzt
  selbst.
- mess-buehne: Die Konsolenmeldung zeigte dauerhaft vier
  Fehlschlaege, alle von den Pruefungen selbst bestellt. Jetzt eng
  gefiltert, benannt, mit Gegenprobe -- und ein UNERWARTETER
  Fehlschlag in der Quelle, die in den Stream geht, faellt ab sofort
  durch.

Gemessen: mess-reaktion 0, mess-buehne 0, pruef-reaktion 383/0,
pruef-buehne 38/0, pruef-tippziele 13/0, pruef-struktur 44/0,
pruef-css-klassen 0, pruef-haus-trennung 100/0, pruef-haus-seiten 38/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 17:16:26 +02:00
DogFatherGit 077925ab25 Dreizehn rote Pruefungen, eine Ursache: openssl steht nicht im PATH
DER BEFUND

Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:

    [uncaughtException] Error: spawnSync openssl ENOENT

Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.

GEMESSEN, NICHT VERMUTET

  openssl liegt hier:   C:\Program Files\Git\mingw64\bin\openssl.exe
                        C:\Program Files\Git\usr\bin\openssl.exe
  im System-PATH steht: C:\Program Files\Git\cmd   (nur git.exe)
  im Benutzer-PATH:     nichts mit Git

Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.

Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.

DIE REPARATUR

`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.

Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.

KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.

GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:

  vorher (direkter Aufruf):  ABSTURZ: spawnSync openssl ENOENT
  nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes

Und eine echte Pruefung unter denselben Bedingungen:

  PATH ohne mingw64 -> pruef-chat-neu-stelle
  63 Pruefungen, 0 Fehler, 0 Abstuerze

Genau diese Datei stand im Nachtlauf mit ENOENT rot.

EINE WACHE DAGEGEN

pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.

Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.

DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT

`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.

NEBENBEI: pruef-community-sicht

Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.

Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.

GEPRUEFT
  pruef-struktur         ALLES IN ORDNUNG (mit der neuen Wache)
  pruef-community-sicht  10 / 0
  pruef-chat-neu-stelle  63 / 0  (mit dem PATH des Nachtlaufs)
  pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
  mess-quer              EXIT 0
  29 Dateien: node --check auf allen, kein direkter Aufruf mehr
2026-09-29 16:14:13 +02:00
DogFatherGit b846693de5 Die letzten fuenf blinden Messungen bekommen einen Finger
Die Grundlinie der Fingerwache sinkt von 6 auf 1.

DIE SCHWEREN FAELLE

pruef-notizen, pruef-teamlage-karten, pruef-terminregel,
pruef-werdegang, pruef-befinden.

Bei diesen fuenf stand mitten im Ablauf ein
`setViewportSize({ width: 390 })`. Das aendert nur die Groesse --
`hasTouch` gehoert zum KONTEXT und ist beim Anlegen entschieden.
Nachtragen geht nicht; es braucht ein zweites Fenster.

DAS MUSTER, DAS FUER ALLE FUENF GETRAGEN HAT

  const handyKontext = await browser.newContext({
    viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
  await handyKontext.addCookies(await kontext.cookies());
  const handy = await handyKontext.newPage();
  await handy.goto(seite.url(), { waitUntil: "networkidle" });

Drei Entscheidungen darin:

  - Die Anmeldung wandert als Keks mit, statt sie zu wiederholen.
    Eine zweite Anmeldung waere eine zweite Stelle, die veralten
    kann.
  - Die Adresse kommt von `seite.url()`. Sie ein zweites Mal
    hinzuschreiben waere eine zweite Wahrheit darueber, welche
    Seite gemessen wird.
  - Das fruehere Zuruecksetzen auf 1280 px faellt weg. Die breite
    Seite war nie schmal -- es gibt nichts zurueckzustellen.

Bei pruef-terminregel liessen sich die vier Schritte, die den
schwebenden Hinweis erzeugen, nicht uebernehmen: Sie werden im
neuen Kontext nachgefahren (oeffnen, "Neu", Datum eintragen).

ALLE FUENF DANACH GRUEN

  pruef-notizen         EXIT 0
  pruef-teamlage-karten EXIT 0  (39 geprueft)
  pruef-terminregel     EXIT 0  (35 Pruefungen)
  pruef-werdegang       EXIT 0
  pruef-befinden        EXIT 0

Diese Seiten halten die Fingerregeln also schon ein. Es hatte nur
nie jemand nachgesehen.

EINES BLEIBT: pruef-grosscheck

Die Datei zu aendern waere ein Zweizeiler. Sie zu PRUEFEN hiesse,
206 Seiten ueber vier Rollen und zwei Bildschirmgroessen laufen zu
lassen -- das braucht Filipes Zusage. Eine Aenderung, die ich nicht
pruefen darf, liefere ich nicht aus. Die Grundlinie steht deshalb
auf 1 und nicht auf 0.

GEPRUEFT
  pruef-fingermass  3 / 0, neun Gegenproben, Grundlinie 1
2026-09-29 16:00:57 +02:00
DogFatherGit d5e873d597 Zehn Messungen bekommen einen Finger -- und eine Zeitbombe faellt auf
Die Grundlinie der Fingerwache sinkt von 17 auf 6.

WAS UMGESTELLT WURDE

pruef-alter, pruef-countdown, pruef-gespraech, pruef-treffchat,
pruef-modi-verborgen, pruef-nachwuchs, pruef-personen-liste,
pruef-serien, pruef-team, pruef-kalender.

Alle zehn legen ein eigenes Handyfenster an; dort fehlte nur
`hasTouch`. Ohne ihn meldet der Browser einen feinen Zeiger, und
keine Regel aus `@media (pointer: coarse)` greift -- dort stehen die
44-Pixel-Beruehrziele.

JEDE EINZELN GELAUFEN, ALLE GRUEN

Diese Seiten halten die Fingerregeln also schon ein. Es hatte nur
nie jemand nachgesehen.

Vorher geprueft, wie gross jede ist: 1 bis 7 Seitenaufrufe, 1 bis 5
Fenster -- echte Einzelpruefungen. (Zum Vergleich: die Pruefung von
gestern Nacht hatte 244 Seitenaufrufe, und genau das hatte ich
vorher nicht nachgesehen.)

DIE ZEITBOMBE IN pruef-kalender

Beim Umstellen fiel sie rot aus -- zweimal, an einer Stelle, die mit
dem Finger nichts zu tun hat:

  FEHL im naechsten Monat ist kein Tag mehr "heute"

Gegen den ausgelieferten Stand gemessen: identisch rot. Nicht
meine Aenderung, sondern der Kalender.

Heute ist der 29. September. Das Oktober-Raster zeigt in seiner
ersten Zeile die letzten Septembertage mit, und einer davon IST
heute. Die Anwendung macht dabei alles richtig: `kalender.js` setzt
die Marke an `tag === daten.heute`, also am DATUM und nicht an der
Rasterposition. Genau das sollte die Pruefung beweisen -- und schlug
an, weil die Anwendung es tat.

Geschrieben wurde sie an einem Tag in der Monatsmitte. Von da an war
sie richtig, bis der Kalender sie einholte. Dasselbe Muster wie
`gate-oeffnung.mjs` am 06.09.2026 im Shop: Ein Test, der die
Wanduhr als Annahme benutzt, misst irgendwann das Gegenteil.

Gefragt wird jetzt, was gemeint war: Ein EIGENER Tag des
Folgemonats darf nie „heute" sein; ein mitgezeigter Randtag
(`data-fremd="ja"`) dagegen sehr wohl, und zwar genau dann, wenn er
es ist. Das gilt an jedem Tag des Jahres. Die Meldung nennt jetzt
beide Zahlen:

  ok  im naechsten Monat ist kein EIGENER Tag mehr "heute"
      (0 eigene, 1 mitgezeigte Randtage)

WAS UEBRIG BLEIBT: SECHS SCHWERE FAELLE

pruef-befinden, pruef-notizen, pruef-teamlage-karten,
pruef-terminregel, pruef-werdegang, pruef-grosscheck.

Dort schaltet `setViewportSize` mitten im Ablauf auf Handyformat um
-- und der Finger laesst sich nachtraeglich nicht setzen, er gehoert
zum Kontext. Jeder Fall braucht einen eigenen Kontext samt
Anmeldung: Umbau, nicht Einzeiler.

`pruef-grosscheck` bleibt unangetastet, bis Filipe einen Lauf
freigibt. Eine Aenderung, die ich nicht pruefen darf, liefere ich
nicht aus.

GEPRUEFT
  alle zehn einzeln: EXIT 0, keine FEHL-Zeile
  pruef-kalender     EXIT 0 (vorher 2 Fehlschlaege, auch auf HEAD)
  pruef-fingermass   3 / 0, Grundlinie 6
2026-09-29 14:51:52 +02:00
DogFatherGit 59b2a6d0e5 Was nur fuer Vorleseprogramme dasteht, ist kein zerquetschter Text
DER BEFUND WAR EIN FEHLALARM

pruef-handy-teamdogi meldete auf notizen.html, nur bei 412 px:

  Text auf fast nichts gedrueckt -- a.zurueck „Team Dogi…" 0 statt 82px

Nachgemessen ist das Absicht. start.css setzt auf schmalen Schirmen
`.kopfleiste .marke__text` auf 1x1 Pixel mit `clip-path: inset(50%)`
-- das uebliche Muster fuer "bleibt fuer Vorleseprogramme,
verschwindet fuer das Auge". Der Grund steht daneben: Der Text
steht auf jeder Seite zwei Zeilen tiefer noch einmal als
Ueberschrift.

WARUM DIE ERKENNUNG DANEBENGRIFF

`nurVorlesen()` gab es laengst -- sie sah aber nur das Element
SELBST an. Der Link darin ist 0 Pixel breit, aber eine Zeile hoch,
und traegt selbst kein `clip-path`. Er fiel damit durch beide
Bedingungen. Die Funktion steigt jetzt die Kette der Vorfahren hoch.

DAS MACHT DIE REGEL SCHAERFER, NICHT LAXER

Ausgenommen wird nur, was in einem nachweislich abgeklemmten Kasten
sitzt. Der echte Fund vom 20.09.2026 -- ein Gespraechsname, der in
einer normalen Kopfzeile auf null gequetscht wurde -- hat keinen
solchen Vorfahren und wird weiter gemeldet.

Und das ist nicht behauptet, sondern gemessen: Die Gegenprobe
bekommt ein PAAR statt eines Falls. Derselbe zerquetschte Text
einmal in einem abgeklemmten Kasten (darf nicht gemeldet werden)
und einmal ohne (muss gemeldet werden). Eine Regel, die enger
misst, kann zu eng sein; dann faellt echter Schaden durch und der
Lauf bleibt gruen. Sechs Gegenproben statt vier.

GEMESSEN

  244 Seitenaufrufe, 123409 Elemente, 5393 Bedienelemente
  0 Befunde   (vorher: 4-5, darunter die 40-Pixel-Knoepfe)
  alle sechs Gegenproben greifen, zweimal hintereinander gleich

  span#gp-laut  „Dieser Name ist wirklich…" 0 statt 320px  -> gemeldet
  span#gp-leise                                            -> nicht

OFFEN GEBLIEBEN

Die Ueberstandsmessung derselben Datei nimmt abgeklemmte Elemente
NICHT aus -- mein Gegenprobe-Element taucht dort als 523 px auf.
Auf echten Seiten hat das keine Folgen (0 Befunde), aber es ist
dieselbe Inkonsistenz an einer zweiten Stelle. Nicht angefasst,
weil der Nachweis dafuer wieder den ganzen Lauf braucht.
2026-09-29 14:35:29 +02:00
DogFatherGit af61f77e92 Die Transportknoepfe sind am Daumen wieder 44 Pixel hoch
DER BEFUND

pruef-handy-teamdogi meldete auf iPhone UND Android, bei zwei
Rollen, denselben Mangel:

  reaktion.html: zu kleine Tippziele --
  button#v-zurueck 48x40, button#v-vor 48x40, button#v-naechstes 83x40

Die Grundregel von `.spur__knopf` steht auf `min-height: 40px`. Am
Rechner ist das richtig; am Finger sind es vier Pixel zu wenig, und
eine Ausnahme dafuer gab es nicht.

WARUM DAS ERST HEUTE AUFFAELLT

Die Transportleiste war am Handy bis gestern gar nicht erreichbar --
sie lag 162 Pixel unter dem Fensterrand, und der Koerper hat
`overflow: hidden`. Was nicht sichtbar ist, wird nicht gemessen. Die
Reparatur von gestern hat den Mangel also nicht verursacht, sondern
aufgedeckt.

Gemessen nach der Aenderung: Transportleiste 207 statt 203 Pixel,
Saal 775 = Leiste 101 + Kino 467 + Regie 336. Kein Knopf liegt ueber
einem anderen, die Leiste verdeckt kein Bedienelement der Regie.

DIE FINGERWACHE ZAEHLT JETZT FENSTER, NICHT DATEIEN

Die erste Fassung fragte: "Steht `hasTouch` irgendwo in der Datei?"
pruef-kalender allein hat sieben Fenster, davon drei schmale -- ein
einziges `hasTouch` haette alle drei freigesprochen. Genau diese
Mischung ist im Haus die Regel.

Die geschaerfte Fassung fand prompt zwei Dateien, welche die grobe
freigesprochen hatte. Beim Nachmessen:

  - pruef-chat-optik: FEHLALARM. Ihr `setViewportSize` sitzt auf
    einer Seite, deren Kontext `hasTouch: breite < 900` traegt --
    wer danach nur die Groesse aendert, behaelt den Finger. Die
    Wache zaehlt solche Stellen jetzt nur noch, wenn in der ganzen
    Datei kein einziges `hasTouch` steht. Wo ich raten muesste,
    zaehle ich nicht: Eine Wache, die im Zweifel meldet, wird
    weggeklickt.
  - pruef-handy-teamdogi: ECHTER FUND. Ihr zweiter Kontext hatte
    keinen Finger, der erste schon. Repariert.

Grundlinie damit von 23 auf 17. Neun Gegenproben statt sechs,
darunter beide neuen Faelle.

Drei Anlaeufe, drei Zahlen: grep 18, Wache je Datei 16, Wache je
Fenster 17 (nach Abzug des Fehlalarms). Die erste Zahl, die eine
Wache liefert, ist selten die richtige.

NOCH OFFEN AUS DEMSELBEN LAUF

notizen.html, nur auf 412 px: `a.zurueck` „Team Dogi…" ist 0 Pixel
breit statt 82. Noch nicht angesehen.

GEPRUEFT
  pruef-reaktion, -tippziele, -css-klassen: alle EXIT 0
  pruef-fingermass    3 / 0
  mess-reaktion       EXIT 0, kein ACHTUNG, kein offener Punkt
2026-09-29 14:23:57 +02:00
DogFatherGit 315eb08cc4 Eine Wache dafuer, dass am Telefon mit dem Finger gemessen wird
DER FUND VON HEUTE WAR GROESSER ALS DIE EINE FUSSZEILE

`page.setViewportSize({ width: 412 })` aendert nur die Groesse.
`hasTouch` gehoert zum KONTEXT und laesst sich danach nicht mehr
setzen -- ohne ihn meldet der Browser `pointer: fine`, und KEINE
einzige Regel aus `@media (pointer: coarse)` greift. Dort stehen im
ganzen Haus die 44-Pixel-Beruehrziele.

Eine Messung mit Mauszeiger auf 412 Pixeln vermisst also eine Seite,
die es auf keinem Telefon gibt -- und meldet dabei "in Ordnung".
Das ist die dritte Sorte falscher Haken: nicht uebersprungen, nicht
rot, sondern gruen und wertlos.

GEZAEHLT: 30 Dateien oeffnen ein Telefonformat. 16 davon messen
darin Groessen, ohne einen Finger zu haben.

pruef-ueberlappung IST UMGESTELLT

Sie sucht Bedienelemente, die uebereinander liegen -- also genau die
Groessen, die erst unter `pointer: coarse` entstehen. Sie hat bis
heute mit dem Zeiger gemessen. Mit Finger: 20 Seiten-Breiten-Paare,
0 Befunde. Das ist zum ersten Mal wirklich geprueft und nicht nur
behauptet. Oberhalb von 860 px bleibt es beim Zeiger; ein Laptop
hat keinen Finger.

DIE UEBRIGEN 16 WERDEN NICHT HEUTE NACHT UMGESTELLT

Sechzehn Pruefungen anzufassen und jede einzeln neu laufen zu lassen
waere ein Gesamtlauf durch die Hintertuer -- und genau die Ausrede,
die in CLAUDE.md steht. Stattdessen haelt `pruef-fingermass.mjs`
eine Grundlinie: Sie meldet nicht 16 Fehler auf einmal (eine
Warnung, die immer kommt, wird weggeklickt und nimmt den echten Fund
mit), sondern schlaegt an, sobald eine SIEBZEHNTE dazukommt.

Sie schlaegt auch an, wenn die Zahl SINKT. Wer eine Messung
repariert und die Grundlinie stehen laesst, deckt Platz fuer die
naechste Suende. Das kostet eine Zeile und haelt die Wache scharf.

DIE ERSTE ZAHL EINER WACHE IST SELTEN DIE RICHTIGE

Mein grep hatte 18 gezaehlt. Zwei davon waren Zahlen in Kommentaren
("gemessen bei 412 px"), keine Fenster. Die Pruefung liest deshalb
nur die Stelle, die ein Fenster AUFMACHT, und blendet Kommentare
vorher aus.

DREI AUSGAENGE, SECHS GEGENPROBEN

Findet sie weniger als 50 Pruefdateien, ist das kein "alles in
Ordnung", sondern "konnte nicht nachsehen" (Rueckgabewert 2). Die
Gegenproben decken beide Richtungen ab, darunter der Fall
`setViewportSize` -- der kann `hasTouch` gar nicht nachtragen und
zaehlt deshalb immer.

`tools/alles-pruefen.mjs` liest den Ordner, keine gepflegte Liste --
die neue Wache laeuft ohne Zutun mit.

GEPRUEFT
  pruef-fingermass    3 / 0   (neu)
  pruef-ueberlappung  EXIT 0 -- 20 Paare, 0 Befunde, jetzt mit Finger
  pruef-portnummern   15 / 0
2026-09-29 14:04:06 +02:00
DogFatherGit 3dd4cef42d Die Handgriffe unter jeder Nachricht: drei Zeilen werden zwei
EINE RECHNUNG, DIE NIE GEMESSEN WURDE

In chat.css stand seit dem 25.09.2026 neben den 44-Pixel-Knoepfen
der Fusszeile:

  "Fuenf mal 44 plus vier Abstaende sind 236 px -- eine Blase hat
   auf 412 px innen 251 px. Die Reihe bleibt damit einzeilig."

Sie vergisst die Uhrzeit. Die ist 57 Pixel breit und steht in
derselben Reihe; 236 + 57 sind 293.

Auffallen konnte das nicht, weil die Regel nie lief: Alle
Handy-Messungen des Hauses liefen bis gestern mit einem MAUSZEIGER.
`setViewportSize` macht aus einem Browser kein Telefon -- `hasTouch`
gehoert zum Kontext und laesst sich danach nicht mehr setzen. Ohne
ihn meldet der Browser `pointer: fine`, und KEINE einzige Regel aus
`@media (pointer: coarse)` greift. Dort stehen im ganzen Haus die
44-Pixel-Beruehrziele. Die Rechnung wurde also gedacht und nie
gesehen.

Mit Finger gemessen, 412 px: 234 Pixel Platz fuer 277 Pixel Inhalt.
DREI Zeilen, 96 Pixel hoch -- unter jeder einzelnen Nachricht.

DIE UHRZEIT BEKOMMT IHRE EIGENE ZEILE

Sie ist das einzige Stueck der Reihe, das kein Beruehrziel ist.
Damit behalten die fuenf Knoepfe ihre 44 Pixel nebeneinander:
5 x 44 + 4 x 2 = 228 und passen in 234.

Gemessen jetzt: zwei Zeilen, 74 Pixel. Die Knopfzeile braucht 224
von 234.

EIN VERSUCH, DER ES NICHT WURDE

Zuerst hatte ich die Blase am Fingergeraet von `min(66%, 62ch)` auf
82 Prozent verbreitert -- daneben steht ja die Absicht, sie solle
"auf dem Handy trotzdem die Breite ausnutzen". Die Messung kam
SCHLECHTER zurueck (188 statt 234 Pixel Platz), und das lag an der
Messung selbst: Sie las "die erste Fusszeile". Wie breit die ist,
haengt vom Text darueber ab. Dieselbe Aenderung sah dadurch mal
besser und mal schlechter aus, ohne dass sich etwas geaendert hatte.

Der eigentliche Grund, warum 82 Prozent nicht helfen: `max-width`
ist eine Obergrenze, keine Breite. Eine kurze Nachricht hat eine
schmale Blase, und die Fusszeile bricht darin genauso um. Bei
allen vier gemessenen Nachrichten brach sie.

MESSUNG GESCHAERFT

  - Sie fragt jetzt, WIE VIELE Fusszeilen umbrechen, nicht wie die
    erste aussieht.
  - "Inhalt" ist die breiteste ZEILE, nicht die Summe aller Kinder.
    Seit die Uhrzeit absichtlich umbricht, waere die Summe die
    Breite zweier Zeilen uebereinander -- sie meldete 444 Pixel in
    einer 234 Pixel breiten Fusszeile und sah wie ein Ueberlauf aus.
  - `mess-notizblock` misst am Handy jetzt ebenfalls mit Finger
    (eigener Kontext, `hasTouch`). Vorher zeigten seine
    Handy-Bilder eine Seite, die auf keinem Handy so aussieht.

DIE KLAMMERWACHE HAT WIEDER EINEN ECHTEN FUND GEMACHT

Beim Zuruecknehmen des 82-Prozent-Versuchs blieb das schliessende
`}` der Medienabfrage stehen. Zweiter echter Fund in zwei Tagen,
beide Male an meinem eigenen Werkzeug.

GEPRUEFT
  pruef-chatkachel, -css-klassen, -tippziele, -handy: alle EXIT 0
  mess-chat-optik   EXIT 0 -- 2 Zeilen, 74 px (vorher 3 / 96)
  mess-notizblock   EXIT 0
2026-09-29 13:59:20 +02:00
DogFatherGit 7351820c55 Der Waechter gegen Namensstreit gilt jetzt fuer das ganze Haus
Er wurde gestern fuer `reaktion.css` gebaut, nachdem `.stufen` dort
zweimal stand und die Stufenleiter der Spenden dadurch das Aussehen
der Chat-Leiste bekam -- drei Stufen nebeneinander, 572 Pixel in
einer 327 Pixel schmalen Spalte, mit der waagerechten Rollleiste auf
Filipes Bildschirmfoto.

Eine Wache, die nur eine Datei ansieht, findet genau die Fehler
dieser einen Datei. Auf alle 36 Stilvorlagen angewandt fand sie
GENAU EINE weitere Stelle.

DER FUND: `.wahl2__eintrag` in gate.css

    Zeile 1584  display: block   (mit text-overflow: ellipsis)
    Zeile 2634  display: flex    (fuer Personenbilder, 08.09.2026)

Kein Streit zweier Bauteile, sondern eine spaetere Erweiterung --
und trotzdem ein echter Fehler: `text-overflow: ellipsis` wirkt bei
`display: flex` NICHT auf ein anonymes Flex-Kind. Der Knopf
„… eintragen" in jeder Auswahl des Hauses hat langen Text seither
HART abgeschnitten statt mit „…" gekuerzt.

GEMESSEN -- und nur im Bild zu sehen: `scrollWidth` ist in beiden
Faellen gleich (364 px bei 200 px Breite). Die Zahlen sagten
„kein Unterschied", das Bildschirmfoto zeigte einen. Ein Beleg mehr
dafuer, dass zwei gleiche Zahlen nicht dasselbe Ergebnis bedeuten.

REPARIERT
  - Der eigene Eintrag bekommt einen `.wahl2__name`-Span, wie jeder
    andere Eintrag auch. Damit kuerzt er wieder mit „…".
  - Die zwei Grundregeln sind eine. Wer die erste las, glaubte an
    `display: block` -- 1050 Zeilen weiter stand das Gegenteil.

DIE WACHE
  923 Grundregeln in 36 Stilvorlagen, keine Klasse zweimal
  verschieden gebaut. Gezaehlt wird nur, was sich widersprechen
  kann: nicht dieselbe Klasse in einem Zusammenhang (`.saal .pult`),
  nicht eine Variante in einer Medienabfrage, nicht
  „erst versteckt, dann gezeigt". Sechs Gegenproben, darunter der
  echte Fall vom 28.09. und eine Klasse im Kommentar.

GEPRUEFT: css-klassen, freie-namen, suchfeld, tippziele alle EXIT 0.
2026-09-29 13:45:02 +02:00
DogFatherGit 0c696f286d Der Kopf der Regie bleibt stehen, waehrend ihr Inhalt rollt
Drei kleine Dinge an der Schublade von vorhin, und eine ehrliche
Notiz zu dem, was nicht ging.

  - `overflow-y: auto` statt `overflow: hidden`. Reicht der Platz
    nicht, rollt die Schublade senkrecht -- statt ihren Inhalt
    unter der Transportleiste verschwinden zu lassen. Senkrechtes
    Rollen war nie das Problem; Filipes Ansage vom 28.09. galt dem
    Wischen nach links und rechts.

  - `flex: none` am Kopf. Ohne das ist er ein Flex-Kind mit
    `shrink: 1` -- was nichts hilft, weil sein `min-height: auto`
    ihn nicht unter die Hoehe seines Inhalts laesst. Er ragte dann
    einfach hinaus.

  - Der Kopf klebt beim Rollen (`position: sticky`). Sonst scrollt
    man „Auf Sendung" weg -- den Knopf, um den es geht.

AUF 320 PIXELN BLEIBT ES BEIM BEKANNTEN MANGEL

Dort bleiben zwischen Registern und Transportleiste rund 190 Pixel,
und der Kopf der Regie allein braucht 210. Sechs Versuche haben nur
bestimmt, WER verdeckt wird: „Vorbereiten" unter der Leiste, die
Schublade ueber „Gaeste" und „Bild", oder beim rollenden Container
22 Pixel Ueberlappung. Keiner hat es geloest.

Das steht jetzt mit Zahlen und Versuchen im Stilblatt -- samt dem
Hinweis, dass es ein eigenes Nachdenken braucht (die Regie wird dort
ein eigener Bildschirm statt einer Schublade) und nicht den siebten
Versuch mit derselben Werkzeugkiste.

NEBENBEI: Die Klammerwache hat ihren zweiten Fund in zwei Tagen
gemacht -- beim Umbauen blieb erneut eine schliessende Klammer ohne
ihren Anfang stehen. `pruef-handy` und `mess-reaktion` waren dabei
gruen; ohne die Wache waere es unbemerkt mitgegangen.

GEPRUEFT: css-klassen, handy, tippziele, reaktion alle EXIT 0;
mess-reaktion EXIT 0 (133 px sichtbares Bild, kein Knopf ueber
einem anderen), mess-quer EXIT 0.
2026-09-29 13:41:04 +02:00
DogFatherGit a6b37909ca Die Transportleiste steht am Handy in einer Reihe
Auf dem Bildschirmfoto nach dem Umbau lagen der runde Play-Knopf und
seine Nachbarn uebereinander. `pruef-handy` meldete das nicht: Sie
fragt, ob die MITTE eines Knopfes frei ist -- zwei Knoepfe koennen
sich an den Raendern ueberlagern und beide „erreichbar" sein. Wer
danebentippt, trifft trotzdem den falschen.

GEMESSEN (neue Stelle in mess-reaktion, die jedes Knopfpaar der
Leiste auf Ueberschneidung prueft):

    tempo-auf / v-spiel   21 x 44 px
    v-spiel / v-tauschen  21 x 38 px

URSACHE: `grid-template-columns: auto 1fr auto`. Tempo links und
„Wechseln zu <Videotitel>" rechts nahmen so viel, dass die mittlere
Spalte schmaler wurde als der 58 Pixel breite Play-Knopf -- und ein
zentrierter Inhalt, der breiter ist als seine Spalte, ragt ueber
BEIDE Raender.

Jetzt `minmax(0, auto) max-content minmax(0, auto)`: Die Mitte ist
so breit wie ihre drei Knoepfe zusammen und schrumpft nie, die
Seiten geben nach. `min-content` war dabei die falsche Zwischenstufe
-- damit war die Spalte so breit wie ihr BREITESTER Knopf, und
„−10" und „+10" brachen darunter um.

GEPRUEFT: pruef-handy, -tippziele, -css-klassen, -reaktion und
mess-quer alle EXIT 0; mess-reaktion meldet „Kein Knopf der
Transportleiste liegt ueber einem anderen" und 133 Pixel sichtbares
Bild.
2026-09-29 13:18:42 +02:00
DogFatherGit e272dd4673 Am Handy ist die Transportleiste wieder erreichbar
Der offene Punkt von gestern Nacht, jetzt geloest -- und dabei stellte
sich heraus, dass er schlimmer war als gemeldet.

WAS WIRKLICH LOS WAR

Gemeldet war: "Am Handy bleibt bei offener Regie vom Bild nichts."
Gemessen auf 390x844 ergab sich mehr:

    Saal 69..844 (775 px)
    Zeilen  101 + 0 + 511 + 325 = 937
    transport 681..1006  -- 162 Pixel UNTER dem Fensterrand

Der Koerper hat `overflow: hidden`; die Leiste war also nicht
abgeschnitten, sondern weg. Play, "Naechstes" und die Sprungknoepfe:
bei offener Regie nicht erreichbar. Das Bild hatte dabei null Pixel.

DIE RECHNUNG, DIE ES ERKLAERT

    Regieleiste   101  (zwei Zeilen Register)
    Regie-Kopf    207  (fuenf Zeilen zu je rund 44 px -- der
                        Klapp-Knopf stand allein in einer)
    Regie-Inhalt  305
    Transport     325  (Schiene 48 + drei Gruppen untereinander)
                 ----
                  938  von 775.

Video, Regie und Transport passen auf einem Handy nicht nebeneinander.
Das ist keine Frage der Gestaltung, sondern des Platzes.

VIER SCHNITTE UND EINE ENTSCHEIDUNG

  1. Die Tempo-Gruppe klappt hinter ihren eigenen Wert -- dasselbe
     Muster wie "Aa" im Chat, das dort 73 Pixel gespart hat. Der
     Knopf zeigt "1x" oder "1,5x", man muss zum Nachsehen also nicht
     aufklappen. Am Rechner gibt es ihn nicht.
  2. Der Klapp-Knopf rutscht neben die Lampe statt in eine eigene
     Zeile.
  3. Die drei Gruppen der Transportreihe stehen nebeneinander (158
     statt 240 Pixel) -- moeglich, seit die Tempo-Gruppe klappt.
  4. Die Messwerte stehen in einer Zeile, die grossen Knoepfe
     bekommen weniger Polsterung.

Das reichte nicht. Also die Entscheidung: AM HANDY LEGT SICH DIE
REGIE UEBER DAS BILD, statt ihm Platz wegzunehmen -- als Rasterfeld
ueber die Zeilen 2 und 3, unten angedockt, hoechstens 72 Prozent
hoch. Oben bleibt ein Streifen Bild stehen (gemessen 135 px): Wer
mitten in der Sendung etwas einstellt, muss sehen, worueber er redet.

Gemessen jetzt: Saal 775 = Leiste 101 + Bild 481 + Regie 346, und
der Transport steht bei 601..844 -- erreichbar.

DREI VERSUCHE, DIE ES NICHT WURDEN (und warum)

  - `position: absolute` mit gemessener Transporthoehe: lief, lag
    aber 17 Pixel ueber den Registern. Der Bezugsrahmen eines
    absolut gesetzten RASTERFELDES ist sein Rasterbereich, nicht der
    Container -- mit `grid-row: 3` (0 Pixel hoch) wurde aus
    `max-height: 100%` eine Hoehe von einem Pixel.
  - `top` UND `bottom` UND `height: fit-content`: Der Browser
    verwirft dann das `bottom`; die Regie ragte 101 Pixel in die
    Leiste.
  - `grid-row: 2 / 4` mit `align-self: end` statt absolut: ragte 146
    Pixel nach oben und schob die Seite 198 Pixel breiter.

AUF 320 PIXELN BLEIBT ES BEIM ALTEN

Dort passen Register (104), Regie-Kopf (210) und Transportleiste
(158) zusammen nicht in die 499 Pixel Saal -- es fehlten 17. Fuenf
Verteilungen haben nur bestimmt, WER verdeckt wird: erst
"Vorbereiten" und "Beenden" unter der Leiste, dann die Schublade
ueber "Gaeste" und "Bild". Die Schublade greift deshalb erst ab 360
Pixeln; darunter bleibt der Stand von vorher -- ein bekannter Mangel,
aber kein neuer. Der Grund steht im Stilblatt.

NEBENBEI GEFUNDEN

  - `.muenzsatz` brach nicht um und ragte auf 320 px 8 Pixel hinaus
    -- die Tafel rollte dort wieder waagerecht.

WACHEN GESCHAERFT

  - Die Klammerwache von gestern hat heute ihren ersten echten Fund
    gemacht: Beim Herausschneiden einer Regel blieb eine `}` stehen.
    Ohne sie waere das als drei Befunde in `mess-reaktion`
    aufgetaucht, die wie Programmfehler ausgesehen haetten.
  - Der Namensstreit-Waechter meldete `pult (grid vs. flex)` und
    `messwerte__paar (grid vs. flex)` -- beides Fehlalarm: Eine
    Regel in einem Zusammenhang (`.saal .pult`) und eine in einer
    Medienabfrage sind Varianten, kein Streit. Er nimmt beides jetzt
    aus. Dabei fiel auf, dass sein Muster die schliessende Klammer
    verbrauchte, die der naechste Treffer als Anfang braucht -- der
    echte Fall vom 28.09. (`.stufen` zweimal) wurde dadurch gar
    nicht gefunden. Die Gegenprobe deckt jetzt fuenf Faelle ab.
  - `mess-reaktion` mass die HOEHE der Kinozeile und haette 431
    Pixel gemeldet, waehrend das Bild vollstaendig verdeckt war.
    Sie misst jetzt, wieviel oberhalb der Schublade uebrig bleibt.

GEPRUEFT
  pruef-reaktion, -css-klassen, -tippziele, -handy, -struktur,
  -buehne: alle EXIT 0
  mess-reaktion  EXIT 0, kein ACHTUNG, kein offener Punkt
  mess-quer      EXIT 0 -- fuenf Groessen, nichts rollt seitlich
  mess-buehne    EXIT 0
2026-09-29 12:56:03 +02:00
DogFatherGit a9e0834cfb Nichts wird mehr nach links oder rechts geschoben
Filipe, 28.09.2026: "ich will auch nicht dass man sachen nach links
und rechts schieben muss. perfektion das untereinander. ich will
niemals irgendwas nach links oder rechts swippen muessen." Dazu ein
Bildschirmfoto der Tafel "Gestaltung" mit waagerechter Rollleiste.

GEMESSEN, NICHT GERATEN

Ein grep nach `overflow-x` findet nur die absichtlichen Roller. Er
findet nicht die Stelle, an der ein Inhalt breiter ist als sein
Kasten und der Browser von sich aus eine Rollleiste anhaengt -- und
genau das war auf dem Bild zu sehen. `server/mess-quer.mjs` geht
deshalb im echten Browser jedes Element durch, auf fuenf
Bildschirmgroessen, in allen sieben Registern, bei offener und
zugeklappter Regie und in allen fuenf Anordnungen.

Erster Lauf: 78 Stellen. Davon waren 54 KEINE -- `overflow-x: hidden`
ist abgeschnittener Text, dort laesst sich nichts schieben. Die
Messung trennt das jetzt; wer es mitzaehlt, findet die echten nicht
mehr. Uebrig blieben vier Ursachen:

  1. DIE REGISTER rollten absichtlich waagerecht. Auf 412 px standen
     von 605 px Registern 193 rechts ausserhalb -- dass es
     "Gestaltung" und "Spenden" gibt, erfuhr man nur beim Wischen auf
     Verdacht. Sie brechen jetzt um. Das kostet oben rund 45 Pixel
     und bringt Gewissheit dafuer.

  2. `.stufen` STAND ZWEIMAL IN reaktion.css -- einmal fuer die
     Stufenleiter der Spenden (`display: grid`), 600 Zeilen spaeter
     fuer die Chat-Leiste Offen/Team/Zu (`display: flex`). Die
     spaetere gewinnt: Die Stufenleiter stellte ihre drei Stufen
     NEBENEINANDER, 572 Pixel in einer 327 Pixel schmalen Spalte.
     Das ist die Rollleiste auf Filipes Bild. Die Chat-Leiste heisst
     jetzt `.chatstufen` / `.chatstufe`.
     Dritter Fall dieser Art nach `.tafel` und `.stufe-knopf`.

  3. DIE TAFELN KONNTEN NICHT SCHRUMPFEN. Ein Gitterfeld hat von
     sich aus `min-width: auto` und besteht auf seinem unteilbarsten
     Inhalt. Mit `minmax(0, 1fr)` und `min-width: 0` bricht jetzt
     alles um, statt hinauszulaufen.

  4. DIE TRANSPORTLEISTE ragte am Handy 76 Pixel ueber beide Kanten
     ("Naechstes" war nur halb zu sehen). Zwei Gruende: `.transport__teil`
     hatte kein `flex-wrap` (die mittlere Gruppe schon -- zwei Regeln
     fuer dieselbe Sache, eine vergessen), und im Umschalter steht
     ein ganzer Videotitel, der als Flex-Kind auf seiner vollen
     Breite bestand.

NEBENBEFUND: EINE FESTE ZAHL VON GESTERN

Der Saal stand auf `height: calc(100dvh - 69px)` -- 69 war einmal
die gemessene Hoehe der Kopfleiste. Sie ist 73 geworden, und der
Saal endete damit 4 Pixel unter dem Fensterrand: Der Play-Knopf war
nur mit Scrollen zu erreichen. Die Antwort ist keine neue Zahl,
sondern eine Regel, die misst -- der Koerper ist jetzt eine Spalte,
die Kopfleiste nimmt, was sie braucht, der Saal bekommt den Rest.
(Dabei noch ein Spezifitaetsunfall: `.reaktion-seite` (0,1,0)
verliert gegen `body.start` (0,1,1) aus start.css.)

NEUE WACHEN

  - `mess-quer.mjs` mit Gegenprobe (ohne die Reparaturen findet sie
    47 px, mit ihnen nichts).
  - pruef-reaktion: kein absichtliches `overflow-x: auto` mehr; und
    zwei Grundregeln derselben Klasse mit verschiedenem `display`
    sind ein Namensstreit. Der bisherige Waechter verglich nur
    ZWISCHEN Stilvorlagen -- `.stufen` stand zweimal in DERSELBEN.
  - pruef-css-klassen: jede Klammer hat ihr Gegenstueck. Beim
    Umschreiben blieb heute das Ende einer Regel ohne ihren Anfang
    stehen; der Browser wirft die Zeile weg und schliesst dafuer den
    naechsten @media-Block zu frueh. Drei Befunde sahen daraufhin wie
    Programmfehler aus.

MESSUNG NACHGEZOGEN

`mess-reaktion` stammte noch aus der Zeit vor "Regie links" und
"drei Kacheln" und meldete drei Dinge, die keine Fehler waren --
gemessen gegen den Stand von HEAD: identisch rot. Sie misst ausserdem
seit heute mit FINGER statt Mauszeiger; ohne `hasTouch` greift keine
einzige Regel aus `@media (pointer: coarse)`, und dort stehen alle
44-Pixel-Beruehrziele des Hauses.

OFFEN UND AUFGESCHRIEBEN

Am Handy bleibt bei offener Regie vom Bild nichts (0 px). Der Mangel
besteht seit dem Umbau "Regie links"; drei Versuche, ihn heute zu
loesen, haben Knoepfe verdeckt -- und ein verdeckter Knopf ist
schlimmer als ein kleines Bild. Die Versuche und der richtige Weg
(Tempo-Gruppe hinter einen Knopf, wie "Aa" im Chat) stehen in
reaktion.css. `mess-reaktion` meldet ihn als dritten Ausgang: nicht
als Fehler und nicht als bestanden.

GEPRUEFT
  pruef-reaktion     384 / 0   (vorher 379)
  pruef-spenden      172 / 0
  pruef-buehne        36 / 0
  pruef-css-klassen, -tippziele, -struktur, -handy: ohne Befund
  mess-quer          EXIT 0 -- 28 Stellen, fuenf Groessen, nichts rollt
  mess-reaktion      EXIT 0, 1 benannter offener Punkt
  mess-buehne        EXIT 0
2026-09-29 02:34:09 +02:00
DogFatherGitandClaude Opus 5 813d2e85a1 Die Kuerzel stehen jetzt an dem, was sie bedienen
Filipe, 28.09.2026: "das soll nicht ueberall sein sondern da perfekt
integriert werden."

Er hat recht: Die Legende war ein eigener Absatz am Ende der Regie --
hinter ALLEN Registern. Damit stand sie unter "Ton", unter "Spenden",
unter "Gestaltung", also an sechs Stellen, an denen keines ihrer
Kuerzel etwas tut. Eine Legende, die ueberall steht, gehoert nirgends
hin.

Und eine Legende ist ohnehin der Umweg: Sie zwingt dazu, vom Knopf zu
einer Liste zu schauen und zurueck. Steht die Taste AM Knopf, liest
man sie beim Bedienen -- und lernt sie dabei.

  Leertaste  unter dem runden Play-Knopf   (ein Wort passt nicht
             hinein, und der Knopf soll das Zeichen bleiben, das man
             blind trifft)
  Pfeile     an -10 und +10
  + und -    am Wort "Tempo"
  N          an "Naechstes"
  T          an "Wechseln"
  M          am Mikro-Knopf in der Senderleiste
  1 bis 5    bei der Anordnung im Register "Bild"

Nichts geht verloren, jedes steht dort, wo es wirkt.

UND DIE PRUEFUNG STELLT EINE ANDERE FRAGE ALS VORHER. Sie pruefte, ob
ein Kuerzel IRGENDWO in der Legende steht. Jetzt prueft sie, ob es am
RICHTIGEN Knopf steht -- ein Kuerzel an der falschen Stelle ist
schlimmer als keins. Dazu eine Gegenprobe, dass die alte Legende
wirklich weg ist: Stuende beides da, pflegte beim naechsten Kuerzel
jemand nur eine der beiden Stellen.

Der Chat bleibt unveraendert: Er liegt in `#teil-live` und erscheint
damit nur, wenn die Sendung laeuft -- so, wie Filipe es erwartet hat.

pruef-reaktion 379/0 - pruef-css-klassen ok - pruef-tippziele 11/0

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 20:51:30 +02:00
DogFatherGitandClaude Opus 5 a6d55d939a Die Regie steht links neben dem Video, nicht nur unten
Filipe, 28.09.2026: "anstatt alles nur unten zu haben wieso nicht
rechts und links noch ausnutzen um mehr ueberblick zu haben."

ICH HATTE IHN ZWEIMAL FALSCH VERSTANDEN. Beim ersten Mal baute ich
zwei Spalten INNERHALB der unteren Leiste, beim zweiten Mal Kacheln
darin. Gemeint war der BILDSCHIRM: Die Regie klebte als 272 Pixel
hoher Streifen unten, waehrend darueber das ganze Bild frei war. Man
sah entweder das Video oder die Bedienung.

AUFGEKLAPPT steht sie jetzt LINKS neben dem Video, ueber die volle
Hoehe. Rechts der Chat, unten die Senderleiste und der Transport --
die Anordnung jedes Sendepults: Was man bedient, liegt neben dem, was
man dabei ansieht.

ZUGEKLAPPT bleibt alles wie vorher, eine schmale Leiste unten. Wer
die Regie zumacht, will das Bild gross; eine leere Spalte daneben
waere das Gegenteil.

WAS DAS NEBENBEI LOEST: Die Kacheln hatten unten 272 Pixel fuer sich
-- drei Felder untereinander passten nicht hinein, und die
Transportleiste verdeckte den Rest. In einer Spalte stehen rund 700
Pixel zur Verfuegung. Die Enge war nie eine Frage der Gestaltung,
sondern des Platzes.

DER TRANSPORT VERLAESST DAS REGISTER. Er lag in "Sendung" und war
damit weg, sobald jemand auf "Ton" oder "Spenden" ging. Jetzt ist er
eine eigene Zeile unter dem Saal -- und damit auch bei zugeklappter
Regie da. Der Play-Knopf ist der eine, den man mitten in einer
Sendung blind treffen muss.

`:has(.pult[data-auf="ja"])` UND KEINE KLASSE AM KOERPER: Der Zustand
steht schon am Pult; ein zweiter Merker waere die zweite Antwort auf
dieselbe Frage. Erst ab 1100 px -- darunter bleibt fuer das Video
keine Breite: 26rem Regie plus 23rem Chat sind 784 Pixel. Gerechnet,
nicht geraten.

=======================================================================
DREI BEFUNDE GEGEN DIE EIGENEN PRUEFUNGEN -- UND EINER IST ERNST

1. "GENAU EINE REGEL DARF DIE ZEILEN DES SAALS SETZEN" war die
   Faustregel zum Befund vom Nachmittag, aber nicht die Frage, um
   die es geht. Eine zweite FASSUNG (Handy, aufgeklappte Regie) ist
   richtig und noetig -- sie setzt die Zeilen neu UND verteilt die
   Kinder neu. Der Fehler war eine zweite ANGABE, die die Zeilenzahl
   VERKLEINERT und die Kinder stehen laesst. Geprueft wird jetzt
   genau das: Jede Fassung, deren Gegenstand der Saal IST, muss
   gleich viele Zeilen haben.

2. DER ERNSTE: Dieselbe Pruefung hatte einen BLINDFLECK. Ihr Muster
   liess den Regelrumpf ueber eine oeffnende Klammer hinweg laufen
   (`[^}]*` statt `[^{}]*`) -- damit fiel jede Regel INNERHALB eines
   `@media` heraus. Gemessen: Sie meldete "alle 1 Fassungen",
   obwohl zwei dastanden. Ein Fehler im Medienblock -- und genau
   dort steht die ganze Handyansicht -- waere unbemerkt
   durchgegangen. Die Gegenprobe beweist jetzt, dass eine Fassung
   mit weniger Zeilen wirklich auffaellt.

3. Auch beim Regieplatz zaehlte sie Fassungen statt Wirkung und
   schlug an, sobald eine dritte dazukam. Jetzt prueft sie, ob eine
   Fassung eine KACHEL VERGISST -- dann verschwindet die in genau
   dieser Ansicht, und gemerkt haette es niemand.

pruef-reaktion 374/0 - pruef-css-klassen ok - pruef-tippziele 11/0

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 20:45:34 +02:00
DogFatherGitandClaude Opus 5 6033d1b6f2 Der Regieplatz sind jetzt Kacheln
Filipe, 28.09.2026, zum ersten Versuch: "ich wollte was hoch
professionelles wo rechts links und unten kacheln sind wo alles drin
ist, schoen verteilt. wieso so einen kleinen scheiss."

Er hat recht, und es steht auf seinem Bildschirmfoto:

  DIE LINKE SPALTE WAR LEER. "Was laeuft" zeigte nur einen Titel --
  und wenn nichts laeuft, steht dort nichts. Ein grosses schwarzes
  Loch neben einer vollen Spalte sieht nicht nach Aufteilung aus,
  sondern nach Fehler.

  ES WAREN KEINE KACHELN. Zwei Spalten mit einer Trennlinie sind eine
  Teilung, keine Gliederung. Ohne Rahmen, ohne Kopf, ohne eigenen
  Grund fehlt genau das, was eine Flaeche aufgeraeumt aussehen
  laesst.

  DIE LEISTE WAR EIN KASTEN MIT LUFT. `1fr auto 1fr` schiebt drei
  kleine Knopfgruppen an die Raender eines 1400 Pixel breiten
  Kastens. Leere zwischen Knoepfen liest sich als "hier fehlt noch
  etwas".

WAS JETZT DASTEHT

  LINKS   Eine Kachel "Was laeuft" mit VORSCHAUBILD -- damit ist sie
          immer gefuellt. Ohne Video steht der Satz darin, der sagt,
          was zu tun ist; ein leerer Kasten sagt nur, dass etwas
          fehlt. Sie geht ueber beide Zeilen der rechten Seite,
          sonst franste eine Seite unten aus.
  RECHTS  Zwei Kacheln: "Die Sendung" (Titel, YouTube, Beginn) und
          "Bereitlegen" (zweites Video, Vorschaubild, Zuruecksetzen).
  UNTEN   Die Transportleiste, dichter: jede Gruppe beschriftet
          ("Tempo", "Weiter"), und der Umschalter ist aus der linken
          Kachel hierher gewandert. Er ist ein Handgriff IM Live wie
          "Naechstes" -- und fuellt die rechte Seite, die vorher leer
          war.

Das Vorschaubild nimmt ein eigenes, wenn eines eingetragen ist, sonst
YouTubes. Laedt keines, tritt der Platzhaltersatz ein: Ein Bild, das
404 antwortet, steht genauso im Dokument wie eines, das laedt -- auf
dem Bildschirm ist an seiner Stelle nichts.

UND EIN FUND DER HAUSPRUEFUNG: `kachel` gibt es bereits in start.css
und module.css. Ein Namensstreit ueber Stilblaetter hinweg ist genau
die Sorte Fehler, die spaeter irgendwo ganz anders etwas verschiebt
-- und den niemand dort suchen wuerde. Heisst jetzt `regiekachel`.

pruef-reaktion 369/0 - pruef-css-klassen ok - pruef-tippziele 11/0

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 20:12:21 +02:00
DogFatherGitandClaude Opus 5 99e62eebbe Regieplatz, Bildschirm teilen, Zuruecksetzen und der Preis
Vier Sachen aus Filipes Ansagen vom 28.09.2026.

=======================================================================
1. DER REGIEPLATZ

"kann das nicht bissl aufgeteilt sein auf links und rechts und eine
barre unten in der mitte. kannst du das nicht hoch professionell
machen und hochwertig vom aussehen?"

Die Teilung folgt dem, was WANN gebraucht wird:
  LINKS   Was laeuft. Das liest man.
  RECHTS  Was eingetragen wird. Das tippt man VOR der Sendung.
  UNTEN   Der Transport. Den fasst man WAEHREND der Sendung an.

Vorher stand alles in einem Stapel, und wer im Live an den Play-Knopf
wollte, musste an den Eingabefeldern vorbeiscrollen. Der Play-Knopf
ist jetzt rund und 58 px -- der einzige, den man blind treffen muss,
und die Form unterscheidet ihn schon vor dem Hinsehen.

UND EIN FUND, DEN NUR DIE MESSUNG FAND: Nach dem Umbau lag die Leiste
bei y=986 in einem 900 Pixel hohen Fenster. Die Rechnung ging auf
(links, rechts, Leiste darunter) und das Ergebnis war trotzdem
falsch. Jetzt klebt sie (`position: sticky`) -- unten, wie gewollt,
und immer sichtbar. Ihr Grund musste dafuer dicht werden: eine
halbdurchsichtige Leiste, durch die Text scrollt, ist die Flaeche,
auf der man sich im Live verliest.

=======================================================================
2. DEN BILDSCHIRM TEILEN

"waere es nicht einfach eine bildschirm uebertragung zu installieren
und es zu perfektionieren?"

Ja -- der Weg war schon da: Die Kamerabilder laufen als
Direktverbindung von Mensch zu Mensch. Geteilt wird deshalb AN STELLE
der Kamera; `replaceTrack` tauscht die Bildspur in jeder bestehenden
Leitung aus, ohne dass eine einzige neu ausgehandelt werden muss.
Eine zweite Spur daneben waere eine zweite Verhandlung je Zuschauer,
und jede davon kann scheitern.

Vier Dinge, die sonst schiefgegangen waeren:
  - Wer waehrenddessen dazukommt, bekommt den Bildschirm und nicht
    das Gesicht.
  - Das eigene Fenster zeigt, was die anderen sehen -- sonst waere es
    die eine Anzeige, der man nicht trauen kann.
  - Der Stopp-Knopf des Browsers wird gehoert; sonst bliebe die Seite
    auf "teilt" stehen und sendete ein totes Bild.
  - Der Kameraknopf wird grau, solange geteilt wird. Er haette keine
    Wirkung mehr -- und ein Knopf, der still nichts tut, ist
    schlimmer als keiner.

Ein Bildschirm wird ausserdem NICHT zugeschnitten: `cover` ist fuer
ein Gesicht richtig, bei einem Schreibtisch faellt links und rechts
genau das weg, worum es geht.

UND DIE WAHRHEIT STEHT AN DER BEDIENUNG: Netflix, Disney+ und Prime
bleiben beim Teilen schwarz (Widevine schaltet den Videobereich ab --
auf Discord und Zoom ist es genauso), und einen Film weiterzusenden
waere eine oeffentliche Wiedergabe. Wer das erst erfaehrt, nachdem im
Stream zehn Minuten ein schwarzes Rechteck stand, erfaehrt es zu
spaet.

=======================================================================
3. ALLES ZURUECKSETZEN

"brauch ich auch noch einen button wo ich alles easy zuruecksetzen
kann."

"Alles" heisst: der Schreibtisch, nicht das Gedaechtnis. Geleert
werden Titel, Video, zweites Video, Vorschaubild, Beginn,
Warteschlange, Gaesteliste; Anordnung, Kameragroesse, Ecke, Tempo und
Chatmodus gehen auf Vorgabe.

NICHT ANGETASTET werden Spenden, die Dogen der Leute, der Chatverlauf
und die Massnahmen der Moderation. Eine bezahlte Spende aus den
Buechern zu nehmen waere eine Faelschung; eine stillschweigend
aufgehobene Sperre waere eine Entscheidung, die niemand getroffen
hat. Beides steht nebeneinander im Dialog, BEVOR etwas passiert -- ein
"Bist du sicher?" ohne diese Liste ist keine Frage, sondern eine
Huerde.

Im Live ist der Knopf grau und sagt warum. Und die Spalten stehen
einzeln da statt als "alles ausser": Eine Ausnahmeliste waechst still
mit jeder neuen Spalte mit, und dann loescht das Zuruecksetzen
irgendwann etwas, das es nie loeschen sollte.

=======================================================================
4. DER PREIS BEI DEN DOGEN

"da muss ich auch sehen so viel dogen sind so viel euro. damit ich
auch immer weiss was es ist. aber nur ich. die leute sollen nur sehen
was dogen kosten."

Das ist keine Ruecknahme von "nie Geld", sondern ihre Grenze. Die
Regel war richtig fuer alles, was ANZEIGE ist -- Karte, Stream, Chat,
Punktestand -- und falsch fuer die eine Stelle, an der jemand KAUFT.

GENAU ZWEI AUSNAHMEN, und die Pruefung nennt sie beim Namen statt die
Regel aufzuweichen:
  knoepfe[].cent      fuer alle -- der Preis am Kaufknopf
  kurs_cent_je_doge   nur fuer die Leitung

DIE ERLAUBNIS STECKT IN DEN DATEN UND NICHT IN EINEM `if`. Ein
Zuschauer hat den Kurs nicht und kann deshalb GAR KEINEN Preis
ausrechnen -- auch nicht, wenn eine spaetere Zeile es versuchte. Eine
Abfrage "darf der das sehen?" in der Oberflaeche waere eine Regel, die
man vergessen kann; eine fehlende Zahl ist eine, die man nicht
vergessen kann.

Eine Zahl statt dreissig Einzelumrechnungen: Wer je Zeile einen Cent
mitschickt, hat dreissig Gelegenheiten, eine zu vergessen.

=======================================================================
FUENF BEFUNDE GEGEN DIE EIGENEN PRUEFUNGEN

1. Eine Pruefung fragte "liegt die Leiste unter beiden Spalten?" --
   das tut eine klebende Leiste beim Hochscrollen absichtlich nicht.
   Sie stellte die Frage von vorher. Jetzt zaehlt die Reihenfolge im
   Dokument, die beim Scrollen wie beim Stillstand gilt.
2. Eine Messung klickte auf einen Namen und wartete 400 ms auf die
   UHR -- und verschluckte den Klickfehler still. Derselbe Lauf war
   dreimal gruen und beim vierten rot, ohne Codeaenderung. Jetzt wird
   auf das Merkmal gewartet, bis zu dreimal, und die Zahl der
   Anlaeufe steht im Protokoll.
3. Eine Pruefung loeschte erst selbst den Chat (eine Sendung zu
   beenden tut das mit Absicht) und fragte dann, ob er noch da ist.
4. "Die Dogen sind unberuehrt (0 Staende)" -- null bleibt auch dann
   null, wenn das Zuruecksetzen sie mitnaehme. Jetzt steht vorher
   eine echte Spende da, und die Zahl gehoert in die BEDINGUNG.
5. `knopf-still--haupt` stand seit Wochen im HTML und war in KEINEM
   Stilblatt definiert -- eine Klasse, die aussieht, als sei etwas
   hervorgehoben, und auf dem Bildschirm ist es das nicht. Gefunden
   beim Nachsehen, ob es sie gibt, bevor ich sie ein zweites Mal
   benutze.

Und eine neue Pruefung, die es vorher nicht gab: ALLE 118 Kennungen,
die das Programm mit `$('...')` anspricht, werden gegen das HTML
gehalten. Nach einem Umbau, der die halbe Tafel neu sortiert, waere
eine verlorene Kennung KEIN Fehler beim Laden -- `$()` gibt still
`null` zurueck, und der Fehler erscheint erst, wenn jemand mitten in
der Sendung den Knopf drueckt.

pruef-reaktion 365/0 (vorher 321) - pruef-spenden 172/0 (vorher 167) -
pruef-buehne 36/0 - pruef-css-klassen ok - pruef-tippziele 11/0 -
mess-reaktion und mess-buehne ohne Beanstandung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 20:03:38 +02:00
DogFatherGitandClaude Opus 5 96bdd79a91 Die Dogen-Muenzen: drei Saetze zu je drei Stufen
Filipe, 28.09.2026: "ich werde die drei varianten schicken fuer
niedrige spenden. mittlere und hohe spenden. alle drei varianten will
ich drauf so dass ich sie aussuchen kann wie ich will. aber pass sie
sofort diesen 3 kategorien an."

DIE ZUORDNUNG IST GELESEN, NICHT GERATEN

In allen drei Saetzen geht es nacktes Metall -> Steinkranz ->
Vollbesatz; Filipes Dateinummern (01/02/03) und die Uhrzeiten seiner
Entwuerfe laufen genau mit. Klassik: Palladium, Gold mit Saphir,
Diamant. Amethyst: Stahl, Rosegold, Vollbesatz. Neon: Chrom auf
Schwarz, Steinkranz, Vollbesatz.

AUS 25 MB WURDEN 443 KB

Je Bild 320 px WebP statt 1254 px PNG, Faktor 58. Die Zahl ist
gemessen: Groesster Fall in der Seite 110 px (Karte, hoechste Stufe),
auf der OBS-Tafel 189 px, bei doppelter Bildschirmdichte rund 220.
Drei Megabyte fuer ein 24-Pixel-Zeichen waeren ein Ladebalken mitten
in der Sendung -- wer auf dem Handy zusieht, bekaeme die Karte, wenn
sie schon wieder weg ist. Die Originale liegen neben den
Datenbanksicherungen, nicht im Repo: 26 MB, die bei jedem Klon
mitkaemen und die niemand ausliefert.

WELCHE MUENZE WANN -- ABGELEITET STATT GEPFLEGT

"Niedrig, mittel, hoch" gibt es im Haus schon: die Stufenleiter. Zwei
eigene Grenzen daneben waeren eine zweite Antwort auf dieselbe Frage,
und spaetestens beim Verschieben einer Stufe zeigte die Muenze etwas
anderes an als der Name auf der Karte. Die Leiter wird deshalb in
DRITTEL geteilt -- bei den drei Werksstufen genau eine je Muenze, bei
sechs zwei je Muenze, bei einer einzigen ueberall die mittlere.

DIE MUENZE STEHT AUCH GROSS AUF DER KARTE

Sonst haette Filipe neun Bilder fuer ein 24-Pixel-Zeichen gezeichnet.
"dogen" ist dafuer eine neue Vorlage neben Herz, Welle und Krone --
und die drei Werksstufen bekommen sie EINMAL zugeteilt, nur wo noch
die Werksvorlage steht und kein eigenes Bild hochgeladen ist. Wer
danach ein Herz zurueckstellt, findet es morgen nicht wieder als
Muenze vor: Eine Einstellung, die sich gegen den Benutzer durchsetzt,
wird abgeschafft.

Und steht sie gross da, nimmt das Stilblatt die kleine neben der Zahl
weg -- dieselbe Muenze in zwei Groessen auf einer Karte sieht aus wie
ein Versehen.

AM BILDSCHIRMFOTO NACHGEBESSERT

Bei 0,92em blieb an den Spendenknoepfen ein 13,5-Pixel-Fleck uebrig;
bei einem flachen Symbol reicht das, bei einer Muenze mit Pfote,
Schriftzug und Steinen nicht. Jetzt 1,05em ueberall und 1,6em auf den
Knoepfen. In der Auswahl sahen "Amethyst" und "Neon" bei 40 px
praktisch gleich aus -- und genau sie auseinanderzuhalten ist der
Zweck dieser Liste. Jetzt 56/64/72 px. Und was gewaehlt ist, steht
als WORT da: Neben neun glaenzenden Muenzen geht ein ruhiger Rahmen
unter, und wer Farben schlecht unterscheidet, sieht ihn gar nicht.

EIN SATZWECHSEL ERREICHT ALLE

Die Karten trugen ihren Satz immer selbst mit und waren richtig. Die
Muenzen an den Spendenknoepfen und beim eigenen Stand aber nicht --
die holt eine Seite nur bei einer Spende neu. Eine Einstellung, die
nur dort ankommt, wo sie gemacht wurde, ist keine Einstellung des
Hauses.

ZWEI BEFUNDE GEGEN DIE PRUEFUNG SELBST

1. Sie verlangte "erste Grenze ECHT kleiner als zweite". Bei genau
   zwei Stufen fallen beide absichtlich zusammen -- sie meldete einen
   Fehler, wo das Verhalten richtig ist.
2. Sie prueft die Muenzverteilung jetzt auf einer FRISCHEN Datenbank.
   Vorher lief sie auf der, die ein frueherer Abschnitt umgebaut
   hatte: Eine Pruefung, die den eigenen Kollateralschaden misst,
   misst sich selbst. Dazu eine Gegenprobe, dass eine selbst
   gewaehlte Vorlage NICHT ueberschrieben wird.

UND DREI AN DER MESSUNG

Sie suchte noch die gezeichnete Muenze (svg), wo jetzt ein Bild
liegt. Umgestellt nicht auf "ist ein img da", sondern auf
`naturalWidth > 0` -- ob es etwas ZEIGT. Ein img, das 404 antwortet,
steht genauso im Dokument wie eines, das laedt; auf dem Bildschirm
ist an seiner Stelle nichts. Und beim Satzwechsel las sie die vorige
Karte, die noch acht Sekunden stand: Die Karten laufen in einer
Schlange, also wird auf das Merkmal gewartet, das nur die neue hat.

pruef-spenden 167/0 (vorher 136) - pruef-reaktion 321/0 -
pruef-css-klassen ok - pruef-tippziele 11/0 -
mess-reaktion und mess-buehne ohne Beanstandung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 19:36:05 +02:00
DogFatherGitandClaude Opus 5 c00c209ac9 Dogen statt Geld -- und die Texte schreiben die Leute selbst
Filipe, 28.09.2026:
  "der text der dazu erscheint sollen die leute selber schreiben
   koennen. soll auf nicht zu viel aber auch nicht zu wenig
   schreiben koennen. aber es sollen personalisierte texte sein von
   den leuten selbst."
  "mann soll nie die summe sehen sondern die dogen auch mit dem
   symbol ... es soll auch nie geld da stehen sondern Dogen."
  "ich will dass die den leuten auch als punkte hinzugefuegt werden."

DER TEXT KOMMT VON DEM, DER GIBT

Bisher konnte ihn nur die Leitung tippen; beim Melden schickte der
Zuschauer gar nichts mit. Jetzt stehen nach dem Tippen auf einen
Betrag zwei Felder da: Name auf der Karte (mit dem eigenen Namen
vorausgefuellt) und der eigene Text, bis 140 Zeichen.

140 ist gemessen, nicht geraten: Die Karte steht je nach Stufe sechs
bis elf Sekunden. Bei 140 Zeichen sind das zwei bis drei Zeilen -- die
fasst man im Vorbeischauen. Bei 200 wird die Schrift auf einer kleinen
Karte so eng, dass niemand mehr hinsieht, und ein Gruss, den keiner
liest, ist schlechter als ein kurzer. Der Zaehler erscheint erst ab
30 uebrigen Zeichen; einer, der von Anfang an mitlaeuft, macht aus
einem Gruss eine Aufgabe.

UND EINE SCHRANKE DAVOR. Der Text kommt von einem Fremden und steht
gleich im Livestream. Er laeuft ohnehin durch die Bestaetigung -- neu
ist, dass die Leitung ihn dort AENDERN kann. Ohne das bliebe nur "ganz
ablehnen", und dann faellt wegen eines Wortes eine echte Spende unter
den Tisch.

NIE WIEDER GELD AUF DEM BILDSCHIRM

Ein Doge sind zehn Euro (Filipes Angabe "1 euro sind 0,10 dogen",
rueckgefragt und bestaetigt -- zwischen den beiden Lesarten liegt der
Faktor 100). Gerechnet wird in Tausendsteln, nie in Kommazahlen.

Das Entscheidende ist nicht die Beschriftung: DER BROWSER BEKOMMT
KEINEN EURO-BETRAG MEHR, auch nicht verborgen im JSON. Der Kurs steht
einmal auf dem Server. Was nicht gesendet wird, kann an keiner Stelle
versehentlich erscheinen, und niemand muss daran denken. Gemessen:
38 Felder im Stand, kein Geldfeld, kein Eurozeichen, auch nicht auf
der Buehnentafel. Der einzige Ort, an dem noch ein Euro entsteht, ist
die PayPal-Adresse -- weil PayPal ihn braucht.

DIE PUNKTE: EIN BUCH, KEIN ZAEHLER

Ein Feld `dogen` an der Person waere kuerzer gewesen -- und die zweite
Antwort auf dieselbe Frage. Der Stand IST die Summe der Buchungen, und
eine Summe kann sich nicht von ihren Posten entfernen. Weil Filipe
spaeter etwas daran haengen will ("spezielle sachen ... wo mit diesen
dogpunkten zu tun hat"), steht neben jeder Zeile ein Grund und ein
Datum: Ein Zaehler, der kleiner wird, laesst keine Frage mehr
beantworten.

Dass nie doppelt gutgeschrieben wird, entscheidet ein eindeutiger
Index in der Datenbank und keine Bedingung im Programm -- "nochmal
zeigen" und ein wiederholter Strom koennen es damit gar nicht
ausloesen.

Ohne Person keine Punkte: Eine von Hand eingetragene Spende hat oft
nur einen Vornamen auf dem Handy. Daraus eine Person zu RATEN waere
schlimmer als keine Gutschrift -- deshalb waehlt die Leitung sie aus,
und tut sie es nicht, laeuft die Karte trotzdem.

DAS SYMBOL IST VORBEREITET

Filipe: "die symbole schick ich dir spaeter." Es wird im Regiepult
hochgeladen, ohne Deploy -- bis dahin steht eine gezeichnete Muenze
da. Es liegt bei den Stufenbildern, weil das der einzige Weg ist, der
Bilder OHNE Anmeldung ausliefert; sonst fehlte es ausgerechnet im
Stream.

=======================================================================
UND EINE REPARATUR AN DEM, WAS HEUTE MITTAG LIVE GING

Seit 3fedeea7 war der Buehnenmodus (reaktion.html?nur=buehne) kaputt:
Leinwand null Pixel hoch, im Stream ein schwarzes Bild. Das ist die
Fensterquelle fuer den Fall, dass Gaeste im Bild sind.

Ursache: Teil A hat die Zeilen des Saals festgenagelt (#teil-live auf
Zeile 2). Im Buehnenmodus stand aber seit jeher eine eigene Regel, die
dem Saal nur EINE Zeile gibt -- das Video landete in einer impliziten
Zeile, die sich nach ihrem Inhalt bemisst, waehrend die Leinwand ihre
Hoehe aus der Zeile nimmt. Beide warten aufeinander, heraus kommt
null.

Das ist heute der VIERTE Fall von "zwei Regeln fuer dieselbe Frage"
(Tafeln, Saalzeilen, Kamerabreite, Buehnenmodus). Die Loesung ist
jedes Mal dieselbe: nicht die zweite Regel richtig stellen, sondern
sie abschaffen.

DREI DINGE, DIE DARAN LEHRREICH SIND:

1. MEINE EIGENE PRUEFUNG WAR GRUEN UND WERTLOS. Ich hatte nach Teil A
   extra geprueft: "genau eine Regel bestimmt die Zeilen des Saals".
   Sie verlangte, dass die Zeile mit `.saal` BEGINNT -- und hat
   `body[data-nur="buehne"] .saal { … }` deshalb nie gesehen. Jetzt
   zaehlt sie jede Regel, in deren Auswahl `.saal` vorkommt, mit einer
   Gegenprobe, die eine eingeschobene zweite wirklich findet.

2. EINE SEITE MIT ZWEI ANSICHTEN BRAUCHT BEIDE MESSUNGEN. Nach Teil A
   liefen `pruef-buehne` (Schnittstelle) und `mess-reaktion` (normale
   Ansicht). `mess-buehne` -- die einzige, die diese Ansicht
   ueberhaupt oeffnet -- lief nicht.

3. EIN PYTHON-SKRIPT, DAS ERST AM ENDE SCHREIBT, MELDET "ok" FUER
   AENDERUNGEN, DIE NIE ANKOMMEN. Eine fehlgeschlagene Zusicherung hat
   einen ganzen Stapel verworfen, obwohl die erste Aenderung schon
   bestaetigt war. Die Messung meldete daraufhin "Auf der Karte steht:
   undefined" -- kein Fehler im Code, sondern eine Zeile, die nie
   geschrieben wurde. Gefunden hat es die Messung, nicht das Lesen.

pruef-spenden 136/0 (vorher 95) - pruef-reaktion 321/0 -
pruef-buehne 36/0 - pruef-css-klassen ok - pruef-tippziele 11/0 -
mess-reaktion und mess-buehne ohne Beanstandung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 18:54:02 +02:00
DogFatherGitandClaude Opus 5 3fedeea716 Kamerafenster: Groesse und Ecke lassen sich stellen
Filipe: "und danach perfektionierst du auch die verschiedenen
groessen von kamera und so, perfektionier das alles bitte."

Sechs Groessen (0,6x bis 2x) und vier Ecken, beide an der SENDUNG
und nicht am Browser: Was Filipe einstellt, sehen alle. Waere es
eine Einstellung je Geraet, redete er ueber ein Bild, das bei den
Zusehenden anders aussieht -- und im Stream stuende ein drittes.

Anordnung, Groesse und Ecke gehen jetzt EINEN Weg (/layout), weil
sie zusammen eine einzige Frage beantworten: Wie sieht das Bild aus?
Drei getrennte Aufrufe waeren drei Rundrufe, und dazwischen saehen
die Zusehenden eine Mischung -- neue Anordnung, alte Ecke. Eine
falsche Angabe laesst auch das Gute stehen, statt halb umzustellen.

Die Ecke gibt es, weil unten rechts bei TikTok die Knopfreihe liegt,
bei YouTube die Fortschrittsleiste, und Untertitel fast immer unten
stehen. Eine feste Ecke ist eine, die bei jeder zweiten Plattform
im Weg ist.

Die OBS-Videoquelle kennt jetzt die Anordnung und blendet sich bei
"Nur Kamera" aus -- sonst laege im Stream ein stehengebliebenes
YouTube-Bild unter den Kameras, und Filipe saehe es nicht, weil er
auf seine Szene schaut und nicht auf die Quelle.

VIER BEFUNDE, ALLE VON DEN PRUEFUNGEN UND KEINER VOM AUGE:

1. Die Knoepfe fuer Groesse und Ecke gab es gar nicht. HTML und
   Stilblatt waren da, das Programm nicht. Gemessen: "0 von 0
   Eckknoepfen gesperrt", und der Hinweistext stand noch wortgleich
   wie im HTML -- zwei leere Kaesten, die aussahen, als sei alles
   in Ordnung.

2. Bei "Nur Kamera" war der Stapel 493 px breit statt 1072. Der
   neue Deckel max-width: 46% -- richtig gegen ein zu grosses
   Fenster bei "Video gross" -- galt still auch dort, wo die
   Kameras die ganze Flaeche fuellen sollen.

3. Am Handy haette die Groesseneinstellung ueberhaupt nichts
   bewirkt: Dort stand weiterhin width: clamp(88px, 26vw, 140px)
   am Fenster selbst. Das ist die dritte Wiederholung derselben
   Sache an einem Tag (die Saalzeilen, die Tafeln, jetzt die
   Kamerabreite): Zwei Regeln fuer dieselbe Frage sind die
   Garantie, dass eine davon irgendwo falsch gewinnt. Breite und
   Rand gehen jetzt ueber --kam-grund/--kam-rand, --kam wird an
   genau EINER Stelle gerechnet, und eine Pruefung zaehlt das nach.

4. Die Pruefung schlug auf ihren EIGENEN Kommentar an, der die
   entfernte Zeile zitiert. Ein Werkzeug, das bei jedem Lauf
   meckert, wird nach dem zweiten Mal weggeklickt -- samt dem
   echten Befund darin. Gezaehlt werden jetzt Regeln, nicht Prosa.

Gemessen beim Zuschauer und nicht in der Datenbank: 1x -> 208 px,
2x -> 416 px, alle Fenster innerhalb der Leinwand, und jede der
vier Ecken am Abstand zu den Kanten nachgewiesen statt an ihrem
eigenen Namen. Ein Knopf, der gerade nichts bewirken kann, ist
grau, und der Satz darunter sagt warum.

pruef-reaktion 320/0 - pruef-buehne 36/0 - pruef-css-klassen ok -
pruef-tippziele 11/0 - mess-reaktion ohne Beanstandung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 14:35:02 +02:00
DogFatherGitandClaude Opus 5 2b02d88767 Die Regieleiste steht jetzt über dem Saal
Filipe: „ich hätte das gerne oben also nicht in dieser kachel sondern
über der kachel wo die videos laufen werden und das soll extrem
perfektioniert werden."

WARUM DAS MEHR IST ALS EIN UMHÄNGEN
Die Register standen INNERHALB des einklappbaren Teils. Wer die Regie
zuklappte, um mehr Bild zu haben, hatte sie nicht mehr — und genau
dann braucht man sie. Oben ändert sich ihre Aufgabe: aus dem
Inhaltsverzeichnis einer Schublade wird eine Steuerleiste über der
Sendung. Daraus folgen vier Dinge, die vorher nicht nötig waren:

1. EIN REGISTER MACHT DIE ZUGEKLAPPTE REGIE AUF. Vorher wechselte nur
   der Reiter, die Tafel lag im zugeklappten Teil — sichtbar passierte
   nichts. Nur auf, nie zu: Zugemacht wird mit dem Griff. Ein Knopf,
   der beim zweiten Druck das Gegenteil tut, versteckt irgendwann
   etwas, das jemand gerade liest.

2. SIE BRICHT NICHT UM, SIE ROLLT. Sieben Register in zwei Zeilen
   kosten am oberen Rand rund 90 px — und zwar dem Video. Gemessen:
   eine Zeile auf 1440 px wie auf 390 px, dort mit Einrasten und
   weichen Rändern als Auskunft, dass es weitergeht.

3. SIE LÖST EIN, WAS `role="tablist"` VERSPRICHT. Pfeiltasten wandern,
   Pos1/Ende springen, genau ein Register liegt in der Tabulatorfolge.
   Steht die Rolle im Markup und tut das Programm es nicht, sagt ein
   Vorleser etwas an, was nicht stimmt — schlimmer als keine Rolle.
   Gemessen wird das gedrückt, nicht gelesen: fünf Tastendrücke, und
   Fokus, Auswahl und offene Tafel müssen jedes Mal dasselbe sagen.

4. EINE GLEITENDE MARKE zeigt, wo man steht — Glas mit Lichtkante,
   darunter ein roter Streifen zur offenen Tafel. Ist die Regie
   zugeklappt, wird er leise: Der Streifen führt dann nirgendwohin.
   Dazu ein Schild „Regie" links (am Handy weg), damit die sieben
   Wörter am oberen Rand nicht wie eine zweite Navigation aussehen.

DER FEHLER, DER MICH ZWEI ANLÄUFE GEKOSTET HAT
Die Leiste war im Browser 1 px hoch, ihre Register standen 55 px hoch
daneben und ragten über das Video. Ursache: 900 Zeilen weiter unten
stand eine ZWEITE `grid-template-rows` für `.saal` (aus der Zeit, als
der Saal zwei Zeilen hatte). Sie stand später und gewann — die Leiste
landete in der `1fr`-Zeile und bekam null Pixel.

Und fast hätte ich das Falsche repariert: Beim Durchprobieren habe ich
`style.gridTemplateRows` gesetzt — ein Stilattribut schlägt jede Regel
im Stilblatt. Damit „funktionierten" `auto` und `min-content` gleich
gut, weil beide die zweite Regel aushebelten, und es sah nach einem
Unterschied zwischen den beiden aus. Erst nach dem Entfernen der
Doppelung hat die Gegenprobe gezeigt: `auto` tut es genauso. Eine
Probe, die mehr ändert als das, wonach man sucht, beantwortet eine
andere Frage. Es gibt jetzt eine Prüfung, dass genau EINE Regel die
Zeilen des Saals bestimmt.

ZWEI WEITERE FUNDE AUS DER MESSUNG
Am Handy blieben bei offener Regie 131 px für das Bild — ein Streifen.
Mein erster Versuch (62dvh → 52dvh) machte es auf 75 px SCHLECHTER:
Die Ursache lag woanders, die Leiste des Pults bricht dort in vier
Zeilen um und ist rund 200 px hoch, wovon ein Anteil der Fensterhöhe
nichts wissen kann. Mit `min(52dvh, 19rem)` sind es 210 px.

Und die Spendenkarte wurde als „steht aus dem Bild heraus" gemeldet:
413 statt 430 px, links −1. 413 ist genau das 0,96-fache — die Messung
hatte die Karte im Ausblenden erwischt. Sie wartet jetzt auf
`data-da="ja"` und misst nur, was ganz da ist.

GEPRUEFT
pruef-reaktion 280 (vorher 260), 0 Fehler — Abschnitt 18 neu.
mess-reaktion: Rückgabewert 0, kein ACHTUNG; misst die Leiste jetzt
auf 1440 und auf 390 px, samt Tastatur und Aufklappen.
Dazu grün: handy, buehne, struktur, css-klassen, tippziele.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 14:13:07 +02:00
DogFatherGitandClaude Opus 5 d2f02ba84e Spenden: Stufen gestaltbar, Bild hochladen, Größe je Stufe
Filipes Wunsch: „mach paar fertige und so dass ich auch hochladen
kann. auch so dass ich das anders gestalten kann oder die größe
verändern kann. ... auch spezielle sachen bei speziellen spenden."

WAS ES SCHON GAB, WAS FEHLTE
Drei Stufen ab Werk, fünf gezeichnete Zeichen, Farbe und Dauer je
Stufe — und sogar schon ein Feld für ein eigenes Bild. Es fehlte der
Weg, das alles zu ÄNDERN: Um eine Stufe umzubenennen, hätte jemand in
die Datenbank greifen müssen.

DIE GRÖSSE IST DAS „SPEZIELLE BEI SPEZIELLEN SPENDEN"
Je Stufe, nicht einmal für alle: Eine Rudel-Legende darf größer
dastehen als ein Danke. Eine einzige Größe für alle wäre wieder eine
Preisliste. Umgesetzt als EINE Schriftgröße, alles darin in `em` —
nicht `transform: scale()`, denn die Karte kommt schon mit
`translateX()` herein, und zwei `transform` an derselben Stelle
schließen einander aus; außerdem wird Text beim Skalieren matschig.
Nur nach oben (1 bis 2,5), und das ist eine ehrliche Grenze: Das
kleinste Wort auf der Karte steht bei 11,52 px, die Hausgrenze ist
11,5. Ein Faktor von 0,8 machte daraus 9,2 px. Kleiner geht an der
richtigen Stelle — die OBS-Tafel hat ihren eigenen Regler in der
Adresse, dort ist es eine Videoeinblendung und kein Text zum Lesen.

AUSPROBIEREN, OHNE EINE SPENDE ANZULEGEN
Der naheliegende Weg wäre gewesen: eine Spende eintragen und danach
löschen. Das ist verboten — in ein laufendes System kommen keine
Testdaten, und „gelöscht" heißt bei Geld nicht „war nie da". Die
Probe schreibt deshalb NICHTS und geht nur an den, der drückt; eine
Probe im ganzen Saal wäre eine Spende, die es nicht gab.

DREI FEHLER, DIE DIE PRÜFUNG GEFUNDEN HAT

1. `protokolliere()` wurde an 17 Stellen falsch herum gerufen —
   `(personId, aktion, detail, ip)` statt `(aktion, {…})`. JavaScript
   beschwert sich nicht: Das zweite Argument war ein Text, und einen
   Text zu zerlegen ergibt lauter `undefined`. Auf dem echten Server
   nachgemessen: 39 Protokollzeilen mit Aktionen wie „16.0", alle
   ohne Person, ohne Detail, ohne IP. Betroffen waren Material,
   Hilfe, Bühne, Reaction und Spenden — also jede Änderung an
   Dogi-Media und jede Maßnahme im Live-Chat, ausgerechnet das,
   wofür es ein Protokoll gibt. Alle 17 berichtigt, und
   pruef-struktur wacht jetzt darüber (mit Gegenprobe).

2. Beim Speichern der Leiter bekam jede Stufe eine NEUE Kennung
   (DELETE + INSERT). Ein Bild-Hochladen gegen die eben noch gültige
   Kennung antwortete mit 404 — im Alltag trifft das jeden, der einen
   zweiten Bildschirm offen hat. Jetzt werden vorhandene Zeilen
   geändert statt ersetzt; das Bild bleibt von selbst daran hängen.

3. `ab_cent` ist eindeutig. Zwei Stufen ihre Beträge tauschen zu
   lassen scheiterte mit „UNIQUE constraint failed", obwohl das
   Ergebnis in Ordnung gewesen wäre: Beim Umschreiben stößt die
   Leiter auf sich selbst. Jetzt in drei Schritten — löschen,
   geparkte Zwischenwerte, endgültige Werte —, und das ist nach
   außen nie sichtbar.

UND DREI, DIE IN MEINER MESSUNG STECKTEN
Die Messung hat eine noch laufende Karte aus dem vorigen Abschnitt
erwischt und daraus drei Fehler gemeldet, die keine waren —
darunter „die Probe läuft im ganzen Saal". Sie zählte außerdem die
versteckten Dateifelder als zu kleine Tippziele. Jetzt räumt sie
vorher auf, wartet auf die Karte MIT DER ERWARTETEN GRÖSSE (die
Karten laufen in einer Schlange — einen Knoten zu entfernen beendet
sie nicht) und lässt die Einblendung zur Ruhe kommen, bevor sie misst.
Ein Bildschirmfoto aus der Einblendphase sah aus, als stünde die
Karte links heraus; nachgemessen: links 18 px, ganz im Bild.

GEMESSEN, NICHT ANGENOMMEN
Karte bei Größe 1: Schrift 16 px, Betrag 25,92 px. Bei Größe 2:
32 px und 51,84 px — Faktor exakt 2,00. Hätte eine einzige Regel noch
in `rem` gestanden, wäre die Karte ungleichmäßig gewachsen, und auf
einem Bild sieht beides nur „größer" aus.

AUCH DAS BILD IST GEPRÜFT
Es liegt am Bühnen-Router und nicht am Spenden-Router: Die
Spendentafel in OBS hat keine Anmeldung, und ein 401 als JSON in
einem `<img>` ergibt ein kaputtes Bild ohne jeden Hinweis. Ohne
Schlüssel, aber mit 128 Bit zufälligem Dateinamen — dieselbe
Größenordnung wie der Bühnenschlüssel, und es ist ein Zierbild, das
ohnehin im Stream steht. Kein Ausbruch aus dem Ordner (vier Wege
geprüft, gemessen wird die Wirkung und nicht der Statuscode).

NACHGETRAGEN AUS BLOCK 4
`reaktion_meldungen` fehlte im Löschkonzept — eine bestehende Prüfung
hat es gefunden. 30 Tage nach dem Erledigen; meistens sind sie
ohnehin früher weg, weil der Live-Chat beim Beenden gelöscht wird und
die Meldungen daran hängen. Wer meldet, muss sich darauf verlassen
können, dass daraus keine dauerhafte Liste wird.

GEPRUEFT
pruef-spenden: 95 Prüfungen (vorher 46), 0 Fehler.
pruef-reaktion 260, pruef-buehne 36, pruef-aufbewahrung 45,
pruef-struktur, pruef-meldungen, pruef-css-klassen, pruef-tippziele,
pruef-deutsche-texte, pruef-auskunft alle grün.
mess-reaktion und mess-buehne: Rückgabewert 0, kein ACHTUNG.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 10:14:26 +02:00
DogFatherGitandClaude Opus 5 74c74a08ab Reaction: Geschwindigkeit, zwei neue Anordnungen, zweites Video
Filipes Wunsch: „auch bei den videos sachen wie pause
geschwindigkeit. meine kamera größer machen video kleiner. nur mein
bild, nur das video, möglichkeit zwischen allem zu wechseln so wie ich
will, auch gerne so dass ich vielleicht noch ein zweites nebenbei
vorbereiten kann so dass ich hin und her switchen kann."

GESCHWINDIGKEIT
Sechs Stufen von 0,5× bis 2× (0,25× fehlt mit Absicht — bei einem
Viertel klingt Sprache wie ein defektes Band). Sie gilt für ALLE: Wer
sie nur bei sich umstellte, redete über eine Stelle, die die anderen
noch nicht gesehen haben. Die OBS-Bühne zieht sie mit nach — ohne das
liefe der Stream nach fünf Minuten auf 1,5× zweieinhalb Minuten hinter
dem Saal her.
Und der Zuschauer SIEHT sie: ein kleines Schild „1,5×
Geschwindigkeit" auf der Leinwand, nur wenn es nicht 1× ist. Ohne das
hält jeder Zweite seine Leitung für kaputt.
Zwei Fallen, die dabei zugemacht wurden: Der Fünf-Sekunden-Takt des
Hosts schreibt das Tempo NICHT mit (sonst drehte ein Takt mit altem
Wert die Einstellung zurück), und nach jedem Videowechsel wird es neu
gesetzt (YouTube stellt beim Laden auf 1 zurück). Gesetzt wird nur,
was `getAvailablePlaybackRates()` hergibt — einen unmöglichen Wert
ignoriert der Player schweigend, und dann stünde der Knopf auf 1,5
und es liefe 1,0.

ZWEI ANORDNUNGEN MEHR
„Nur Video" und „Nur Kamera" sind kein weiteres Größenverhältnis,
sondern ein Weglassen — und genau das braucht OBS: Dort liegt die
Kamera ohnehin als eigene Quelle, die Bühnenseite soll sie nicht
doppelt zeigen. `display: none` und nicht `opacity: 0`: ein
unsichtbarer YouTube-Rahmen spielt weiter und hält den Ton.
Tasten 1–5, wie vorher 1–3.

DAS ZWEITE VIDEO — und warum es nicht die Warteschlange ist
Die Schlange ist eine Reihenfolge: eins nach dem anderen, das
Gespielte ist weg. Filipe will etwas anderes — zwei Videos
NEBENEINANDER, hin und her, und jedes merkt sich seine Stelle. Mit
der Schlange nachgebaut wäre es eine Schlange, aus der man nie wieder
herauskommt. Der Umschalter steht nur da, wenn es etwas zum Umschalten
GIBT; ein Knopf, der „kein zweites Video" antwortet, ist im Live eine
Falle. Getauscht wird in EINEM Schreibvorgang (BEGIN IMMEDIATE) —
ein Abbruch dazwischen hätte dasselbe Video auf beiden Seiten und die
gemerkte Stelle des anderen verloren. Und die Stelle kommt vom Host
und nicht aus der Datenbank: dort steht der Stand vom letzten Takt,
bis zu fünf Sekunden alt.
Was daneben bereitliegt, sieht nur, wer sendet — es ist die Rückhand,
und die verrät man nicht vorher (`zweit` ist aus `oeffentlich()`
ausgenommen).

DER FEHLER, DEN DIE MESSUNG GEFUNDEN HAT
Bei „Nur Kamera" war der Kamerabehälter 2175 px breit in einer
1072 px breiten Leinwand — von zwei Gästen stand nur einer im Bild,
der zweite lag hinter `overflow: hidden`. Auf dem Bildschirmfoto sah
das aus wie eine Anordnung für einen, und niemand hätte gefragt, wo
der zweite geblieben ist.
Zwei Ursachen: Die Leinwand ist ein Raster mit einer Spalte nach
Inhaltsbreite — ein zu breites Kind im Fluss zieht die Spalte mit, und
`width: 100%` bezog sich danach auf die gewachsene Spalte. Die
Begrenzung wuchs also mit dem, was sie begrenzen sollte; `max-width:
calc(50% - 8px)` rechnete gegen denselben Wert und war wirkungslos.
Jetzt `position: absolute; inset: 0` (kann das Raster nicht mehr
aufziehen) und `flex: 1 1 0; min-width: 0` an den Fenstern — eine
Regel, die nicht rechnet, kann sich nicht verrechnen.
Die Messung prüft seitdem bei JEDER Anordnung, ob alle Kamerafenster
innerhalb der Leinwand liegen.

GEMESSEN, NICHT ANGENOMMEN
Dass in der Datenbank 1.5 steht, sagt nichts darüber, ob das Video
schneller läuft. Die Messung liest deshalb die Uhr der Regie zweimal
ab und rechnet nach: 1× → 1,00 Sekunden je Sekunde, 2× → 2,00.
Der Umschalter ebenso: hin, zurück, und die Uhr steht wieder bei 79 s
statt bei 0.

GEPRUEFT
pruef-reaktion: 260 Prüfungen (vorher 216), 0 Fehler.
pruef-buehne 36, pruef-struktur, pruef-meldungen, pruef-tippziele,
pruef-css-klassen alle grün. mess-reaktion: Rückgabewert 0, kein
einziges ACHTUNG.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 09:48:26 +02:00
DogFatherGitandClaude Opus 5 a02cf5c026 Reaction: Rollen sichtbar, Moderation im Live-Chat
Filipes Wunsch: „die modis, linke hand und rechte hand soll im chat
extra aussehen und auch sachen wie stummschaltungen, sperrungen,
meldunge und alles mögliche im chat machen können falls sich jemand
nicht benimmt."

WER SPRICHT
Jeder Beitrag aus dem Team trägt jetzt ein Abzeichen: „Dogi", „Team"
(beide Hände) und „Modi". Ein WORT und nicht nur eine Farbe — rund
acht Prozent der Männer unterscheiden Rot und Grün schlecht, und hier
hängt an der Unterscheidung etwas. Gedeckte Töne auf schwacher Fläche,
11,52 px (die Hausgrenze ist 11,5), weil daneben ein Video läuft.
Die Wörter sagen den RANG nicht: Filipe steht nie über seinem Team.
Eine Prüfung schlägt an, wenn dort „Chef", „Boss" oder „Leitung"
stünde.

WAS DIE MODERATION KANN
Am Namen öffnet sich ein Menü mit vier Wegen: Beitrag wegnehmen,
10 Minuten stumm, aus der Sendung — und der Verweis in den Treff, wo
längere Maßnahmen hingehören (mit Begründungspflicht und Frist in
Tagen). Die Reaction baut kein zweites Sperrsystem daneben: Wer im
Treff eine Pause hat, schreibt hier auch nicht. Neu ist nur, was der
Treff nicht hat — MINUTEN. Höchstens eine Stunde; wer länger etwas
braucht, nimmt den Treff.
Nicht gegen das Team: Wer einen Modi stummschalten könnte, hätte einen
Weg, die Moderation selbst auszuschalten, mitten in der Sendung.

MELDEN DARF JEDER
Die Moderation sieht nicht alles, wer mitliest schon. Zweimal melden
zählt einmal (sonst füllt einer allein die Liste). Bei der Moderation
erscheint im Kopf der Schiene „1 Meldung" — nur wenn etwas offen ist;
eine Zahl, die immer dasteht und meistens null ist, wird nach drei
Tagen nicht mehr gelesen.

VIER FEHLER, DIE DABEI AUFGEFALLEN SIND

1. `oeffentlich()` nahm `meldungen` und `massnahmen` NICHT aus dem
   Rundruf. Der Rundruf wird aus dem Stand dessen gebaut, der gerade
   etwas getan hat — bei einer Moderationshandlung wären der gemeldete
   Text, der Name des Gemeldeten und der Name des MELDERS an jeden im
   Saal gegangen. Wer meldet, muss sich darauf verlassen können, dass
   das niemand sieht.

2. `meldung.js` hatte zwei Schlüssel doppelt: `geschlossen` und
   `nur_leitung`. Der spätere gewinnt stillschweigend — in der Hilfe
   stand dadurch „Der Saal ist zu". Ein Wort, ein Satz; eine Prüfung
   hält das jetzt fest.

3. Wer stummgeschaltet war, bekam bei OFFENEM Chat „Der Chat ist
   gerade zu" — der Aufrufer reimte sich den Grund aus der Chat-Stufe
   zusammen. Jetzt gibt `schreibGrund()` den echten Grund zurück.

4. Wer rausgenommen wurde, merkte nichts: Das Video lief weiter, nur
   die Anwesenheitsmeldung schlug still fehl. Jetzt hält alles an und
   es steht ein Satz da, der sagt, dass es nur für heute gilt.

UND EINER, DEN NUR DAS BILDSCHIRMFOTO GEFUNDEN HAT
Das Menü lag messbar komplett im Fenster (x=1099..1315 von 1440,
y=652..840 von 900) und war trotzdem abgeschnitten: Die Chatliste
rollt, und ein rollender Vorfahre beschneidet sein Kind. „Im Fenster"
und „sichtbar" sind zwei verschiedene Fragen, und ich hatte die
falsche gemessen. Das Menü hängt jetzt fest am Fenster und klappt nach
oben oder links, wo kein Platz ist. Die Messung fasst seitdem mit
`elementFromPoint` an jeden Knopf an, statt Rechtecke zu vergleichen.

GEPRUEFT
pruef-reaktion: 216 Prüfungen (vorher 156), 0 Fehler — zwei neue
Abschnitte. mess-reaktion misst die Moderation jetzt im echten
Browser: Abzeichen, Überlauf der Schiene, Menü, Stummschaltung beim
Betroffenen, Meldung bis zur Moderation, Rauswurf.
Dazu grün: pruef-meldungen, pruef-struktur, pruef-css-klassen,
pruef-tippziele, pruef-deutsche-texte, pruef-hilfe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 09:32:10 +02:00
DogFatherGit cb8e59bb08 Eine Arbeitsdatei war mitgewandert
tools/.messkopf.txt ist ein Zwischenstand beim Bauen der Messung --
sie gehoert nicht in die Geschichte. Die uebrigen Zwischenstaende
stehen schon in .gitignore; dieser Name fehlte.
2026-09-28 09:08:59 +02:00
DogFatherGit 079cf8c74f Drei Quellen fuer OBS und TikTok Studio -- ohne Anmeldung, mit Schluessel
Filipe: "ich will das alles auch so perfekt dass ich es ganz einfach
und easy mit obs oder mit tiktok studio verbinden kann. also so dass
man dan nur die kamera und das video sieht."

WARUM OHNE ANMELDUNG -- nachgesehen, nicht angenommen

OBS speichert die Anmeldung einer Browser-Quelle NICHT zuverlaessig;
im OBS-Forum stehen dazu Meldungen bis in die aktuelle Fassung 31.
Eine Quelle, bei der man sich nach jedem Programmstart neu anmelden
muss, ist mitten in einer Sendung unbrauchbar.

Deshalb ein SCHLUESSEL in der Adresse -- derselbe Weg, den jedes
Alert-Werkzeug im Netz geht. 32 Byte aus dem Zufall des Systems,
verglichen wird zeitgleich (`timingSafeEqual`): Ein gewoehnlicher
Vergleich bricht beim ersten falschen Zeichen ab, und aus den
Bruchteilen einer Millisekunde laesst sich ein Schluessel Zeichen
fuer Zeichen erraten.

DREI QUELLEN, WEIL DREI DINGE VERSCHIEDEN SIND

  buehne.html   Das laufende YouTube-Video, auf die Sekunde genau wie
                bei allen anderen. Stumm (der Ton kommt aus dem
                Mischpult) und ohne jede Bedienung -- was hier zu
                sehen ist, geht in den Stream.

                DIE EIGENE KAMERA IST ABSICHTLICH NICHT DRIN. Sie ist
                in OBS direkt als Geraet verfuegbar, in besserer
                Qualitaet und frei in Groesse und Lage -- genau das,
                was Filipe will ("meine kamera groesser machen video
                kleiner"). Den Umweg ueber den Browser zu nehmen
                hiesse, Qualitaet gegen nichts einzutauschen und die
                Groesse festzulegen statt sie freizugeben.

  tafel.html    Nur die Spendenkarten, auf DURCHSICHTIGEM Grund.
                Groesse und Lage stehen in der Adresse (`&g=1.6`,
                `&pos=or`): Wer in OBS eine Quelle einrichtet, hat die
                Adresse ohnehin vor sich -- ein Wert, den man
                stattdessen im Regiepult suchen muesste, waere ein
                Fensterwechsel mitten im Einrichten. Alles rechnet in
                `rem`, ein Wert nimmt Schrift, Bild und Polsterung
                gleichmaessig mit.

  Buehnenmodus  `reaktion.html?nur=buehne` -- dieselbe Seite, nur ohne
                alles Bedienbare. Fuer den Fall, dass GAESTE im Bild
                sind: Deren Kameras kommen ueber eine
                Direktverbindung an, und die braucht eine angemeldete
                Seite. Diese eine wird als Fenster aufgenommen.

                ES IST DIESELBE SEITE UND NICHT EINE ZWEITE. Eine
                eigene muesste Video, Kameras, Verbindungsaufbau und
                Nachfuehrung noch einmal enthalten -- und beim
                naechsten Umbau saehe eine von beiden anders aus.

WAS HERAUSKOMMT, IST DIE EIGENTLICHE FRAGE

Wer den Schluessel hat, sieht genau das, was ohnehin im Stream
steht: Video, Stand, Sekunde, Titel -- und die Spendenkarten. Kein
Chat, keine Namen von Zusehenden, keine Zahlen ueber das Haus. Die
Pruefung zaehlt die Felder der Auskunft EINZELN auf und weist jedes
verbotene namentlich nach; eine Auskunft, die "ungefaehr das
Richtige" enthaelt, ist bei einem Weg ohne Anmeldung keine.

Ein neuer Schluessel macht die alten Adressen sofort tot -- und
schliesst die laufenden Quellen. Sonst liefe eine mit dem alten
weiter, obwohl er zurueckgezogen ist, und man haelt sich fuer
sicher, ohne es zu sein.

SIE MUESSEN TAGE LAUFEN, OHNE DASS JEMAND HINSIEHT

Das ist der Unterschied zu einer Seite im Browser: Wer eine Seite
offen hat, merkt, wenn sie haengt. Eine Quelle in OBS laeuft im
Hintergrund, und ein Stillstand faellt erst auf, wenn die erste
Spende nicht erscheint -- mitten in der Sendung. Deshalb ein
Lebenszeichen alle 25 Sekunden, eine eigene Wache (70 Sekunden ohne
alles = neu verbinden) und ein sofortiger Neuaufbau, wenn der
Rechner aus dem Ruhezustand kommt.

Und: Ein Fehler wird angezeigt, aber nur der, der etwas bedeutet --
ein falscher Schluessel. Alles andere bleibt still, weil jede
Flaeche hier im Stream zu sehen waere.

ZWEI EIGENE FEHLER, BEIDE VON DER MESSUNG GEFUNDEN

1. Die Quellen kamen mit 401 zurueck, obwohl die Seiten laengst
   geladen waren: `aufgabenRouter` haengt eine Schranke ueber ALLE
   Pfade unter /workspace/api. Genau dafuer stehen `sicherungRouter`
   und der Weg fuers Profilbild schon davor -- die Buehne ist der
   dritte Fall derselben Art und steht jetzt dort.

2. `waitUntil: "networkidle"` auf einer Seite mit Ereignisstrom. Der
   Strom endet absichtlich nie; die Messung wartete auf einen
   Zustand, der nicht eintreten kann, und brach nach 30 Sekunden ab.
   Dieselbe Falle wie am 06.09. beim Regressionslauf.

ZWEI PRUEFUNGEN WURDEN DABEI GENAUER

  `pruef-struktur` verlangte von den zwei OBS-Quellen ein Symbol fuer
  den Startbildschirm, ein Manifest und eine Leistenfarbe. Sie
  laufen in einem Programmfenster und werden nie installiert -- sie
  fallen aus dieser Frage heraus, benannt und mit Grund.

  Die Namensstreit-Regel zaehlte jede Klasse, die irgendwo in einem
  Selektor vorkommt. Damit galt auch
  `body[data-nur="buehne"] .kopfleiste { display: none }` als eigene
  Klasse -- dabei ist das das Gegenteil: eine absichtliche
  Bezugnahme, um sie im Buehnenmodus wegzunehmen. Gezaehlt wird
  jetzt nur, was am ANFANG einer Regel steht, also als eigenes
  Bauteil gemeint ist. Mit Gegenprobe in beide Richtungen -- sonst
  haette ich eine Regel nur so lange geschaerft, bis sie schweigt.

GEMESSEN

  mess-buehne     (neu) Ein Browserfenster OHNE jeden Keks: beide
                  Quellen arbeiten, 0 Kekse, Grund durchsichtig
                  (rgba(0,0,0,0)), Karte laeuft an (25 EUR,
                  Rudel-Legende, 348x178). Falscher Schluessel: kein
                  Inhalt, Grund im Bild. Neuer Schluessel: alter 404,
                  neuer 200. Buehnenmodus: Kopf, Chat, Pult und
                  Schild weg, Leinwand da, Saal 720 von 720.
  pruef-buehne    (neu) 36 Punkte, 0 Fehler
  pruef-reaktion  156 (war 154), spenden 46, haus-trennung 100,
                  haus-seiten 38, struktur 35, css-klassen 33,
                  verborgen 25, rechtetafel 19, portnummern 15,
                  ports 8 -- alle 0 Fehler.
2026-09-28 09:08:42 +02:00
DogFatherGit dcd0298700 Spenden: vier Knoepfe, eine Karte im Bild -- und die Wahrheit ueber PayPal
Filipe: "ich will dass das richtig perfekt gemacht wird so dass die
leute so einfach wie moeglich eine spende aufs paypal machen koennen.
und wenn jemand spendet soll auch der betrag erscheinen mit einem
bild."

WAS GEHT UND WAS NICHT -- NACHGESEHEN, NICHT ANGENOMMEN

Hinterlegt ist ein PayPal.me-Link auf ein PRIVATES Konto. Daraus
folgt zweierlei, und beides bestimmt den ganzen Aufbau:

  ES GEHT: `paypal.me/<name>/5EUR` oeffnet PayPal mit schon
  eingetragenem Betrag. Ein Tipp, fertig. Belegt an PayPals eigener
  Hilfeseite zu PayPal.Me.

  ES GEHT NICHT VON SELBST: PayPal meldet eine Zahlung nur, wenn ein
  Webhook oder IPN eingerichtet ist -- beides braucht Zugangsdaten,
  die nur Filipe selbst anlegen kann. Ob ein PRIVATES Konto das
  ueberhaupt kann, sagt PayPals eigene Doku nicht eindeutig; ich habe
  es gesucht und nicht gefunden, und etwas zu behaupten, das ich
  nicht belegen kann, waere hier das Gefaehrlichste.

DESHALB DREI HERKUENFTE UND NICHT EINE

  "hand"      Filipe sieht die PayPal-Meldung auf dem Handy und tippt
              den Betrag ins Pult. Geht immer, braucht nichts, ist in
              drei Sekunden getan, und die Karte laeuft sofort.
  "gemeldet"  Der Zuschauer sagt nach dem Spenden selbst Bescheid.
              Landet als OFFEN und wird erst gezeigt, wenn die
              Leitung es bestaetigt.
  "paypal"    Kommt automatisch, sobald ein Webhook eingerichtet ist.
              Bis dahin steht dieser Weg leer da -- die Tabelle und
              die Sperre gegen doppelte Zahlungsnummern sind schon
              fertig.

WARUM EINE MELDUNG NICHT SOFORT ERSCHEINT: Sonst tippt irgendwer
"500 Euro" und steht damit gross im Bild. Eine Spende ist eine
Aussage ueber Geld; die gehoert bestaetigt, bevor sie oeffentlich
wird. Der Weg dahin ist EIN Tipp -- billig genug, dass niemand in
Versuchung kommt, ihn abzukuerzen. Beim Bestaetigen darf der Betrag
berichtigt werden: Die Leitung hat die PayPal-Meldung vor sich und
weiss es besser als die Behauptung.

FUER DIE ZUSCHAUER

Vier Betraege im Chat (2, 5, 10, 25 EUR) statt eines Links. Wer eine
Liste sieht, rechnet; wer vier Knoepfe sieht, tippt. Die Adresse wird
NICHT zweimal gepflegt -- sie steht auf der Unterstuetzen-Seite, und
von dort wird sie gelesen. Steht dort nichts oder ist der Weg auf
unsichtbar, gibt es hier auch keine Knoepfe. An einer Adresse, die
kein paypal.me ist, wird kein Betrag angehaengt: Er fuehrte sonst zu
einer Seite, auf der etwas anderes steht als auf dem Knopf.

DIE KARTE

Betrag gross, Name, Gruss, ein Bild dazu -- und eine Farbe, die von
der Stufe kommt. Drei Stufen ab Werk: Danke (ab 1), Starke Runde (ab
5), Rudel-Legende (ab 20), je mit eigener Vorlage, Farbe und Dauer.
Gilt immer die HOECHSTE, die noch passt; mit Ober- UND Untergrenze je
Stufe waere die doppelte Gelegenheit, eine Luecke zu lassen -- durch
die faellt dann ausgerechnet der grosse Betrag.

EINE NACH DER ANDEREN. Drei Spenden in zehn Sekunden sind keine
Seltenheit; uebereinander gelegt waere keine mehr lesbar, und
ausgerechnet die groesste ginge unter. Bei voller Schlange werden die
Zeiten gekuerzt, nicht die Karten weggeworfen -- wer gegeben hat,
soll es sehen.

DIE KARTE IST EIN EIGENES STUECK (spendenkarte.js/.css) und haengt an
nichts aus der Reaction. Dieselbe Karte laeuft spaeter als eigene
Seite fuer OBS und TikTok Studio; zweimal gebaut hiesse, sie sieht
nach der naechsten Aenderung an einem der beiden Orte anders aus --
und man merkt es erst im Livestream.

DER BETRAG STEHT IN CENT

Nie als Kommazahl. 0.1 + 0.2 ist in keiner Programmiersprache 0.3,
und bei Geld faellt das irgendwann jemandem auf -- meistens dem, der
zahlt. Formatiert wird erst auf dem Bildschirm.

DREI EIGENE FEHLER, VON DEN PRUEFUNGEN GEFUNDEN

1. Ein Weg aus einer Verzweigung: `/spenden/${id}/${ja ?
   "bestaetigen" : "ablehnen"}`. `pruef-struktur` hat das zu Recht
   beanstandet -- ein Tippfehler im selteneren Zweig faellt erst auf,
   wenn er mitten in einer Sendung gebraucht wird. Beide Wege stehen
   jetzt ausgeschrieben da.

2. "Genau 5 Register" -- zweimal am selben Tag dieselbe feste Zahl,
   in der Messung UND in der Pruefung. Beim sechsten Register wurden
   beide rot, obwohl nichts kaputt war. Jetzt wird gezaehlt: zu jedem
   Reiter gehoert eine Tafel, und keine steht ohne Reiter da.

3. Die Messung war zu ungeduldig: Die erste Karte laeuft elf
   Sekunden (die hoechste Stufe steht am laengsten), die zweite
   wartet in der Schlange -- richtig so. Die Messung wartete 1,6
   Sekunden und meldete "laeuft nicht". Jetzt wird auf das Merkmal
   gewartet, nicht auf die Uhr.

GEMESSEN

  mess-reaktion   4 Betragsknoepfe mit richtiger Adresse.
                  25 EUR von Hand -> Karte "Rudel-Legende" bei der
                  Zuschauerin. 500 EUR gemeldet -> steht NICHT im
                  Bild, wartet im Pult. Bestaetigt mit berichtigten
                  10 EUR -> Karte "Starke Runde". Stufe passt zum
                  Betrag, beide Male.
  pruef-spenden   46 Punkte, 0 Fehler (neu) -- darunter die
                  Gegenprobe, dass ohne Zahlungsnummer beliebig viele
                  Zeilen nebeneinander stehen duerfen (sonst liesse
                  sich nur EINE Spende von Hand eintragen).
  pruef-reaktion  154, unterstuetzung 70, aufbewahrung 45,
                  struktur 35, css-klassen 33, portnummern 15,
                  tippziele 11, ports 8, meldungen 8 -- alle 0 Fehler.

SCHEMA: zwei Tabellen (spenden, spenden_stufen) mit einem
Einmalig-Index auf die Zahlungsnummer. Auf einer Kopie der echten
Datenbank durchgespielt: 20 Personen, 227 Chatnachrichten, keine
Tabelle verliert eine Spalte.
2026-09-28 08:34:42 +02:00
DogFatherGit 28b492527e Kamera und Chat -- die zwei fehlenden Host-Steuerungen
Filipes Notiz nennt acht: "Host-Steuerung fuer Kamera, Mikrofon,
Video, Gaeste, Lautstaerke, Chat, Layout und Start/Ende."
Nachgezaehlt war sechsmal etwas da und zweimal nichts.

KAMERA

Es gab keinen Weg, das eigene Bild abzuschalten. Wer kurz aufstehen,
trinken oder etwas holen wollte, musste die Sendung verlassen oder
sich dabei filmen lassen.

Jetzt liegt eine SENDERLEISTE ueber dem Bild -- Kamera, Mikro und ein
Pegel. Sie gehoert jedem, der sendet, Host wie Gast: Ein Gast, der
sein eigenes Bild nicht abschalten kann, muesste den Host darum
bitten, und das ist keine Bedienung, sondern eine Bitte.

DAS BILD WIRD AM GERAET ABGESCHALTET (`track.enabled = false`), nicht
am Server: Die Verbindung bleibt stehen, der Ton laeuft weiter, und
beim Wiedereinschalten ist das Bild sofort da. Der Server erfaehrt es
nur, damit bei den ANDEREN "Kamera aus" im Fenster steht. Ohne diese
Beschriftung sind ein abgeschaltetes und ein kaputtes Bild dasselbe
schwarze Rechteck -- und dann fragt jemand im Chat, ob die Technik
hakt.

Erst das Geraet, dann die Ansage. Andersherum stuende bei allen
"Kamera aus", waehrend noch ein Bild fliesst; man glaubte sich
unsichtbar. Geht die Ansage nicht durch, wird das Geraet
zurueckgesetzt -- ein Knopf, der halb wirkt, ist schlimmer als einer,
der gar nicht wirkt.

Das Mikro laeuft denselben Weg. Erst wollte ich es rein oertlich
lassen; das waere dieselbe stille Falle gewesen: Wer sich selbst
stummschaltet und trotzdem redet, saehe bei allen anderen ein ganz
normales Fenster.

CHAT

Es gab Moderation -- Beitraege wegnehmen -- aber keine Steuerung des
Chats selbst. Einen Beitrag zu loeschen, nachdem er stand, ist etwas
anderes, als ihn gar nicht erst zuzulassen.

Drei Stufen: OFFEN, TEAM (wer moderiert, darf -- wer aufraeumen soll,
muss dabei reden koennen) und ZU (nur die zwei, die fuehren). Zwei
Stufen waeren zu wenig: "zu" ist in einer Sendung fast immer zu viel,
dann sitzen alle vor einem stummen Fenster. Die mittlere ist die, die
man wirklich braucht.

Die Schalter stehen im Kopf der Chatschiene, nicht im Regiepult: Man
moderiert, wo man liest. Ein Umweg ueber ein Register waere in dem
Moment, in dem es laut wird, genau ein Umweg zu viel.

EIN GESPERRTES FELD SAGT, WARUM. "Gerade schreibt nur das Team" statt
eines Feldes, das sich nicht beschreiben laesst und schweigt --
sonst schreibt jemand in den Hauschat, dass die Reaction hakt. Und
die Stufe gilt AM SERVER: Ein ausgegrautes Feld haelt niemanden auf,
der die Schnittstelle kennt. Beide Antworten kommen aus derselben
Funktion; zwei Rechnungen waeren zwei Gelegenheiten, dass ein Feld da
ist und mit 403 antwortet.

EIN FUND NEBENBEI: "MEIN MIKRO" WAR EIN PLACEBO

Gemessen: `staende.mikro` wird nirgends gelesen. Der Schieber liess
sich bewegen, die Zahl daneben aenderte sich -- und es passierte
nichts. Das ist schlimmer als ein fehlender Regler: Man glaubt, man
haette leiser gestellt.

Technisch ist das auch richtig. Die eigene Lautstaerke laesst sich
nicht am Regler aendern; man muesste den Ton umrechnen und die Spur
in jeder Verbindung austauschen. Was man beim eigenen Mikro braucht,
ist AN oder AUS -- und ein Pegel, der zeigt, dass es ankommt. Genau
das steht jetzt dort, als vierte Spalte im Pult, mit demselben
Zustand wie die Senderleiste. Die drei anderen Regler bleiben Regler:
Sie steuern, was ICH hoere, und das geht am Empfaenger.

UND EINER IN MEINER EIGENEN ARBEIT

`kasten.append(el("div","pegel")).append(el("i"))` -- `Node.append()`
gibt `undefined` zurueck, nicht das angehaengte Element. Ein
TypeError beim Aufbau des Pults, den `node --check` nicht sieht.
Beim Verkuerzen nicht nachgesehen, was die Methode zurueckgibt.

Dazu: Die Pegel-Takte liefen in `pultAufbauen()`. Das Pult hat nur,
wer die Sendung fuehrt -- ein Gast haette seinen Pegel nie gesehen,
und genau er braucht ihn am dringendsten.

GEMESSEN

  mess-reaktion   Der Host schaltet ab, und bei der Zuschauerin steht
                  "Kamera aus" bei DogFather, Bild verdeckt.
                  Stufe "Team": Feld gesperrt mit Grund, und der
                  Server lehnt denselben Versuch mit 403 ab.
  pruef-reaktion  154 Punkte, 0 Fehler (vorher 132), neuer Abschnitt
                  13 mit 22 Punkten und Gegenprobe (es geht auch
                  wieder auf -- eine Sperre, die man nicht loesen
                  kann, ist keine Stufe, sondern ein Ende)
  dazu gruen      handy 180, css-klassen 33, struktur 35,
                  tippziele 11, aufbewahrung 45, meldungen 8

Der neue Abschnitt baut sich seine Buehne selbst. Abschnitt 9 beendet
die Sendung; sich auf den Stand eines frueheren Abschnitts zu
verlassen ist die Kopplung, die spaeter jemand aus Versehen
zerreisst.

SCHEMA: drei Spalten (reaktion_dabei.kamera_aus, .mikro_aus,
reaktion.chat_modus), alle per ALTER TABLE. Auf einer Kopie der
echten Datenbank durchgespielt: 20 Personen, keine Tabelle verliert
eine Spalte.
2026-09-28 08:16:49 +02:00
DogFatherGit d79f6faa23 Die Reaction-Kachel steht hinter Dogi-Media
Filipe: "die kachel von der kategorie, reaktion, soll nach der
kachel, dogi-move, erscheinen bitte und danke."

EINE KACHEL "DOGI-MOVE" GIBT ES NICHT -- das Wort kommt im ganzen
Haus nicht vor. Die einzige, die passt, ist "Dogi-Media" (Bilder und
Videos zum Posten). Dorthin ist sie gesetzt; sollte etwas anderes
gemeint gewesen sein, ist es eine Zeile zurueck.

Die Reihenfolge der Community-Kacheln ist damit:

  Willkommen - Rudel-Chat - Highlights - Anschlagbrett -
  Was ansteht - Wunschliste - Dogi-Media - REACTION - Draussen

Geprueft: reaktion 132, kachelraster 24, kachel-universum 13 --
alle 0 Fehler.

Die angefangene Arbeit an der Host-Steuerung (Kamera- und
Chat-Spalten) bleibt bewusst im Arbeitsstand: Sie ist unfertig und
hat in dieser Lieferung nichts zu suchen.
2026-09-28 08:04:07 +02:00
DogFatherGit 711a5470ab Die Regie wird eine Regie -- und das Video lief bei niemandem
Filipe: "perfektionier das auch mit den videoos. pefektionier auch das
aussehen und das layout von der regie. ich will dass du das viel
hochwertiger und profissioneller machst."

DAS VIDEO LIEF BEI NIEMANDEM -- AUCH NICHT BEIM HOST

Gemessen ueber ein neues Merkmal am Rahmen: `onStateChange` ist nie
ausgeloest worden, bei keinem der drei Browser. Zwei Ursachen, die
sich gegenseitig verdeckt haben:

  Ein Player mit Ton darf ohne Handlung des Menschen nicht losgehen.
  Ohne `mute: 1` greift `playVideo()` nicht -- und ein Zuschauer hat
  keine Bedienung (mit Absicht), haette also NIE eine Moeglichkeit
  gehabt, es zu starten. Eine Stunde Standbild.

  `onReady` tat `if (host) takt(); else folgen();`. Der Host hat damit
  nur GEMELDET, wo er steht, und nie selbst begonnen. Er meldete
  "laeuft nicht", und alle anderen folgten ihm brav ins Stehen.

Die Messung bricht ab jetzt ab, wenn der Player nicht bei beiden
laeuft. Ein gruener Haken ueber einem Standbild ist wertlos.

DIE WARTESCHLANGE

Vorher gab es genau EIN Videofeld. Wer zwei Sachen hintereinander
schauen wollte, tippte mitten in der Sendung eine YouTube-Adresse ein
-- vor Publikum, mit laufender Kamera, ein Tippfehler von einem
schwarzen Rechteck entfernt. Jetzt wird vorher eingeraeumt und im Live
nur weitergeschaltet: anhaengen, schieben, "Jetzt", "Naechstes".

Die Titel kommen von YouTube selbst (oEmbed, kein Schluessel, kein
Kontingent) und werden EINMAL geholt und hingelegt. Klappt der Abruf
nicht, steht dort die Kennung -- kein erfundener Name. Die Grenze ist
ueber die Umgebung veraenderbar, damit die Pruefung den vollen Fall in
Sekunden erreicht statt dreissig Videos anzuhaengen.

DIE REGIE

Vorher fuenf Kaesten untereinander in einem Bereich, der hoechstens
die halbe Bildschirmhoehe hat -- man sah fuenf halbe Dinge. Die drei
grossen Knoepfe lagen ganz unten, hinter acht Feldern und vier
Reglern. Wer mitten in der Sendung "Beenden" drueckt, drueckt es, weil
etwas passiert ist; das darf nicht hinter einer Bewegung liegen.

  Eine Leiste, die IMMER steht: Lampe, Laufzeit, Zuschauer, wie viele
  davon Bild bekommen, Gaeste, gemessener Upload -- und rechts die
  drei Knoepfe.

  Register statt Stapel: Sendung, Warteschlange, Ton, Gaeste, Bild.

  Eine Videospur mit echter Bedienung: Pause, +/-10 s, Positionsband,
  Restzeit, "Naechstes". Vorher musste man ins YouTube-Bild fassen --
  und was dort passiert, passiert nur bei einem selbst.

  PEGEL an den Reglern, aus einer echten Messung (AnalyserNode). Sie
  beantworten die Frage, die man sich sonst erst nach der Sendung
  stellt: "War mein Mikro ueberhaupt an?" Ein Regler auf 100 sagt
  darueber nichts. Beim YouTube-Regler steht dabei, dass dort nichts
  zu messen ist -- Ton aus einem fremden Rahmen laesst sich nicht
  abgreifen, und ein erfundener Ausschlag waere schlimmer als keiner.

  Der Upload wird an der Verbindung gemessen (`getStats`), nicht aus
  "zwoelf mal 350 kbit/s" gerechnet.

  Tastaturkuerzel -- und die Legende steht darunter. Ein Kuerzel, das
  niemand kennt, ist keins.

VIER FEHLER, DIE NUR DIE MESSUNG GEZEIGT HAT

1. `.tafel` WAR SCHON VERGEBEN. Die Anmeldeseite hat diese Klasse und
   setzt sie `position: absolute`; reaktion.html laedt beide
   Stilvorlagen. Die Register-Tafel trug damit NICHTS zur Hoehe bei
   (114 px Raster, 294 px Inhalt) und malte quer ueber Register und
   Kuerzel -- 197 Pixel, um die die Seite ueberlief. Auf dem Bild sah
   es aus wie ein Anzeigefehler; es war ein Namensstreit. Dritter
   Fall dieser Art nach .knopf-still und .schalter, deshalb steht er
   ab jetzt in einer Pruefung: keine Klasse aus reaktion.css darf in
   gate/start/module/haus.css vorkommen.

2. `node:sqlite` kennt kein `.transaction()`. Von Hand geklammert.

3. Das Schild war auf dem Handy 10,24 px gross -- unter der
   Hausgrenze von 11,5 px. Gefunden von `pruef-handy`, das die
   GEZEICHNETE Groesse misst; die Stilvorlagen-Pruefung haette die
   Zeile in der Medienabfrage durchgelassen.

4. Die Messung klickte blind auf den Griff des Pults ("umschalten")
   und machte es damit ZU, seit es aufgeklappt startet. Sie stellt
   den Zustand jetzt her, statt ihn umzuschalten.

UND DIE HARTNAECKIGSTE: DAS BILD DES GASTES KAM BEIM HOST NICHT AN

Erst in einem von drei Laeufen, dann in drei von drei -- und zwar
SCHLIMMER, nachdem ich einen Wachhund dagegen gebaut hatte. Vier
Ursachen, hintereinander gemessen statt geraten:

  a) `kameraHolen()` wurde beim Host FUENFMAL betreten. Die Wache
     `if (meinStrom) return` wirkt erst, wenn die Kamera DA ist --
     solange die erste Anfrage laeuft, geht jede weitere als zweite
     Anfrage an dasselbe Geraet. Eine blieb liegen, und mit ihr der
     Anruf, der darauf wartete. Der Platz in `ruftGerade` blieb
     belegt, und damit kam nie wieder eine Leitung zustande. Jetzt
     bekommen alle dasselbe Versprechen zurueck.

  b) Der Wachhund riss Leitungen ab, die gerade verhandelt wurden --
     zwischen "Leitung angelegt" und "Angebot abgeschickt" liegen
     drei await.

  c) "Ruf mich an" brach dasselbe ab: Der Bittende weiss nicht, dass
     es schon laeuft, und fragt alle drei Sekunden weiter.

  d) Meine Reparatur von (b) und (c) war "signalingState !== stable
     heisst: in Arbeit" -- ohne Uhr. Damit war eine Leitung, deren
     Antwort nie ankommt, vor BEIDEN Aufraeumwegen sicher, dauerhaft.
     Der Zuschauer bekam daraufhin gar nichts mehr; ich hatte den
     Fehler nur von einer Seite auf die andere geschoben. "Wird
     verhandelt" ist ein Zustand MIT DAUER und steht jetzt an genau
     einer Stelle.

Dazu ein eigener Fehler beim Aufraeumen: Mit der Messspur ist
`ruftGerade` mit herausgefallen. Vier Laeufe ohne jedes Bild -- und
`node --check` sieht das nicht, eine fehlende Variable ist
syntaktisch tadellos.

GEMESSEN

  mess-reaktion   fuenf Laeufe hintereinander ohne Beanstandung;
                  beide Kameras 640 px bei Host UND Zuschauerin,
                  Video laeuft bei beiden, Upload 0,9 Mbit/s,
                  Warteschlange 2 Zeilen mit Vorschaubildern,
                  kein Ueberlauf (4 px statt 197)
  pruef-reaktion  132 Punkte, 0 Fehler (vorher 88)
  pruef-handy     180 Punkte, 0 Fehler
  dazu gruen      css-klassen 33, struktur 35, tippziele 11,
                  meldungen 8, kamera-richtlinie 10

SCHEMA: neue Tabelle `reaktion_liste`, neue Spalte
`reaktion.video_titel` (ALTER TABLE, keine Abschrift der Spalten).
2026-09-28 03:45:15 +02:00
DogFatherGit 85101176d2 Zwei fuehren die Sendung: DogFather und die rechte Hand
Filipe, 28.09.2026: „die rolle dogfather und vanvan sollen alles sehen
und bereit machen auch vorher. nur die zwei sollen alles sehen,
vorbereiten und einschakten koennen."

DREI DINGE AENDERN SICH, UND ZWAR GENAU DIESE DREI

1. VORBEREITEN UND EINSCHALTEN duerfen jetzt beide -- einstellen,
   Vorbereitung, auf Sendung, beenden, Gaeste holen und entfernen,
   stummschalten, Anordnung, Video steuern. Beide dasselbe; es gibt
   hier keinen Ersten und keinen Zweiten. Das ist eine AUFGABE und
   kein Rang: Wer die Sendung fuehrt, bedient die Technik.

2. „ALLES SEHEN" heisst die Namensliste derer, die zusehen. Die
   bekamen bisher auch die linke Hand und die Modis, weil sie
   moderieren duerfen. Das war eine Vermischung zweier Dinge, die
   nichts miteinander zu tun haben: Wer einen Beitrag wegnehmen darf,
   muss deshalb nicht wissen, wer im Saal sitzt. Ab jetzt bekommt die
   Liste nur, wer auf der Buehne steht.

   Die Moderation bleibt unveraendert bei DogFather, beiden Haenden
   und den Modis -- einen Beitrag wegzunehmen ist weder „alles sehen"
   noch „vorbereiten" noch „einschalten", sondern dasselbe, was sie im
   Treff ohnehin tun.

3. WER AUF SENDUNG DRUECKT, IST IM BILD.

   Vorher stand als Host, wer zuletzt etwas eingestellt hatte. Mit
   zwei Leuten, die vorbereiten duerfen, waere das eine Falle: VanVan
   richtet am Nachmittag alles ein, Filipe drueckt abends auf Sendung
   -- und im Bild stuende VanVans Name, waehrend Filipe redet.

   Beim Einstellen wird `host_id` deshalb nur noch gefuellt, wenn
   dort noch niemand steht (damit die Ankuendigung „Als Naechstes"
   jemanden nennen kann). Wer die Sendung FUEHRT, entscheidet sich
   beim Einschalten.

GEPRUEFT IN BEIDE RICHTUNGEN

Nur zu messen, wer darf, hiesse: Die Tuer laesst sich spaeter weit
aufmachen, ohne dass etwas rot wird. pruef-reaktion misst deshalb
auch, wer ausdruecklich NICHT darf -- und dass es genau zwei sind:

  genau zwei fuehren die Sendung: admin, hand
  die rechte Hand darf einstellen / vorbereiten / Anordnung
  linke, modi, gast: duerfen nicht einstellen (403)
  ein Modi holt niemanden dazu (403)
  wer eingeschaltet hat, fuehrt die Sendung (Filipe)
  drueckt die rechte Hand, fuehrt sie (VanVan)
  die rechte Hand sieht, WER dabei ist -- sie fuehrt mit
  linke, modi: moderieren, bekommen die Namensliste aber nicht
  admin/hand: sehen Regiepult -- linke/modi/gast: nicht

Das Regiepult haengt an `ich.host` und an nichts sonst. Ein zweiter
Ort, an dem der Browser dieselbe Frage noch einmal beantwortet, waere
der, der spaeter abweicht -- und dann stuenden Knoepfe da, die mit 403
antworten.

pruef-reaktion 74 -> 88 Punkte, 0 Fehler. Dazu gruen: meldungen 8,
rechtetafel 19, verborgen 25, modi-verborgen 85.
2026-09-28 02:01:32 +02:00
DogFatherGit 5d89d108f9 Die Reaction: zusammen schauen, live, mit Kamera und Chat
Filipes Kurznotiz vom 28.09.2026, von links nach rechts:

    Kachel sichtbar -> geschlossen -> Vorbereitung -> Wartebereich/
    Chat -> Countdown -> LIVE -> Reaction + Gaeste + Chat + PayPal
    -> Ende

DREI STAENDE, NICHT SIEBEN

„Wartebereich" und „Countdown" sind keine eigenen Zustaende, sondern
das, was „Vorbereitung" auf dem Bildschirm TUT. Drei Staende, die
sich gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
vier ueberlappen, sind es nicht.

DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER

Naheliegend waere: Der Host spielt ab, alle sehen seinen Bildschirm.
Das waere aus zwei Gruenden falsch. Rechtlich ist ein
weitergesendetes YouTube-Video eine oeffentliche Wiedergabe -- genau
die Sache, fuer die Kanaele gesperrt werden. Und technisch kostet es
Bandbreite und Qualitaet.

Jeder Zuschauer laedt das Video deshalb SELBST. Uebertragen wird nur
der Spielstand: Kennung, laeuft/pausiert, Sekunde. Das sind ein paar
Byte, jeder sieht es in voller Qualitaet, und alle sind auf derselben
Sekunde. Nachgefuehrt wird erst ab anderthalb Sekunden Abweichung --
ein Player, dem man jede Sekunde eine neue Position gibt, ruckelt
sichtbar.

DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH

Ueber denselben Weg wie die Anrufe im Haus (seit 18.09.), nur mit
mehr Empfaengern. Das hat eine Grenze, und sie ist gerechnet, nicht
geraten: Bei 360p und rund 350 kbit/s sind zwoelf Zuschauer etwa
4 Mbit/s Upload beim Host. Darueber schaltet die Sendung von selbst
auf Ton um -- wer keine Kamera mehr bekommt, hoert alles, sieht das
Video und kann schreiben. Ehrlicher als eine Verbindung, die stockt,
und sichtbar im Regiepult.

Heute sind es elf Menschen im ganzen Haus (gemessen: 1 admin, 1 hand,
1 linke, 4 modi, 4 gast). Die Grenze ist weit weg -- sie steht
trotzdem drin, weil sie sonst erst auffaellt, wenn es zu spaet ist.

DIE SEITE IST ANDERS GEBAUT ALS JEDE ANDERE IM HAUS

Ueberall sonst: Kacheln, Karten, Listen -- man liest, entscheidet,
geht wieder. Hier sitzt man. Eine Stunde, mit anderen, auf EINE
Sache schauend. Deshalb kein Raster, sondern ein SAAL: grosse Flaeche
fuer das Video, Kamerabilder als schwebende Fenster darueber, der
Chat als Schiene daneben. Die Seite scrollt nicht -- ein Video, das
beim Tippen im Chat nach oben rutscht, ist der schnellste Weg, dass
jemand aufhoert zu schreiben.

Fuer den Host ein REGIEPULT: vier senkrechte Regler nebeneinander wie
an einem Mischpult, darueber die Sendung, daneben Gaeste und
Anordnung, unten drei grosse Knoepfe. Es SCHIEBT den Saal, es deckt
ihn nicht zu.

Die Kachel traegt ihren Zustand als Farbe: grau geschlossen,
bernstein in Vorbereitung, rot auf Sendung. Keine Ton-Nummer -- der
Farbraum ist bei 46 voll, und sie braucht auch keine.

PAYPAL: EINE QUELLE

Der Knopf nimmt den Weg, der auf der Unterstuetzen-Seite hinterlegt
ist -- derselbe Eintrag, dieselbe Pflege. Ist dort nichts eingetragen
oder steht er auf unsichtbar, erscheint hier kein Knopf. Eine
geratene Adresse ist an dieser Stelle die gefaehrlichste aller
Abkuerzungen.

=======================================================================
ACHT FEHLER, DIE OHNE MESSUNG LIVE GEGANGEN WAEREN
=======================================================================

1. `data-live` WAR SCHON VERGEBEN. Die Draussen-Kachel bekommt es,
   sobald Filipe auf Twitch sendet. Meine Regel haette ihr waehrend
   jedes Streams die Farbe genommen -- genau dann, wenn sie wichtig
   ist. Heisst jetzt `data-sendung`, und pruef-reaktion haelt beides
   auseinander.

2. DIE INHALTSRICHTLINIE HAETTE YOUTUBE LAUTLOS GESPERRT. Die Datei
   warnt an genau dieser Stelle selbst davor: Am 27.08.2026 hat
   `frame-src 'none'` den Musik-Knopf stillgelegt -- der Knopf
   reagierte, das Feld ging auf, und wo die Player sein sollten,
   blieb es leer. Hier waere das Ergebnis eine schwarze Leinwand vor
   Publikum gewesen. youtube-nocookie.com fuer den Rahmen (setzt keine
   Werbekennungen), www.youtube.com fuer die Einbett-API,
   i.ytimg.com fuer die Vorschaubilder.

3. KAMERA UND MIKROFON WAREN GESPERRT. Dieselbe Falle, vor der
   index.js selbst warnt -- und die am 18.09. schon einmal zugeschlagen
   hat. Die Ausnahme ist jetzt eine benannte MENGE statt eines zweiten
   Sonderfalls, und pruef-kamera-richtlinie.mjs haelt sie GEGEN DEN
   QUELLTEXT: Welche Seite laedt ein Skript, das getUserMedia
   aufruft? Genau die muss drinstehen -- und keine andere. Eine
   Liste, die abgeleitet wird, kann nicht veralten.

4. ZWEI ANRUFE AN DIESELBE PERSON. Zwischen `await kameraHolen()` und
   dem Anlegen der Verbindung laeuft alles andere weiter; jeder Takt
   sagte wieder „den kenne ich noch nicht". Der Empfaenger antwortete
   auf beide Angebote, und die zweite Antwort traf eine Verbindung,
   die laengst stand.

5. DAS ANGEBOT GING HINAUS, BEVOR DER EMPFAENGER ZUHOEREN KONNTE.
   Gemessen:

       [spur] an [3] reaktion_signal | offen: [2,1]
       ...
       [spur] Strom auf fuer 3 Lenny

   Die Anmeldung ist ein gewoehnlicher Abruf und sofort durch, der
   Ereignisstrom eine stehende Verbindung. Der Host erfaehrt vom
   Neuankoemmling also zuverlaessig, BEVOR der zuhoeren kann.
   Die Richtung ist jetzt umgedreht: Wer bereit ist, BITTET um den
   Anruf -- er ist der Einzige, der das sicher weiss. Dazu ein
   eigener, schneller Takt (2,5 s) und eine Ruecknahme, wenn ein
   Angebot bei niemandem ankommt.

6. EIN VIDEO MIT TON STARTET NICHT VON ALLEIN. `videoWidth` war 640,
   das Bild kam also an -- und das Fenster blieb schwarz. Kein
   Fehler, keine Meldung, es passiert einfach nichts. Die Kameras
   starten jetzt stumm (stumm darf losgehen), ein Knopf schaltet den
   Ton frei, und die erste Beruehrung der Seite tut es ohnehin.

7. DIE LADE AM HANDY GING NICHT AUF. Gemessen: ein 390x775 grosser
   Saal mit 219 px Video und 556 px Leere darunter. Statt den Knopf
   zu reparieren, ist die Lade weg -- unter Kopfleiste und Video
   bleiben auf einem Telefon rund 550 px, das ist mehr Chat, als eine
   Lade je zeigen wuerde. Ein Zustand weniger ist besser als ein
   Zustand, der funktioniert.

8. `sendBeacon` KANN NUR POST. Beim Schliessen des Fensters wird ein
   gewoehnlicher Abruf abgebrochen; mein DELETE waere nie angekommen,
   und jeder haette zwei Minuten lang als anwesend gegolten.

Dazu drei Funde der Hauspruefungen, alle von mir verursacht:
17 Schriftgroessen unter der Lesbarkeitsgrenze von 11,5 px, elf
Maschinenworte ohne deutschen Satz, und ein Aufbewahrungseintrag ohne
Rechtsgrundlage.

=======================================================================

GEMESSEN

pruef-reaktion            74 Punkte, 0 Fehler (11 Abschnitte)
pruef-kamera-richtlinie   10 Punkte, 0 Fehler (neu, abgeleitet)
mess-reaktion             beide Kameras kommen an, 640 px, laufen --
                          beim Zuschauer UND beim Host. Diese Messung
                          hat einen Rueckgabewert: Alles andere kann
                          gruen sein, und trotzdem sitzt jeder vor
                          einem schwarzen Rechteck.
pruef-handy               180 (vorher 177), pruef-notizen 79,
pruef-aufbewahrung        45, pruef-meldungen 8, pruef-css-klassen 33,
pruef-struktur            35, pruef-crew-adresse 161,
pruef-haus-trennung       100, pruef-start-ansicht 160 -- alle 0 Fehler.
2026-09-28 01:52:22 +02:00
DogFatherGit 6261d548d0 Die Einblendung nimmt die Farbe des Bereichs an, in dem sie steht
Filipe, 27.09.2026: „nimm dann doch die seiten vom community bereich
ne?"

Er hatte recht, und es war derselbe Fehler noch einmal. Beim ersten
Mal stand die Buehne in einer Farbe aus einem fremden Bereich
(#9cde90, die Notizen-Kachel aus dem Team). Beim zweiten Mal stand
sie in EINER Farbe fuer alles -- und jeder Bereich des Treffs hat
seine eigene:

    Rudel-Chat     #5499ff        Wunschliste    #abff96
    Highlights     #f60684        Dogi-Media     #918a01
    Anschlagbrett  #ffabff        Mitmachen      #577ecc
    Was ansteht    #2dc9ba        Unterstuetzen  #e76006

Ein Video ueber Dogi-Media mit violetten Einblendungen sind zwei
Sachen nebeneinander. Mit der Farbe des Bereichs ist es eine.

ABGELESEN, NICHT ABGESCHRIEBEN

Die Buehne fragt nach jedem Seitenwechsel
`getComputedStyle(document.body).--ton` -- genau den Wert, den die
Seite selbst benutzt. Eine Liste hier waere eine zweite Wahrheit, die
irgendwann von der ersten abweicht; tools/treff-farben.mjs liest
dieselben zwei Quellen (workspace.js fuer die Nummer, start.css fuer
den Farbwert), wenn man sie nachsehen will.

AUFGEHELLT, BIS ES LESBAR IST

#918a01 ist als Flaeche schoen und als Schrift auf dunklem Grund
unlesbar (1,9:1). Die Buehne mischt Weiss dazu, bis 4,5:1 erreicht
sind -- dieselbe Rechnung wie bei den Namen im Chat. Balken, Schein
und Vorhang bekommen den rohen Ton, die brauchen keinen Kontrast.

DER ABSPANN BLEIBT MARKE

Er ist in allen vier Videos derselbe und faellt deshalb auf
--violett/--akzent zurueck, egal auf welcher Seite er steht. Wer drei
Videos gesehen hat, soll den vierten am Schluss wiedererkennen.

UND EIN EIGENER FEHLER, GEMESSEN STATT UEBERSEHEN

Das Ablesen stand zuerst in `auf()` -- und `auf()` laeuft vor jeder
Karte, jedem Untertitel und jedem Rollen. Ueber hundert zusaetzliche
Wege in den Browser fuer eine Antwort, die sich nur beim
Seitenwechsel aendern kann. Video 1 wuchs dadurch von 32,9 auf
36,1 s. Jetzt wird beim Seitenwechsel gelesen: 32,0 s.

Alle vier neu gedreht: 24 bis 34 s, 1080x1920.
2026-09-27 19:37:06 +02:00
DogFatherGit a28a8ea2e3 Die Buehne der Videos ist gestaltet statt hingestellt
Filipe, 27.09.2026: „die graphik sieht scheisse aus und so. mach das
hoch profissionel und nicht so eine halbe geschissene scheisse."

Er hat recht gehabt, und der Grund war nicht Geschmack.

DIE FARBE GEHOERTE NIRGENDWO HIN

Karten und Untertitel standen in #9cde90 -- das ist der Ton der
NOTIZEN-Kachel aus dem Teambereich. In diesen Videos ist die
Community-Seite zu sehen, und die laeuft auf --violett #a98bff und
--akzent #8ec9ff ueber --tinte #07060f (crew-haus.css). Eine
Einblendung in einer Farbe, die im Bild gar nicht vorkommt, sieht
immer aufgeklebt aus, egal wie sauber sie gebaut ist. Das war der
eigentliche Befund; alles andere kam obendrauf.

WAS JETZT STEHT

Die Karte: Zierzeile „- TEAM DOGI -" wie ueber dem Titel in der App,
Ueberschrift mit ausgeglichenen Zeilen, langsame Kamerafahrt von drei
Prozent ueber zweieinhalb Sekunden, feines Korn gegen Streifen in
dunklen Flaechen. Der Abspann bekommt eine eigene hellere Flaeche und
einen Strich -- er ist das Einzige, was der Mensch danach noch tun
soll.

Der Untertitel: Glasflaeche mit Farbstreifen links. Rechts bleiben
17 % frei, dort liegt auf TikTok die Knopfreihe; unten bleibt Platz
fuer Beschreibung und Ton. Vorher war es ein graues Kaestchen mit
gruenem Rand ueber die ganze Breite.

Der Vorhang: Jeder Seitenwechsel laeuft dahinter. Vorher war jeder
Wechsel im Bild -- „Eintraege werden geladen ...", halb aufgebaute
Raster, ein kurzes Aufblitzen. Zusammen sah das nach
Bildschirmaufnahme aus und nicht nach Film.

EINE FUNKTION STATT EINER ZEICHENKETTE

Die Buehne war ein Text, der im Browser ausgewertet wurde, und darin
noch ein Text fuer das Stilblatt -- zwei Ebenen Anfuehrungszeichen, in
denen jedes Backtick von Hand entschaerft werden musste. Dabei ist
jede zweite Aenderung kaputtgegangen. Playwright nimmt auch eine echte
Funktion und reicht ein Argument durch; die ganze Schicht faellt weg.

VIER FEHLER, DIE NUR DAS EINZELBILD GEZEIGT HAT

  1. WAISEN IN DER ZEILE. „Ein Kommentar ist nach / nach / drei
     Sekunden weg." -- ein von Hand gesetztes <br> traf eine Zeile,
     die ohnehin umbrach. Ein Umbruch, den jemand vor drei Tagen
     gesetzt hat, weiss nichts von der Schriftgroesse von heute.
     `text-wrap: balance` rechnet ihn aus; die elf Handumbrueche
     sind weg.
  2. DER STRICH UNTER DEM ABSPANN WAR NIE ZU SEHEN. Er steht in
     einem <span>, und ein span ist inline -- Hoehe, Breite und
     margin:auto gelten dort nicht. Gebaut, unsichtbar, und ohne das
     Bild haette ich weiter geglaubt, er sei da.
  3. DER VORHANG GING ZU FRUEH AUF, dreimal aus drei Gruenden. Erst
     standen 700 ms -- eine Zahl. Dann eine einmalige Frage „steht
     irgendwo, dass geladen wird?" -- und die traf den Moment, BEVOR
     die Seite ihren Ladehinweis gezeichnet hatte. Dann 450 ms Ruhe
     -- zu kurz, weil eine Seite ihre Abschnitte nacheinander laedt
     und es zwischen zweien kurz still ist. Jetzt: 650 ms am Stueck
     ohne Hinweis, ein Aufflackern setzt die Uhr zurueck.
  4. DAS LEUCHTEN AUF DEN BLAUEN WOERTERN war zu breit und hat die
     Buchstabenkanten gefressen; im Video wurde daraus Matsch.

NICHT `networkidle` ABGEWARTET, UND ZWAR ABSICHTLICH: Der Chat haelt
einen Ereignisstrom offen, der nie endet. Ein Warten darauf liefe dort
immer in die Frist und haenge jedem Seitenwechsel neun Sekunden an --
dieselbe Falle wie am 06.09.2026 bei `await antwort.text()` auf einen
Stream.

Alle vier Videos neu gedreht: 1080x1920, 24 bis 33 s, Lecksuche
unveraendert scharf.
2026-09-27 19:05:48 +02:00
DogFatherGit d465c89289 Ein Werkzeug, das abstuerzt, soll abstuerzen
Das Videowerkzeug hat zweimal zwoelf Minuten lang an nichts
gearbeitet. Es war nicht langsam, es war tot: Beim Fuellen des
Medienregals warf es 'no such table: material', und index.js faengt
uncaughtException ab und protokolliert nur. Fuer den Betrieb ist das
richtig -- ein Fehler in einer Nebensache darf die Website nicht
offline nehmen. Wer die Datei importiert, erbt das Netz, und der
laufende HTTP-Server haelt den Prozess danach am Leben.

Von aussen sieht das aus wie 'rechnet noch'. Ein Stillstand ist
schlimmer als ein Fehlschlag: Ein Fehlschlag ist rot und steht da.

Genau diese Falle steht seit dem 06.09.2026 in den Projektregeln
('Auffangnetze im Betrieb sind Fallen in der Pruefung'), und ich bin
trotzdem hineingelaufen -- weil ich das Werkzeug nicht als Pruefung
verbucht habe, sondern als Werkzeug. Der Unterschied ist keiner:
Entscheidend ist, wer index.js importiert.

process.on ERGAENZT, es ersetzt nicht. Die Meldung von index.js kommt
weiterhin, danach endet der Lauf mit Rueckgabewert 8.

Beide Richtungen nachgefahren: PROBE_ABSTURZ=ja endet sofort mit 8,
ein normaler Lauf endet mit 0 und einem fertigen Video. Ein Netz, das
man nie hat reissen sehen, ist eine Hoffnung.
2026-09-27 18:46:53 +02:00
DogFatherGit de4a9d0771 Der Commit-Text gehoert nicht ins Verzeichnis
Beim Committen mit -F landet die Textdatei ueber 'git add -A' selbst
im Stand. Sie ist Arbeitsmaterial wie die Drehprotokolle und die
Sammellaeufe -- alle drei stehen jetzt in .gitignore.
2026-09-27 18:34:46 +02:00
DogFatherGit cf6c72b3d8 Beim Laden stand "SPICY MEDIA - Zentrale" auf jeder Startseite
WAS DAS BILD GEZEIGT HAT

Beim Durchsehen der Einzelbilder einer Videoaufnahme -- vier
Sekunden lang, gleich nach dem Laden:

    ---- SPICY MEDIA ----
         Zentrale
       WIRD GELADEN

Und zwar auf der Startseite eines COMMUNITY-MITGLIEDS.

In start.html steht die Vorgabe des Agenturhauses. Das Teamhaus
bekommt eigene Worte ("Team Dogi" / "Die IrrenAnstalt"), aber erst
wenn /api/ich geantwortet hat. Bis dahin las jeder Modi und jedes
Community-Mitglied die Marke der Agentur in der groessten Schrift
der Seite. Auf einem langsamen Telefon sekundenlang, bei jedem
Aufruf.

Das ist kein Schoenheitsfehler. Spicy Media ist der Betrieb hinter
der Agentur, und seit dem 24.09. sind die beiden Haeuser getrennt.
Genau diese Zeile hat die Trennung bei jedem Seitenaufruf kurz
aufgehoben -- und niemandem ist es aufgefallen, weil die Seite
DANACH richtig aussah. Wer hinsieht, sieht den Endzustand.

GELOEST OHNE SPRUNG

`data-wartet` macht die zwei Zeilen durchsichtig, nicht leer -- der
Platz bleibt stehen, es ruckt nichts. start.js nimmt das Merkmal
weg, sobald es weiss, in welchem Haus es ist; gemessen dauert das
351 ms.

Eine Notbremse in der Seite nimmt es nach vier Sekunden notfalls
selbst weg. Ohne sie waere die groesste Schrift der Seite fuer immer
unsichtbar, falls start.js gar nicht erst laeuft -- und "unsichtbar"
waere schlimmer als das falsche Wort.

DIE PRUEFUNG DAZU -- UND ZWEI MESSFEHLER DARIN

pruef-start-ansicht misst ab jetzt beide Richtungen: nach dem Laden
muessen beide Zeilen sichtbar sein, mit gesetztem `data-wartet`
unsichtbar. Ohne die zweite Haelfte koennte die Regel spurlos
verschwinden, ohne dass etwas rot wird.

Die Pruefung selbst hat mich zweimal getaeuscht, und beide Male
lehrreich:

  1. Sie mass 900 ms nach der Anmeldung -- eine feste Pause. Ob
     /api/ich in dieser Zeit geantwortet hat, haengt vom Rechner ab.
     Dieselbe Pruefung war einmal gruen und einmal rot, bei
     unveraendertem Code. Gewartet wird jetzt auf das Merkmal selbst,
     und wie lange es gedauert hat, steht im Meldetext.

  2. `opacity` hat einen Uebergang von 180 ms, und getComputedStyle
     liefert waehrenddessen den laufenden Zwischenwert statt des
     Ziels. Erst meldete die Gegenprobe "nicht verdeckt", dann die
     Messung davor "nicht sichtbar" -- beide Male war die Regel in
     Ordnung und nur die Animation im Weg. Fuer die Messung wird der
     Uebergang jetzt abgeschaltet.

DAS VIDEOWERKZEUG

server/tiktok-videos.mjs nimmt die vier Clips auf. Sechs Aenderungen,
jede aus einem Einzelbild:

  - DIE NACHTRUHE HAT DIE VORBEREITUNG MITBLOCKIERT. Video 2 zeigte
    einen leeren Chat. Gemeldet wurde "gefuellt", weil nur geprueft
    war, dass es den RAUM gibt. Jetzt werden die Nachrichten
    zurueckgelesen und gezaehlt; unter acht wird nicht gefilmt.
  - DOGI-MEDIA WAR LEER -- ein Video ueber Material zum Mitnehmen,
    und auf dem Bildschirm stand "Gerade ist nichts frei". Sechs
    echte Marken-Bilder werden eingestellt, zwei davon schon
    genommen, damit die Aussage des Films auch im Bild steht.
  - "WILL ICH AUCH - 0" an jedem Wunsch, waehrend der Untertitel
    sagte "die anderen sehen, wer mitwill". Ein Versprechen, das das
    Bild gleich wieder einkassiert, ist schlimmer als ein leeres
    Brett. Jetzt wird ueber die echte Route gestimmt, absteigend
    6/5/4 -- unter zehn Stimmen wird nicht gefilmt.
  - DER WAECHTER FUER VIDEO 2 verglich zwei feste Zahlen
    (TREFF_NACHT_AB === "0"). Das ist die Abschrift einer Einstellung
    und keine Frage nach dem Zustand: Jedes andere Fenster, das den
    Chat genauso schlafen legt, wurde abgewiesen. Gefragt wird jetzt
    `istNachtruhe()` -- dieselbe Funktion, die auch die Seite
    befragt. Mit 12 bis 6 steht im Bild "Ab 06:00 Uhr geht es
    weiter" statt "Ab 24:00 Uhr", und das ergibt fuer einen
    Zuschauer ueberhaupt erst Sinn.
  - ROLLEN IM KASTEN STATT IN DER SEITE. `window.scrollBy` bewegt
    die Seite; im Chat rollt aber der Verlauf in seinem eigenen
    Kasten. Drei Bilder hintereinander sahen gleich aus.
  - EINE FESTE PIXELZAHL TRAF DEN FALSCHEN BLOCK. Video 4 filmte
    den Katalog der fertigen Vorschlaege statt der Wuensche, weil
    "480 nach unten" zufaellig dort endete. `b.zu(wahl)` misst, wo
    das Element steht.

Dazu: Umlaute in allen Bildtexten ("Tueren" stand im Video), der
Abspann bleibt am Ende stehen (vorher sah man die letzte Sekunde
wieder die App), und die Dateigroessen im Regal sind echt.

KEIN LINK, UND ZWAR GEMESSEN

Nach jedem Seitenwechsel wird der SICHTBARE Text nach Adressen
abgesucht -- dogfather-universe, https://, www., jeder Domainname.
Bei einem Fund bricht die Aufnahme ab, und es wird keine Datei
geschrieben. Das mit dem Auge zu pruefen waere genau die Sorte
Kontrolle, die beim vierten Video nachlaesst.

Mit PROBE_LECK=ja schiebt die Suche selbst eine Adresse ins Bild --
nachgefahren, Rueckgabewert 2. Eine Suche, die nur "nichts gefunden"
sagen kann, hat nichts bewiesen.

Gemessen: pruef-start-ansicht 160/0 (vorher 157), pruef-buehne
242/0, pruef-handy 177/0, pruef-kachelraster 24/0,
pruef-css-klassen 33/0.
2026-09-27 18:34:10 +02:00
DogFatherGit 9c45cdc218 Drei Pixel breite Kacheln auf kleinen Handys -- und der Block war auf
der Pruefadresse tot

DER RASTERFEHLER

Auf einem 320 px breiten Geraet waren "Rudel-Chat" und "Anschlagbrett"
DREI PIXEL breit: ein senkrechter Strich mit abgeschnittenem Text. Bei
360 px waren es 43 px. Gefunden habe ich es nicht mit einer Pruefung,
sondern mit dem Auge, in einem Einzelbild einer Videoaufnahme.

Die Ursache stand in start.css: Unter 380 px wird das Kachelraster
einspaltig (`grid-template-columns: 1fr !important`), die
Willkommenskachel behielt aber ihr `grid-column: span 2` aus einem
Block, der 2600 Zeilen spaeter steht und deshalb gewinnt. Ein Gitter
mit einer erklaerten Spalte und einem Kind, das zwei braucht, erfindet
die zweite -- und teilt den Platz 3 zu 281.

WARUM ES KEINE PRUEFUNG GEMERKT HAT, und das ist der eigentliche
Befund: pruef-handy misst Ueberhang und Beruehrziele. Eine 3 px breite
Kachel ragt nicht hinaus, und ihr Link ist 142 px HOCH -- die
Mindestgroesse fuer den Finger war also erfuellt. Beide Pruefungen
waren gruen, und die Kachel war unbenutzbar.

pruef-kachelraster misst deshalb ab jetzt die BREITE jeder Kachel
mit, bei 320, 360 und 390 px. Die Grenze ist abgeleitet und nicht
gesetzt: Eine Kachel muss mindestens ein Drittel der Inhaltsbreite
haben -- schmaler waere sie auch bei drei Spalten nicht. Dazu wird
gezaehlt, wie viele Spuren das Gitter wirklich hat; eine erfundene
Spalte faellt damit auf, bevor jemand sie sieht.

Gegenprobe gefahren: Ohne die neue Regel meldet die Pruefung
"320 px: schmalste Kachel 3 px -- Rudel-Chat 3px, Anschlagbrett 3px"
und wird rot. 15 -> 24 Punkte, 0 Fehler.

DER NOTIZBLOCK AUF DER PRUEFADRESSE

pruef-handy meldete auf notizen.html einen 404 in der Konsole, auf
allen drei Geraetebreiten. Kein Anzeigefehler: Die Seite lud, der
Block blieb leer.

Meine Schranke fragte `haus !== "crew"`. Das klingt richtig und ist es
nicht -- auf einer Pruefadresse (127.0.0.1) hat niemand ein Haus,
`person.haus` ist dort absichtlich `null`, damit die Pruefungen des
Hauses nicht still blind werden. Damit antwortete JEDER Aufruf des
Blocks dort mit 404.

hausWo() in workspace.js macht es seit dem 24.09. richtig herum: Ist
das Haus weder crew noch agentur, wird NICHT gefiltert. Die Schranke
folgt jetzt derselben Regel und weist das ANDERE Haus ab statt "alles
ausser crew". Die Trennung bleibt unveraendert scharf.

pruef-notizen misst ab jetzt BEIDE Enden -- auf `workspace.` 404, auf
der Pruefadresse 200. Wer nur eins misst, kann die Schranke jederzeit
wieder zu scharf stellen, ohne dass etwas rot wird. 76 -> 79 Punkte.

DAS WERKZEUG FUER DIE VIDEOS

server/tiktok-videos.mjs nimmt vier Clips ueber die App auf (eigene
Wegwerf-Datenbank, eigener Port, nie die echte). Eingebaut ist eine
Lecksuche: Nach jedem Seitenwechsel wird der SICHTBARE Text nach
Adressen abgesucht, und bei einem Fund bricht die Aufnahme ab. Filipe
am 27.09.: "es darf kein link zu sehen sein." Das mit dem Auge zu
pruefen waere genau die Sorte Kontrolle, die beim vierten Video
nachlaesst. Mit PROBE_LECK=ja laesst sich zeigen, dass sie anschlaegt
-- nachgefahren, Rueckgabewert 2.

Gemessen: pruef-handy 177/0 (vorher 3 Fehler), pruef-kachelraster
24/0, pruef-notizen 79/0, pruef-start-ansicht 157/0,
pruef-handy-teamdogi 0 Befunde.
2026-09-27 17:34:16 +02:00
DogFatherGitandClaude Opus 5 150555bc33 Der Notizblock -- und eine Pruefung, die zwoelf Kacheln nie angesehen hat
Filipe: „fuer die modis, rechte und linke hand und dogfather eine
kachel hinzufuegst, sie soll: Notizen, heissen. ich will dass du das
auch wie ein notizblock erstellst. ich will dass es uebelst geil und
einzigartig ist."

=================================================================
TEIL 1: DIE FARBE -- UND WAS DABEI AUFFIEL
=================================================================

Fuer die neue Kachel braucht es einen Ton. Beim Suchen fiel auf, dass
tools/kachelton-entzerren.mjs zwoelf der 45 Farben fuer FREI hielt.

Sie sind es nicht. bereicheFuer() gibt fuer die fuenf Rollen des
Agenturhauses null zurueck -- das heisst „nimm die Liste aus dem
Browser", und die steht in workspace/assets/js/bereiche.js. Dort
stehen die Toene 1 bis 22 und 44: Dashboard, Zahlen, Team-Lage,
Scout-Pipeline. Kacheln, die jeden Tag jemand ansieht.

pruef-kachelfarben hatte denselben blinden Fleck. Sie meldete seit
Wochen „33 benutzte Toene" und war gruen. Was sie dadurch NICHT sah:

  * Der Farblauf von gestern Nacht hat zwoelf dieser Kacheln
    verschoben und drei davon unter die Buntheitsgrenze gedrueckt.
    Gruen geblieben.
  * Ton 7, 16 und 22 lagen SEIT JEHER unter der Grenze (0,112 / 0,116
    / 0,068). Nie gemeldet.

Das ist die Sorte gruener Haken, vor der die Hausregel warnt: Er sagt
nur, dass die Bedingung erfuellt war -- nicht, dass sie das Richtige
angesehen hat.

BEHOBEN, und zwar an der Wurzel: Die REGELN (Kontrast, Buntheit)
gelten jetzt fuer jeden Ton, der irgendwo an einer Kachel steht --
gelesen aus denselben fuenf Dateien, die auch pruef-kachel-universum
liest. Dazu die Namen der Kacheln, damit „Ton 1 traegt Dashboard"
ueberhaupt pruefbar ist.

DIE PALETTE WURDE NEU GERECHNET, mit dem dritten Verfahren an einem
Tag -- die ersten zwei stehen als Fehler im Kopf des Werkzeugs:

  1. Alle 45 neu verteilt. Lief ueber zwei ausdrueckliche Wuensche
     hinweg (#ff1a1a, #a8d8ff).
  2. Nur die Kollisionen, aber immer nur EINEN der beiden bewegt.
     Ergebnis: Ton 1 sprang vom Tuerkis ins Altrosa, 0,197 weit.
  3. BEIDE duerfen sich bewegen, und zwar beide nur ein bisschen.
     Zwei Toene, die 0,02 auseinanderstehen und 0,09 brauchen, teilen
     sich das -- jeder rueckt 0,045, und beide bleiben, was sie waren.

  kleinster Abstand   0,0154  ->  0,0905
  zu blass                 4  ->  0
  sichtbar veraendert           7 Kacheln (ueber 0,05)
  kaum zu sehen                28 (13 zwischen 0,02 und 0,05, 15 darunter)

EINE AUSNAHME MIT ZAHL, keine mit Achselzucken: Ton 1 (Dashboard)
bleibt acht Tausendstel unter der Buntheitsgrenze. Gemessen ueber ALLE
16,7 Millionen sRGB-Farben ist die naechste, die alle Regeln haelt,
#7a75c8 -- ein Blauviolett, 0,113 entfernt. Tuerkis erreicht in sRGB
schlicht keine hoehere Buntheit, und das schmale Band teilen sich
schon sieben Toene. Eine Kachel, die ihre Farbfamilie behaelt, ist die
bessere Antwort auf „richtig geil und speziell" als eine, die acht
Tausendstel bunter ist und niemand wiedererkennt. blassBis sagt jetzt
bei jeder Ausnahme, WIE WEIT sie reicht -- vorher hiess blassErlaubt
schlicht „hier wird weggesehen".

DIE NEUE FARBE IST GRUEN UND WOLLTE BERNSTEIN SEIN. Gesucht war die
Farbe von Papier. Gemessen gibt es sie nicht mehr: Der beste Bernstein
im ganzen Farbraum haelt 0,0799 Abstand -- unter der Hausgrenze. Und
Platz schaffen hilft nicht: Setzt man ihn fest, muss ein warmer Ton
das Band verlassen (Ton 45 waere 0,212 weit ins Magenta gewandert).
Die groesste wirklich freie Luecke liegt im Gruen, bei 0,0917. Ein
linierter Block in Gruen ist Papier, seit es Papier gibt.

=================================================================
TEIL 2: DER BLOCK
=================================================================

DREI ENTSCHEIDUNGEN, AUS DENEN DER REST FOLGT:

1. ES GIBT KEINEN SPEICHERN-KNOPF. Nirgends. Wer einen Block
   aufschlaegt und lostippt, drueckt hinterher nicht auf „sichern";
   er klappt ihn zu. Gesichert wird 800 ms nach dem letzten
   Tastendruck. Geht das nicht, bleibt der Text im Browser liegen und
   wird nachgereicht -- und der Fuss sagt ehrlich „noch nicht
   gesichert", statt einen Verlust zu melden, den man gerade nicht
   verhindern kann.

2. DAS BLATT IST EIN SCHREIBFELD MIT EINER DECKSCHICHT. Das Feld
   traegt Text und Cursor und ist unsichtbar; darueber zeichnet eine
   zweite Schicht denselben Text noch einmal -- mit Kaestchen,
   Ueberschriften, Strichen und Links.

   DARAUS FOLGT EINE EISERNE REGEL, und sie steht dreimal im Code:
   KEINE Auszeichnung darf die BREITE eines Zeichens aendern. Kein
   Fettdruck, keine andere Groesse, keine Sperrung. Erlaubt sind nur
   Farbe, Flaeche, Rahmen und Durchstreichen. Ein einziges
   font-weight: 700 verschoebe den Umbruch, und ab der zweiten Zeile
   stuende der sichtbare Text neben dem Cursor.

   pruef-notizen misst das am echten Umbruch: eine Probe mit allen
   Auszeichnungen und einer Zeile, die umbrechen MUSS. Sind beide
   Schichten verschieden hoch, sitzt der Umbruch woanders. Mit
   Gegenprobe -- Fettdruck auf der Deckschicht bricht die Deckung um
   genau eine Zeile (30 px), und die Zeile wird rot.

3. DIE TASTATUR FUEHRT DIE LISTE FORT, NICHT EINE LEISTE. Ein
   Spiegelstrich und Enter macht den naechsten Punkt, ein leeres
   Kaestchen und Enter das naechste Kaestchen -- und ein GESETZTER
   Haken wird dabei nicht mitgenommen, die naechste Aufgabe ist ja
   noch nicht erledigt. Zweimal Enter beendet die Liste. Ein Klick
   aufs Kaestchen hakt ab. Es gibt keine Werkzeugleiste, weil man beim
   Schreiben nie den Stift wechselt.

UND: NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht.
Es gibt in workspace-notizen.js keinen siehtAlles()-Zweig, keinen
Umschalter, keine fremde Liste -- jede Abfrage hat person_id = ? fest
eingebaut. Die Kachel steht deshalb in „Fuer dich" und nicht in
„Taeglich": Die Gruppe sagt die wichtigste Eigenschaft, bevor man sie
anfasst.

NUR AUF DER TEAM-ADRESSE. Auf der Agenturadresse ist die Seite ein
404 -- auch fuer DogFather. Das ist dieselbe Trennung wie bei Chat,
Kalender und Aufgaben, und sie steht an zwei Stellen: in
GEHOERT_ZU_ADRESSE (die Datei) und in der Schnittstelle (die Daten).
Die Seite ist nur HTML; die Daten sind die Sache.

Dazu: sechs Papierfarben, Anheften, Suche mit Hervorhebung im Text,
Papierkorb mit dreissig Tagen und einem Eintrag in der
Aufbewahrungsliste (ohne den waere die Frist ein Satz in einem
Kommentar -- genau das ist dem Support am 24.09. passiert).

=================================================================
EIN FUND BEIM BAUEN, DER ALLEN GEHOERT
=================================================================

DELETE /workspace/api/notizen/:id gab es schon -- in
workspace-aufgaben.js, fuer die Notizen AN einer Aufgabe. Weil jener
Router frueher eingehaengt ist, hat er jeden Loeschversuch des Blocks
abgefangen und mit 404 beantwortet.

Von aussen sah das aus wie ein Fehler im neuen Modul: Anlegen ging,
Aendern ging, Loeschen nicht. Man sucht dann im eigenen Code, und dort
ist nichts. Express meldet so etwas nicht -- es nimmt die erste Route,
die passt, und schweigt ueber die zweite.

Der Block liegt jetzt unter /workspace/api/notizblock. Und
pruef-notizen geht seitdem den Routenbaum von express durch und meldet
jedes Paar aus Methode und Pfad, das zweimal vergeben ist. Gemessen:
307 Wege, keine Dublette. Die Pruefung gilt fuers ganze Haus, auch
wenn sie in dieser Datei steht -- hier ist sie gefunden worden.

Nebenbei berichtigt: pruef-crew-adresse verlangte von jedem Eintrag
der Haustafel HTTP 200 mit Inhalt. Das stimmte, solange dort nur
oeffentliche Dateien standen. notizen.html ist die erste Seite hinter
der Anmeldung -- sie antwortet mit 302, und das ist richtig. Gefragt
wird jetzt, was die Tafel wirklich meint: GIBT es das hier.

GEMESSEN, alles nach den Aenderungen:

  pruef-notizen             76 Punkte, 0 Fehler (neu)
  pruef-kachelfarben        31 Punkte, 0 Fehler (vorher 26)
  pruef-kachel-universum    13 Punkte, 0 Fehler
  pruef-crew-adresse       157 Punkte, 0 Fehler
  pruef-buehne             230 Punkte, 0 Fehler -- notizen.html neu in
                           der Liste, schlechtester Kontrast 6,73:1,
                           also 50 % ueber der Grenze
  pruef-rechtetafel, -haus-seiten, -struktur, -css-klassen,
  -jeder-hat-eine-seite, -workspace-seiten, -kachelraster,
  -rueckmeldung            alle 0 Fehler

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-26 12:42:59 +02:00
DogFatherGitandClaude Opus 5 69ba533cee Wer mit der Tastatur bedient, sieht wieder, wo er steht
pruef-barrierefrei-workspace, wissen.html, Scout und Creator: Beim
Durchtabben veraendert sich an den Wissenskacheln NICHTS. Kein Rahmen,
kein Ring, keine Kante. Wer nicht mit der Maus arbeitet, tippt blind.

=== EIN SPEZIFITAETS-UNFALL, UND ER BETRIFFT NICHT NUR DIESE SEITE ===

Die Regel war da und richtig geschrieben:

  wissen.css   .kachel:focus-visible { box-shadow: 0 0 0 3px ... }

Sie kam nur nicht an. Gemessen mit einem neuen Werkzeug
(server/mess-fokus.mjs, echte Tastendruecke, kein focus()):

  :focus true   :focus-visible true
  boxShadow     gleich   rgba(0,0,0,0.95) 7px 7px 14px -10px inset
  outline       gleich   none

`:focus-visible` griff also, und trotzdem blieb alles, wie es war.
Der Grund steht in module.css, in einer Liste von 45 Klassennamen:

  :is(.eintrag-karte, ..., .kachel, ..., .gruppe[data-gruppe], ...)

`:is()` uebernimmt die Spezifitaet seines STAERKSTEN Arguments.
`.gruppe[data-gruppe]` ist eine Klasse PLUS ein Attribut. Damit ist die
ganze Liste (0,2,0) statt (0,1,0) -- genau so stark wie
`.kachel:focus-visible`. Bei Gleichstand gewinnt, was spaeter geladen
wird, und module.css wird zuletzt geladen. Ein einziges Attribut in
einer Aufzaehlung, sechs Zeilen weiter rechts, hat den Fokusring von
jedem Bauteil im Haus verschluckt, dessen Fokusregel aus einer Klasse
besteht.

=== GELOEST WIRD DAS NICHT, INDEM MAN DIE LISTE SCHWAECHER MACHT ===

Das war schon einmal so (`:where()`, Spezifitaet null) und ergab einen
Zwitter aus neuer Form und alter Kante -- der Kommentar in module.css
beschreibt es. Wer die Zahl senkt, verschiebt das Problem auf die
naechste Regel.

Geloest wird es, indem die ANTWORT dort steht: ein Fokusring fuer die
ganze Modulliste, in derselben Datei wie die Form, mit
`:focus-visible` also eine Klasse staerker als die Grundregel. Er gilt
damit fuer jedes Modul im Haus -- auch fuer die, die es noch nicht
gibt, und auch dort, wo nie jemand an eine Fokusregel gedacht hat.

`outline-offset` ist NEGATIV, und das ist kein Geschmack: `clip-path`
(die Fase an der Ecke) schneidet alles ab, was ausserhalb der Form
liegt. Ein Ring mit positivem Abstand waere unsichtbar gewesen -- der
alte war es ja auch. Nach innen gezeichnet bleibt er stehen. Gemessen,
nicht geschlossen.

=== WAS NICHT MITREPARIERT WURDE, UND WARUM ES DASTEHT ===

Derselbe Gleichstand trifft auch Hover-Regeln in frueher geladenen
Dateien: `.ablage:hover` (dateien.css), `.call:hover` (calls.css),
`.kk:hover` (scouting.css), `.fortschritt:hover` (uebersicht.css)
setzen alle `border-color`, und die Grundregel setzt `border: 0`.
Diese vier tun vermutlich nichts.

Angefasst habe ich sie nicht -- es gibt kein Messgeraet dafuer. Fokus
laesst sich pruefen (die Pruefung tabbt und vergleicht), Hover nicht.
Und die naheliegende Loesung wuerde die Form von 45 Bauteilen auf 38
Seiten neu entscheiden; das ohne Messgeraet zu tun waere Raten mit viel
Einsatz. Der Befund steht deshalb als Absatz in module.css, damit der
Naechste nicht wieder bei null anfaengt.

=== UND EINE BEHAUPTUNG VON MIR WIRD ZURUECKGENOMMEN ===

Im Commit d7bb0f7e steht, ein Mindestabstand von 0,10 sei fuer 45
Kachelfarben „rechnerisch nicht mehr moeglich", mit einer Tabelle von
„Decken". Das ist falsch herum gelesen: Das Werkzeug SUCHT eine
Anordnung. Findet es nichts Besseres, sagt das etwas ueber das
Verfahren, nicht ueber die Welt. Nachweisbar ist nur, was es GEFUNDEN
hat -- und wie wenig das eine Decke ist, zeigt der eigene Lauf: Fuer
dieselben 45 Farben kam dasselbe Verfahren je nach Rasterweite einmal
auf 0,0974 und einmal auf 0,0876.

Die Pruefung sagt das jetzt so, und das Werkzeug heisst zwar weiter
kachelton-decke.mjs, erklaert aber in den ersten zwanzig Zeilen, dass
es keine Decke misst. Beide Werkzeuge liegen jetzt ueberhaupt im
Verzeichnis: Sie hiessen `tools/_toene-*.mjs`, und `tools/_*` ist
ausgenommen -- die Kommentare verwiesen also auf Dateien, die es nach
dem Klonen nirgends gibt.

GEMESSEN, alles nach den Aenderungen:

  pruef-barrierefrei-workspace   18 Punkte, 0 Fehler (vorher 2)
  pruef-buehne                  230 Punkte, 0 Fehler
  pruef-kachelraster             15 Punkte, 0 Fehler
  pruef-start-ansicht           157 Punkte, 0 Fehler
  pruef-kopfleiste-farbe          9 Punkte, 0 Fehler
  pruef-rueckmeldung             30 Punkte, 0 Fehler
  pruef-entwicklung-kacheln      34 Punkte, 0 Fehler
  pruef-kachel-universum         13 Punkte, 0 Fehler

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 23:50:54 +02:00
DogFatherGitandClaude Opus 5 80988f2d23 Berichtigt: Die Rechnung hat zwei ausdrueckliche Wuensche ueberfahren
Der Commit davor hat alle 45 Kachelfarben neu verteilt, um acht fast
gleiche Paare zu trennen. Das Ergebnis war rechnerisch besser und
praktisch falsch. pruef-kachelfarben hat sechs Fehler gemeldet:

  Ton 38  #ff1a1a -> #ff241e   „nur die soll knall rot sein!!!"
  Ton 44  #a8d8ff -> #90c3ff   „soll auch babyblau sein mit bissl lila"
  Ton 40, 10, 33          unter die Buntheitsgrenze von 0,12 gedrueckt
  und die Regenbogenkachel zeigte noch die 32 alten Farben

Beide Farben standen mit Datum, Zitat und Begruendung in der Pruefung.
Ich habe sie nicht gelesen, weil ich ein GLOBALES Ziel optimiert habe
-- „alle 45 moeglichst weit auseinander" -- und eine globale Rechnung
kennt keine Einzelfaelle.

=== DIE LEHRE IST NICHT „DIE RECHNUNG WAR FALSCH" ===

Sondern: Eine Gesamtloesung fuer ein LOKALES Problem bezahlt man mit
allem, was an den nicht betroffenen Stellen schon richtig war. Acht
Paare standen zu eng. Verschoben wurden 45.

Der zweite Anlauf (tools/_toene-reparieren.mjs) macht es umgekehrt:
Solange ein Paar zu eng steht, wird das engste genommen und EINER der
beiden auf die naechste erlaubte Farbe geschoben. Sonst nichts.

Und dabei kam das Beste des Tages heraus -- gemessen, nicht geahnt:
Von den 45 Toenen tragen nur 33 eine Kachel, zwoelf stehen bereit. In
JEDEM der acht zu engen Paare ist mindestens einer dieser zwoelf
dabei. Wer den Unbenutzten verschiebt, loest dasselbe Problem, ohne
dass sich fuer irgendjemanden auch nur eine Kachel veraendert.

  kleinster Abstand   0,0154  ->  0,0940
  Paare unter 0,09        14  ->  0
  veraendert                       14 von 45
  davon in Gebrauch                 4 -- und zwar um 0,003 bis 0,012

Vier Zehntausendstel OKLab sind nicht zu sehen. Es gibt also KEINE
Kachel, die ihre Farbe wechselt. Das ist mehr als Bequemlichkeit: Wer
zwei Jahre lang „das Tuerkise" angesteuert hat, sucht danach, wenn es
plötzlich rosa ist.

=== UND DIE WUENSCHE STEHEN JETZT DORT, WO GERECHNET WIRD ===

Die Liste FESTGELEGT ist von server/pruef-kachelfarben.mjs nach
tools/kachelton-regeln.mjs gewandert -- neben die Schwellen, aus
genau demselben Grund, aus dem die Schwellen dort stehen.

In der Pruefung konnte sie nur EINES: eine Abweichung melden, nachdem
sie passiert ist. Das hat sie getan, und das ist ihr Verdienst. Aber
eine Bedingung, die erst NACH dem Schaden spricht, ist die schwaechere
Form. Wer die Farben rechnet, soll gar nicht erst die Moeglichkeit
haben, sie zu verschieben -- das Werkzeug liest die Liste jetzt selbst
und setzt festgelegte Toene sogar zurueck, wenn es sie verschoben
vorfindet.

Dasselbe gilt fuer die Buntheitsgrenze: Sie kommt aus der Regeldatei,
und sie gilt nur fuer benutzte Toene -- so, wie die Pruefung sie
anwendet. Das ist nicht Nachgiebigkeit, sondern Rechnung: Mit
Buntheit >= 0,12 fuer ALLE 45 ist ein Mindestabstand von 0,09
nachweislich unmoeglich (Decke 0,0853). Die zwoelf unbenutzten duerfen
blasser sein und nehmen genau den Druck aus dem engen
Blau-Tuerkis-Bereich, an dem der erste Anlauf gescheitert ist.

GEMESSEN: pruef-kachelfarben 26 Pruefungen, 0 Fehler (vorher 26 mit
6 Fehlern). pruef-kachel-universum 13 Pruefungen, 0 Fehler.
Regenbogenkachel mit tools/kachel-regenbogen.mjs neu geschrieben --
99 Punkte in drei Groessen, je 33 Farben.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 23:44:40 +02:00
DogFatherGitandClaude Opus 5 954426615f Der Pruefstand bekommt selbst eine Pruefung -- und der Mensch ein Stueck
der Notiz

Jede Nacht laeuft tools/nachtlauf.mjs ueber 193 Pruefdateien und
schreibt das Ergebnis als Notiz in Filipes Vault. Diese Notiz ist das
Einzige, was er von dem ganzen Aufwand zu sehen bekommt.

Und sie war die einzige Datei im Haus, die niemand geprueft hat.

=== WARUM DAS VORHER GAR NICHT GING ===

nachtlauf.mjs startet BEIM IMPORT sofort einen Lauf. Wer die Notiz
messen wollte, haette damit eine halbe Stunde Rechenzeit ausgeloest --
und nebenbei das echte Ergebnis auf Filipes Startseite ueberschrieben.
Am 04.09.2026 hat genau so ein Griff bei RunOne einen roten Alarm ueber
einer fehlerfreien Buchhaltung erzeugt, am Morgen einer Vorstellung.
„Ein Prueflauf ist kein harmloser Lesevorgang."

Der Textbau steht deshalb jetzt in tools/nachtlauf-notiz.mjs: nur
Textbau, kein Nebeneffekt, Importieren ist folgenlos.

=== UND EIN STUECK DER NOTIZ GEHOERT AB JETZT DEM MENSCHEN ===

Oben in der Notiz stand: „von Hand aendern bringt nichts, beim
naechsten Lauf ist es wieder ueberschrieben." Das war ehrlich und
trotzdem falsch herum gedacht.

Das Wertvollste an diesem Pruefstand ist naemlich nicht die Zahl,
sondern der Satz daneben: „die hier ist rot, weil sie den Umbau vom
24.09. nicht mitbekommen hat" oder „die ist echt, Finger weg". Genau
dieser Satz wurde jede Nacht geloescht. Heute Nacht standen 37
Dateien in der Liste, und bei 25 davon waere so ein Satz die halbe
Arbeit gewesen.

Jetzt gibt es einen Bereich zwischen zwei Marken, den der Lauf wieder
einsetzt statt ihn zu ueberschreiben. Fehlt er, wird er leer neu
angelegt -- mit der Erklaerung darin, wozu er da ist.

=== 30 PRUEFUNGEN, JEDE MIT GEGENPROBE ===

  1. Der Block „von Hand" ueberlebt einen Lauf -- UND das alte
     Ergebnis daneben verschwindet wirklich. Ohne die zweite Haelfte
     wuerde die erste nur beweisen, dass die Datei nicht angefasst
     wurde.
  2. Ein leerer Block zaehlt als nicht vorhanden; sonst waere die
     Erklaerung fuer immer weg, sobald jemand sie einmal loescht.
  3. Weniger Pruefungen als gestern werden gemeldet, auch wenn alles
     gruen ist (die Lehre vom 28.08.: 789 statt 804, gruen, und acht
     Pruefungen je Geraet waren still weggefallen). Gegenprobe: Bei
     MEHR Pruefungen darf der Satz NICHT kommen -- eine Warnung, die
     immer kommt, ist keine Warnung mehr.
  4. Der dritte Ausgang sagt „konnte nicht nachsehen", nennt den Grund
     und wie alt der letzte echte Stand ist -- und behauptet nirgends,
     alles sei in Ordnung.
  5. „Keine Pruefung gezaehlt" wird als das benannt, was es ist: kein
     Befund, sondern ein Zaehlproblem. Acht Dateien standen heute Nacht
     aus diesem Grund als rot da.

Punkt 5 der Pruefung hat sich beim ersten Lauf gleich bezahlt gemacht:
Sie zaehlt nach, ob JEDER der vier Aufrufe in nachtlauf.mjs den
Notizpfad mitreicht. Drei davon sind die seltenen Ausgaenge, die man
beim Ausprobieren nie erwischt -- und an jedem einzelnen waere der
Block „von Hand" spurlos verschwunden. Beim Umbau hatte ich genau
diese drei zuerst vergessen.

GEMESSEN: 30 Pruefungen, 0 Fehler. Die echte Notiz im Vault wird dabei
nicht angefasst -- gearbeitet wird in einem mktemp-Ordner.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 23:19:50 +02:00
DogFatherGitandClaude Opus 5 d7bb0f7e60 45 Kachelfarben: acht Paare waren dieselbe Farbe -- und die Pruefung
konnte es nicht sagen

Filipe am 17.09.: „ich will das jede kachel eine andere farbe hat, es
soll keine die gleiche farben haben bitte und auch keine die sich
irgendwie aehnlich sind ... die farben sollen auch richtig geil und
speziell sein."

Gemessen am 25.09.: #ff8fb4 gegen #fe8ebe, Abstand 0,0154 in OKLab.
Das ist mit blossem Auge DIESELBE Farbe. Acht solche Paare gab es.

=== WARUM ES NIEMAND GEMERKT HAT ===

pruef-kachel-universum hat es gesagt. Jede Nacht. Die Zeile stand da:
„kleinster Abstand 0,0154". Gelesen hat sie niemand -- weil direkt
daneben eine zweite Zeile stand, die NIEMALS gruen werden konnte:
„kein Paar unter 0,10".

Nachgerechnet (tools/_toene-packen.mjs, eine echte Kugelpackung ueber
alle sRGB-Farben, die 4,8:1 gegen den Grund halten, nicht blenden und
bunt genug sind):

  30 Farben  ->  0,115        48 Farben  ->  0,0925
  40 Farben  ->  0,103        52 Farben  ->  0,0840
  45 Farben  ->  0,0974       60 Farben  ->  0,0805

Die Forderung war fuer 21 Farben geschrieben. Bei den heutigen 45
liegt die DECKE bei 0,0974 -- „kein Paar unter 0,10" konnte niemand
erfuellen, mit keiner Palette der Welt.

DAS IST DER EIGENTLICHE BEFUND. Eine Bedingung, die niemand erfuellen
kann, macht nicht nur sich selbst wertlos. Sie faerbt die ganze Datei
rot, und ab da liest man die Zeile darueber nicht mehr. Der echte
Mangel lag acht Tage offen da, versteckt hinter einem Fehlalarm.

=== DIE NEUE PALETTE WURDE GERECHNET, NICHT NACHGEBESSERT ===

Von Hand nachbessern hat sie erst dahin gebracht: Am 08.09. waren es
21 Farben, danach kamen sechzehn dazu, jede einzeln gewaehlt, keine
gegen die anderen geprueft. In drei Schritten:

  1. Aus allen erlaubten sRGB-Farben 45 so waehlen, dass der kleinste
     Abstand so gross wie moeglich wird.
  2. Sie den 45 Kachelnummern so zuordnen, dass jede moeglichst nah an
     ihrer bisherigen Farbe bleibt -- eine Kachel soll wiedererkennbar
     sein, sie rueckt, sie wechselt nicht.
  3. Nachziehen: Jeder Ton darf zurueck in Richtung seiner alten Farbe
     wandern, solange der Mindestabstand haelt.

Ergebnis: kleinster Abstand 0,0154 -> 0,0931, kein Paar mehr unter
0,09. Dreissig der 45 Kacheln haben sich um weniger als 0,02 bewegt --
das sieht man nicht. Nur sieben sind sichtbar gewandert, und alle
sieben lagen in dem Gedraenge aus neun fast gleichen Rot- und
Rosatoenen, das den Ausschlag gegeben hat.

„Richtig geil und speziell" bleibt messbar erhalten: Die Buntheit hat
eine Untergrenze von 0,10 in der Rechnung, damit keine Farbe ins Graue
rutscht. Gemessen kostet das nichts -- mit dieser Grenze ist die Decke
sogar minimal hoeher als ohne (0,0974 gegen 0,0973).

=== UND DIE PRUEFUNG SAGT JETZT ETWAS ERFUELLBARES ===

  kleinster Abstand >= 0,09          (Decke fuer 45 Farben: 0,097)
  hoechstens 48 Farben               (darueber ist 0,09 nicht mehr
                                      erreichbar -- gemessen, nicht
                                      gesetzt)
  Gegenprobe: #ff8fb4 gegen #fe8ebe  muss durchfallen

Die zweite Zeile ist die wichtige: Sie bewacht den GRUND. Wer die
46., 47., 48. Kachel anlegt, kommt noch durch; wer die 52. anlegt,
bekommt gesagt, dass jetzt ueber die Palette geredet werden muss,
statt still wieder in zwei gleiche Farben zu rutschen. Genau das ist
zwischen dem 08.09. und dem 17.09. passiert.

GEMESSEN: pruef-kachel-universum 13 Pruefungen, 0 Fehler (vorher 12
mit 2 Fehlern). Schlechtester Textkontrast auf der fertigen Kachel
7,02:1, am Bildschirmfoto gemessen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 23:19:29 +02:00
DogFatherGitandClaude Opus 5 ce1a1f758c Zum dritten Mal dieselbe Schrift zu dunkel -- diesmal mit Luft
entwicklung.html, „Noch niemand im Team": 4,42:1 gemessen, noetig sind
4,5:1. Der Befund ist klein. Die Geschichte dahinter ist es nicht.

=== DREIMAL IN VIER WOCHEN, IMMER DERSELBE GRIFF ===

  01.09.  #6d7d92 -> #75859a    gemessen 4,43  ->  4,95
  07.09.  #75859a -> #7f8fa6    gemessen 4,50  ->  rund 4,9
  25.09.  #7f8fa6 -> #95a4ba    gemessen 4,42  ->  5,75

Neben jedem dieser Schritte steht ein sorgfaeltiger Kommentar, der
erklaert, warum genau dieser Wert richtig ist. Alle drei waren richtig
-- fuer den Untergrund, den man GERADE KANNTE.

Der Hintergrund ist aber ein BILD. Bilder haben helle Stellen, und die
liegen bei jedem neuen Layout woanders. Wer eine Schrift auf zwei
Kommastellen genau gegen die hellste Stelle einstellt, die er heute
gefunden hat, stellt sie auf den naechsten Umbau ein.

=== WAS DIESMAL ANDERS GEMACHT WURDE ===

1. NICHT NUR DIE ROTE SEITE ANGESEHEN. Die schlechtesten Werte ALLER
   vierzehn Seiten nebeneinandergelegt:

     hilfe.html      4,90   auf rgb(51,53,56)   <- die hellste im Haus
     teamlage.html   4,98   auf rgb(45,52,64)
     entwicklung     4,42   auf rgb(40,41,44)   <- die, die rot war
     befinden.html   5,38   auf rgb(29,39,46)
     ... zehn weitere ueber 6,3

   Die rote Seite war also NICHT die gefaehrlichste. Zwei andere lagen
   knapper an der Grenze, als der letzte Rutscher gross war -- sie
   waeren als naechste drangewesen. Haette ich nur entwicklung.html
   repariert, haette ich in zwei Wochen dasselbe noch einmal getan.

2. DER BEZUG IST JETZT DIE HELLSTE FLAECHE IM HAUS, nicht die der
   Seite, die gerade rot ist. Beide Toene beider Haeuser gegen
   rgb(51,53,56) gerechnet:

     Gate    still #7f8fa6 -> #95a4ba   4,35 -> 4,86
             leise #94a5bb -> #9fb0c6   4,90 -> 5,56
     Crew    still #8f88b2 -> #a7a0cb   4,10 -> 5,00
             leise #a9a2ca -> #b4aed6   5,10 -> 5,84

   Der stille Ton im Crew-Haus stand bei 4,10 -- UNTER der Grenze, seit
   Wochen, ohne dass eine Pruefung je etwas gesagt haette. Nicht weil
   sie nachsichtig war, sondern weil noch keine Crew-Seite mit dieser
   Flaeche angesehen wurde. Ein gruener Haken heisst nicht, dass
   nachgesehen wurde.

3. PRUEF-BUEHNE PRUEFT JETZT AUCH DIE LUFT. Nicht nur „ueber 4,5",
   sondern: „hat die schlechteste Stelle dieser Seite noch elf Prozent
   Reserve?" Die Zahl ist gemessen und nicht gewaehlt -- beide
   Rutscher oben waren rund elf Prozent gross, und 4,5 mal 1,11 ist
   5,0. Eine Seite unter diesem Wert ist noch in Ordnung und schon in
   Gefahr, und genau das soll sie sagen, solange man es noch in Ruhe
   beheben kann.

   Sie haengt an der jeweiligen Grenze, nicht an einer festen 5,0: Bei
   grossem Text sind 3:1 erlaubt, dieselbe Luft sind dort 3,33. Eine
   hineingeschriebene 5,0 wuerde grossen Text ohne Not anmahnen.

GEMESSEN: pruef-buehne 230 Pruefungen (vorher 192), 0 Fehler.
Schlechteste Seite jetzt hilfe.html mit 5,15:1 -- 15 % ueber der
Grenze. Die Schrift bleibt deutlich leiser als der Haupttext: der
steht bei 11,8:1, also mehr als doppelt so hoch.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 23:19:04 +02:00
DogFatherGitandClaude Opus 5 1ba9d07af2 Zwoelf rote Pruefungen -- und kein einziger Fehler im Programm
Der Rundumcheck geht weiter. Diese zwoelf standen im Nachtlauf als rot
und waren es nicht: Jede hat den Umbau vom 24.09. (die Haustrennung),
den vom 25.09. (die zugetragenen Punkte) oder eine Umbenennung
mitbekommen -- nur ihre Erwartung nicht.

DAS IST KEIN TROST. Ein Fehlalarm zeigt auf eine echte Stelle und nennt
eine plausible Zahl; man sucht danach an richtigem Code. Und zwoelf rote
Zeilen, die immer rot sind, nehmen dem ganzen Satz die Stimme.

=== DIESELBE WURZEL, SECHSMAL: DAS FALSCHE HAUS ===

Seit dem 24.09. antwortet der Server auf der Agenturadresse nicht mehr
ueber Team-Dogi-Leute -- und genau das taten diese Pruefungen:

  pruef-nachwuchs      legte einen Modi ueber die Agenturadresse an
                       (400). Einundzwanzig Meldungen hingen an diesem
                       einen Aufruf. 82 Zeilen auf crew. umgestellt,
                       aus „Mara, Managerin" wurde die linke Hand --
                       im Teamhaus ist sie das, was gemeint war.
                       230 -> 262 Pruefungen, 0 Fehler.
  pruef-schritt        dasselbe beim Aufgabenbrett (404/400).
  pruef-treff-werkzeuge fragte die Einladungsliste eines Modis auf der
                       Agenturadresse ab -- dort ist er nicht.
  pruef-rechte-umstellen benutzte `wissen.html` als zweite Probeseite.
                       Die ist laut Rechtetafel fuer alle offen und auf
                       crew. trotzdem zu: Dort kommt man nur auf Seiten,
                       fuer die es eine Kachel gibt. Jetzt material.html.
  pruef-personen-formular verglich eine Erwartung FUER die Agentur mit
                       einer Messung OHNE Adresse (127.0.0.1) -- vier
                       gegen acht Rollen. Beides war richtig, nur nicht
                       dasselbe.
  pruef-treff          suchte „Support" zwischen den Brettern. Er steht
                       in „Fuer dich", wo er hingehoert -- eine kaputte
                       Seite zu melden ist kein Aushang. Die Liste stand
                       ZWEIMAL in der Datei; nachgezogen wurde eine.
                       Jetzt eine, von beiden benutzt.

=== ZWEIMAL: EINE FESTE ZAHL, DIE DIE SEITE UEBERHOLT HAT ===

  pruef-community-sicht erlaubte „3 bis 8 Seiten". Es sind zwoelf, und
                       jede gehoert dorthin. Statt einer Spanne steht
                       jetzt die LISTE da: Kommt eine dazu, wird die
                       Zeile rot und nennt sie beim Namen. Eine Spanne
                       haette elf statt zwoelf stillschweigend
                       durchgelassen -- in beide Richtungen.
  pruef-nachwuchs      schaltete EINE Person ab und erwartete, dass die
                       Ampel danach schweigt. Der Kommentar darueber
                       sagt selbst: „Die Schwelle wird nicht
                       abgeschrieben, sondern aus dem Verhalten
                       abgeleitet" -- eine feste Anzahl abzuschalten ist
                       aber eine Abschrift in anderer Waehrung. Jetzt
                       wird abgeschaltet, BIS es kippt.

=== UND VIERMAL EIN WERKZEUG, DAS STUMPF WAR ===

  pruef-scout-zuteilung suchte `.person__zeile` -- eine Klasse, die es
                       nicht mehr gibt. `querySelectorAll` liefert dafuer
                       ein leeres Feld, `.some()` darauf ist immer
                       false: Die Pruefung war nicht rot, weil etwas
                       fehlte, sondern weil sie nichts ansehen konnte.
                       Jetzt `.person`, und die ANZAHL der gefundenen
                       Karten steht in einer eigenen Bedingung.
  pruef-loeschen       suchte `::before` in einem Fenster von 600
                       Zeichen ab dem Selektor. Das misst die Laenge des
                       KOMMENTARS: Am 25.09. kam eine Begruendung von
                       zwanzig Zeilen dazu, und die Regel rutschte
                       hinaus. Jetzt wird nach dem Selektor gesucht.
  pruef-portnummern    hielt jedes `listen(` fuer einen Serverstart --
                       auch das blosse Nachsehen, ob eine Nummer frei
                       ist. Damit mahnte sie ausgerechnet die Datei an,
                       die das Vergeben der Nummern prueft. Jetzt zaehlt
                       der Import von `index.js`; dazu vier Gegenproben,
                       damit die engere Fassung nicht zum blinden Fleck
                       wird.
  pruef-spicy          stellte eine Person auf „manager" und dann
                       weiter. An Managern aendert seit dem 22.09. nur
                       DogFather etwas -- die Pruefung hatte sich selbst
                       die Tuer zugezogen. Jetzt kommt „manager" zuletzt,
                       und dass danach nichts mehr geht, ist eine eigene
                       Zeile. Aus dem Stolperstein wird eine Aussage.

=== EINER WAR FAST EIN BEFUND ===

  pruef-alle-sehen-es  meldete „highlight: DogFather 0, Community 0".
                       Das Brett hat als einziges keinen Katalog (dort
                       stehen echte Hoehepunkte, keine Vorlagen), und an
                       einem leeren Brett ist „sehen beide dasselbe?"
                       nicht zu messen: null gleich null waere auch dann
                       wahr, wenn die Community gar nichts duerfte.
                       Die Pruefung legt dort jetzt selbst einen Eintrag
                       an -- und musste dabei zweimal lernen: Der Eintrag
                       gehoert VOR den Katalog (danach ist die
                       Schreibbremse ausgeloest, 429), und er muss
                       FREIGEGEBEN werden, sonst sieht die Community ihn
                       zu Recht nicht. Beides steht jetzt als Aussage in
                       der Pruefung.

GEMESSEN: alle zwoelf gruen -- 43, 10, 31, 262, 43, 15, 56, 69, 37, 85,
73 und 80 Pruefungen, zusammen 804, kein Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 22:54:21 +02:00
DogFatherGitandClaude Opus 5 3b73e1f42e Versionsnummer von anruf.js hochgezaehlt -- sonst bliebe die Reparatur im Zwischenspeicher
Die Datei wird mit fester Nummer eingebunden (anruf.js?v=...). Wer sie
aendert und die Nummer stehen laesst, hat ausgeliefert und trotzdem
nichts bewirkt: Jeder Browser, der die Seite schon einmal offen hatte,
nimmt weiter die alte Datei. Genau die Sorte Auslieferung, die gruen
meldet und nichts tut.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 17:11:31 +02:00
DogFatherGitandClaude Opus 5 29d339b262 Ein turn: ohne Zugangsdaten haette JEDEN Anruf verhindert, nicht nur die vermittelten
Gefunden am 25.09.2026 beim Bau des Podcast-Studios, das dieselbe
Rechnung benutzt, und dort im echten Browser gemessen:

  Failed to construct 'RTCPeerConnection': Both username and credential
  are required when the URL scheme is "turn" or "turns".

`new RTCPeerConnection(...)` WIRFT. Die Folge ist nicht "kein
Vermittlungsserver", sondern KEIN ANRUF -- auch nicht der, der direkt
zustande gekommen waere. Aus einem Randproblem (etwa jeder Fuenfte
braucht Vermittlung) waere ein Totalausfall fuer alle geworden,
ausgeloest davon, dass /etc/coturn-workspace.geheimnis einmal nicht
lesbar ist: Rechte verstellt, beim Neuaufbau vergessen, Datei leer.

server/workspace-turn.js laesst solche Eintraege bewusst stehen, damit
der Grund nicht verschwindet ("KEIN STILLES WEGLASSEN"). Das ist dort
richtig -- es heisst aber, dass im Browser aussortiert werden muss,
bevor die Verbindung gebaut wird. Genau das fehlte. Der Kommentar
daneben behauptete "ein turn: ohne Passwort schadet nicht"; das war
nie gemessen und ist falsch.

Zwei Aenderungen in workspace/assets/js/anruf.js:

1. Beim Uebernehmen der Adressen werden turn:/turns:-Eintraege ohne
   Benutzer UND Passwort aussortiert. STUN bleibt -- es kennt gar keine
   Anmeldung und ist der Weg, der in den meisten Faellen reicht. Der
   Grund geht nicht verloren, er steht weiter in der Konsole (jetzt mit
   dem Dateinamen, in dem man nachsehen muss).

2. `adressenFrisch()` sagt bei gesetztem Grund immer "nicht frisch".
   Sonst waere die Liste fuer immer frisch: Ohne Zugangsdaten bleibt nur
   STUN uebrig, und ein STUN-Eintrag hat keinen Ablaufzeitpunkt --
   `every()` sagt dann "alles gueltig" und es wird nie wieder gefragt.
   Waere die Datei auf dem Server repariert, telefonierte trotzdem
   niemand mehr mit Vermittlung, bis er die Seite neu laedt. (Nebenbei
   die zweite Falle derselben Zeile: `every()` auf einer LEEREN Liste
   ist `true`.)

Geprueft in server/pruef-anruf.mjs: Die Pruefung liest den Filter aus
der ausgelieferten Datei und FUEHRT IHN AUS -- vier Adressen hinein,
zwei brauchbare heraus, mit Gegenprobe, dass er ueberhaupt etwas
wegnimmt. Eine Pruefung, die nur nachsieht, ob irgendwo "filter" steht,
waere auch dann gruen, wenn der Filter das Falsche tut.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 17:02:23 +02:00
DogFatherGitandClaude Opus 5 cdb6c46f8b Der Chat oeffnet dort, wo das Neue anfaengt -- bei allen sechs Rollen
Filipe: "wenn ich in den chat rein gehe und es neue kommentare gibt,
will ich dass mein chat sich da öffnet wo die neuen nachrichten anfangen
die ich noch nicht gesehen hab bitte und nicht immer ganz unten. sonst
muss man immer hoch scrollen um die neuen zu lesen und das ist scheisse.
perfektionier das bei allen rollen."

DIE FUNKTION GAB ES SEIT DEM 09.09.2026 -- eine Linie „Ab hier neu" und
einen Sprung darauf. Sie hat trotzdem nicht funktioniert, und zwar aus
DREI Gruenden, die sich gegenseitig verdeckt haben. Gefunden hat sie
keine Ueberlegung, sondern eine Messung in Pixeln: Wie weit ist die
Linie vom oberen Rand entfernt? Ein negativer Wert heisst „darueber",
also unsichtbar.

1. DER SPRUNG RECHNETE GEGEN DEN FALSCHEN PUNKT.
   `linie.offsetTop` ist der Abstand zum `offsetParent` -- und das ist
   nur dann der Verlauf, wenn dieser `position: relative` traegt. Tut
   er nicht. Gemessen auf 390 px landete die Linie 23 px OBERHALB des
   sichtbaren Bereichs: Man musste genau das tun, was Filipe nicht mehr
   tun wollte. Am Rechner stimmte es zufaellig, weil dort weniger
   dazwischenliegt -- deshalb ist es nie aufgefallen.
   Jetzt: Oberkante der Linie minus Oberkante des Verlaufs plus dessen
   Bildlaufposition. Das gilt immer, egal wer wessen offsetParent ist.

2. JEDES NACHLADEN LOESCHTE DIE LINIE.
   `gelesenBeimOeffnen` wurde bei JEDEM Aufruf gesetzt, auch beim
   sanften Nachladen. Sanft laedt der Verlauf staendig nach -- vor
   allem, wenn der Ereignisstrom sich verbindet. Die Seite laedt,
   zeichnet, meldet „gelesen bis hier", der Strom verbindet sich, laedt
   sanft nach -- und jetzt steht in `gelesen_bis` schon die letzte
   Nachricht. Keine Linie mehr, Sprung ans Ende.
   DAS IST DER FEHLER, DEN FILIPE GESEHEN HAT. Und er wuerfelte: Kommt
   die Verbindung vor dem ersten Zeichnen, passiert nichts; danach ist
   die Linie weg. Ueber sechs Rollen gemessen waren mal drei rot, mal
   zwei, mal andere -- bei unveraendertem Code.

3. UND EIN EINZIGER SPRUNG REICHT NICHT.
   Zwischen Sprung und fertigem Bild waechst die Hoehe noch: Schriften
   kommen an und setzen den Text um, Bilder melden ihre Groesse. Jetzt
   wird nachgezogen -- nach den Schriften, nach jedem Bild, nach zwei
   Bildwiederholungen -- und die Stelle haelt, bis der Mensch selbst
   scrollt. „Ich habe dich an die neue Stelle gesetzt" ist eine Zusage;
   sie beim naechsten Nachladen zu brechen waere schlimmer, als sie nie
   gegeben zu haben.

GEMESSEN -- server/pruef-chat-neu-stelle.mjs (neu), 63 Pruefungen,
0 Fehler, zweimal hintereinander mit demselben Ergebnis:

  Sechs Rollen in beiden Haeusern (DogFather, rechte Hand, linke Hand,
  Modi auf crew.; Manager und Creator auf workspace.), je auf Handy
  (390 px) und Rechner (1280 px). Je Blick: Steht die Linie im
  Verlauf? Ist sie zu SEHEN? Steht Zusammenhang darueber? Und ist der
  Verlauf NICHT am Ende?

  Ergebnis: 115-118 px unter dem oberen Rand am Handy, 150-153 px am
  Rechner -- darueber jeweils die letzte alte Nachricht.

  Dazu drei Gegenproben: Ohne Ungelesenes gibt es keine Linie und der
  Verlauf steht am Ende (sonst laendete man grundlos mitten im
  Verlauf); und die Messung erkennt „nicht sichtbar" auch wirklich
  (-1403 px an einem absichtlich nach unten gescrollten Verlauf) --
  sonst waere jede gruene Zeile darueber wertlos.

DIE PRUEFUNG SELBST HAT ZWEIMAL DAS FALSCHE GEMESSEN, bevor sie das
Richtige maass, und beides steht als Begruendung darin: Sie schickte
`/gelesen` ohne `bis` (die Route verlangt eine Nummer und lehnt sonst
ab -- der Lesestand blieb null, die Linie entstand nie), und sie
benutzte einen Raum je Rolle fuer zwei Blicke, was einen Wettlauf mit
der „gelesen"-Meldung erzeugte. Jetzt bekommt jeder Blick seinen
eigenen Raum: mehr Aufbau, dafuer immer dasselbe Ergebnis.

pruef-chat 63/0, pruef-chat-optik 62/0, pruef-chat-kanaele 81/0,
pruef-treffchat 110/0, pruef-chat-neu 36/0, pruef-pin-fuer-mich 37/0,
pruef-erwaehnung 129/0, pruef-gifs 17/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 14:40:01 +02:00
DogFatherGitandClaude Opus 5 9d219e17ce Ein Bild vom Aushang: zwei Knoepfe, und man sieht welcher mehr bewirkt
server/mess-aushang.mjs zeigt, was pruef-pin-fuer-mich nicht messen
kann -- wie die zwei Knoepfe nebeneinander aussehen. Drei Blicke:
DogFather auf dem Handy (beide Knoepfe), ein Modi auf dem Handy (nur
einer), DogFather am Rechner.

Gemessen: „lösen" 52x50 in Grau, „bei allen" 68x50 in der Warnfarbe
des Hauses. Beide ueber 44 px hoch, beide im Bild, und auf den ersten
Blick unterscheidbar -- genau darum ging es: Zwei gleich aussehende
Knoepfe nebeneinander waeren die schlechteste Loesung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 14:14:35 +02:00
DogFatherGitandClaude Opus 5 bf3c7bd718 Einen Aushang loest jeder fuer sich -- fuer alle nur DogFather und die rechte Hand
Filipe: "jeder soll das fixierte individuel für sich lösen können aber
niemals so dass es sich für alle löst. außer dogfather macht es oder die
rechte hand dan ist es bei jedem weg ansonsten sollen alle anderen
rollen es individuell für sich lösen können. dogfather und die rechte
hand sollen die option haben für sich selbst oder für alle zu lösen."

BIS HEUTE GAB ES NUR EIN LOESEN, UND DAS GALT FUER ALLE. Wer den Knopf
sah, nahm damit jedem im Raum den Aushang weg; wer ihn nicht sah, musste
die Ansage vom Montag bis Freitag ueber jedem Gespraech stehen lassen.

JETZT ZWEI KNOEPFE AM AUSHANG:

  "lösen"      nimmt ihn nur bei MIR weg -- jeder darf das, ohne
               Rueckfrage. Umkehrbar: Das Menue an der Nachricht holt
               ihn mit "wieder oben" zurueck. Eine Rueckfrage vor etwas
               Umkehrbarem lernt man wegzuklicken, und danach klickt man
               auch die weg, die zaehlt.

  "bei allen"  nimmt ihn jedem weg -- nur fuer DogFather und die rechte
               Hand, und MIT Rueckfrage. Er traegt die Warnfarbe des
               Hauses: Zwei gleich aussehende Knoepfe nebeneinander
               waeren die schlechteste Loesung, man traefe den falschen
               und merkte es erst, wenn jemand fragt, wo die Ansage
               hin ist.

EIN EINZIGER KNOPF MIT AUSWAHLFENSTER waere kuerzer und schlechter: Der
haeufige Fall ("weg damit, kenne ich") braeuchte dann zwei Klicks, und
der seltene, folgenreiche waere genauso weit entfernt wie der harmlose.

WAS NICHT IN FILIPES SATZ STEHT UND TROTZDEM NOETIG IST: Wer einen
Aushang SELBST angeheftet hat, darf ihn auch selbst wieder fuer alle
loesen. Sonst entsteht eine Sackgasse -- es haengen hoechstens drei,
und eine Gruppenleitung, die drei angeheftet hat und keinen abnehmen
darf, koennte nie wieder etwas anheften. Sie nimmt damit nur zurueck,
was sie selbst getan hat; das ist die Kehrseite derselben Erlaubnis,
keine neue.

TECHNISCH: eine Tabelle `chat_pin_aus` (Nachricht, Person). Kein
Eintrag heisst sichtbar -- nicht umgekehrt, sonst muesste beim Anheften
fuer jeden Teilnehmer eine Zeile entstehen und wer spaeter dazukommt,
saehe den Aushang nie. Der Verbund steht in der Abfrage und nicht im
Browser: Eine Liste, die alles schickt und im Browser gefiltert wird,
ist eine Liste, die alles schickt.

GEMESSEN -- server/pruef-pin-fuer-mich.mjs (neu), 37 Pruefungen, 0 Fehler:
  - Der Modi nimmt sie bei sich weg. BEI DOGFATHER UND BEIM ZWEITEN
    MODI HAENGT SIE WEITER -- das ist der Kern des Auftrags, und er
    laesst sich nur mit mehreren Anmeldungen messen.
  - Er holt sie zurueck; zweimal wegnehmen ist kein Fehler.
  - Er kann NICHT fuer alle loesen (403), und die Absage sagt, was
    stattdessen geht.
  - Die rechte Hand loest fuer alle -- danach ist sie bei jedem weg.
  - Wer selbst angeheftet hat, loest seinen eigenen (200) und den von
    DogFather nicht (403).
  - Das Nachruecken stimmt: Wer einen von dreien weggenommen hat, sieht
    zwei, waehrend DogFather drei sieht.
  - Drei Gegenproben: fremder Raum 404, ohne Anmeldung 401, erfundene
    Nummer 404.

DIE PRUEFUNG MUSSTE AUF node:http UMGEBAUT WERDEN. Sie braucht
Team-Dogi-Rollen, die es nur auf der Crew-Adresse gibt -- und den
`Host`-Kopf laesst `fetch` nicht setzen (verbotener Kopf, undici
verwirft ihn stumm). Die Anfrage kam auf 127.0.0.1 an, waehrend
`Origin` die Crew-Adresse nannte; jede schreibende Anfrage bekam 403
"fremde_herkunft". Im ersten Lauf sah das aus, als sei die neue Route
kaputt.

AUSSERDEM IN DIESER RUNDE: Die drei Faecher im Chat (Personen, Gruppen,
Kanaele) waren 38 px hoch statt 44. Gefunden vom Handy-Rundgang bei
jeder Rolle -- aber erst, seit der Sammellauf auch die Zeile UNTER dem
Befund mitschreibt. Vorher stand dort nur "1 Befund".

Die Schemaaenderung auf einer Kopie der echten Datenbank durchgespielt:
72 Tabellen, keine Zeile und keine Spalte verloren, chat_pin_aus da.
pruef-chat 63/0, pruef-chat-optik 62/0, pruef-chat-kanaele 81/0,
pruef-treffchat 110/0, pruef-chat-neu 36/0, pruef-handy-teamdogi
0 Befunde, pruef-css-klassen gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 14:13:05 +02:00
DogFatherGitandClaude Opus 5 a06184f24f Der Name auf der Teamseite hatte null Pixel -- und drei Messungen waren stumpf
Weiter am Rundumcheck, diesmal der Handy-Rundgang. Von sechs Befunden
war einer ein echter Layoutfehler, zwei waren zu kleine Schrift, und
drei kamen daher, dass die Messung etwas nicht unterscheiden konnte.

=== AUF DEM HANDY KAPUTT ===

1. DER NAME IN DER PERSONENKARTE WAR NULL PIXEL BREIT.
   Gemessen auf 412 px: `h3.tperson__name` mit `w=0` bzw. `w=15`. Der
   Name stand als Buchstabensaeule da oder gar nicht -- auf der Seite,
   die von Menschen handelt.

   `.tperson__text` trug `flex: 1; min-width: 0`. Das erlaubt dem
   Textblock, auf null zu schrumpfen, und Flexbox schrumpft lieber,
   als umzubrechen -- die Pille „zuletzt gesehen" daneben blieb stehen
   und nahm allen Platz. `min-width: 0` war trotzdem richtig gemeint
   (ohne sie blaeht ein langes Wort den Kasten auf); es fehlte nur die
   Untergrenze. Jetzt `min(14ch, 100%)`: vierzehn Zeichen, wenn so
   viel Platz ist, sonst der ganze Platz, der da ist. Die Pille bricht
   um -- `flex-wrap: wrap` stand am Kopf ohnehin schon, es fehlte nur
   der Grund, es zu benutzen.

2. ZWEI BESCHRIFTUNGEN UNTER DER LESBARKEITSGRENZE. `.u-weg__marke`
   10,88 px, `.u-weg__aus` 11,2 px -- die Hausgrenze sind 11,5. Der
   Reflex dahinter: Eine Marke soll leise sein, also macht man sie
   klein. Leise wird sie aber durch Farbe und Gewicht; eine Schrift,
   die man nicht lesen kann, ist nicht leise, sondern weg. Derselbe
   Griff ist mir gestern dreimal an einem Tag passiert.

=== DREI MESSUNGEN, DIE ETWAS NICHT UNTERSCHEIDEN KONNTEN ===

3. `pointer-events: none` IST KEIN BERUEHRZIEL. Die Terminpunkte im
   Monatsraster des Kalenders sind 8 x 8 px und nehmen ausdruecklich
   keine Beruehrung an -- angetippt wird die ZELLE. Sie als „zu klein"
   zu melden ist, als beanstande man die Groesse eines gemalten
   Knopfs. `pruef-breiten` kennt die Ausnahme seit jeher; im
   Handy-Rundgang hat sie gefehlt.

4. `font-size: 0` IST KEINE KLEINE SCHRIFT, SONDERN KEINE. Dieselben
   Punkte: Die Schrift wird auf null gesetzt, die Farbe bleibt.
   Gemeldet wurde „0px, zu klein". Die Grenze nach unten bleibt scharf
   -- alles zwischen 0,1 und 11,5 px ist weiterhin ein Befund, nur die
   glatte Null faellt heraus. Sie ist eine Aussage, keine
   Nachlaessigkeit.

5. `scrollWidth > clientWidth` SAGT BEI INLINE-ELEMENTEN NICHTS.
   Chromium liefert dort fuer `clientWidth` glatt null, und damit ist
   jeder Text breiter als sein Kasten. Der richtige Umgang mit einer
   unmoeglichen Messung ist, sie nicht zu machen -- nicht, ihr
   Ergebnis zu glauben. Dazu: Was per `clip-path: inset(50%)` fuer das
   Auge weggenommen ist (echte <select> unter selbst gebauten
   Umschaltern, Beschriftungen zu Symbolknoepfen), kann nicht
   abgeschnitten sein.

=== UND EINE MELDUNG, DIE JETZT SAGT, WO MAN SUCHEN MUSS ===

„abgeschnitten: Mara (18>0)" hat mich zwanzig Minuten gekostet -- drei
Vermutungen, drei Messungen. Die Meldung nennt jetzt Element, Klasse,
Darstellungsart und Breite: „Mara (18>0, h3.tperson__name, block,
w=0)". Damit war der Fall in einem Blick klar. Eine Pruefung, die nur
sagt DASS etwas ist, ist eine halbe.

GEMESSEN: pruef-breiten 0 Fehler (9 Breiten, 38 Seiten), pruef-team
51/0, pruef-tippziele 11/0, pruef-css-klassen 33/0. Im Handy-Rundgang
bleiben die Kopfleisten-Befunde bei 360/390/412 px -- die nehme ich mir
als Naechstes vor, sie brauchen einen Umbau und keine Korrektur.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 13:27:01 +02:00
DogFatherGitandClaude Opus 5 f7276172d0 Drei Fehlalarme, die auf echte Stellen zeigten -- und keiner war ein Fehler
Weiter am Rundumcheck. Diese Runde hat KEINE Zeile am Programm
geaendert: Alle drei Befunde kamen aus den Pruefungen selbst. Das ist
kein Trost, sondern die unangenehmere Sorte -- ein Fehlalarm nennt eine
echte Stelle und eine plausible Zahl, und man sucht danach am richtigen
Code.

1. EIN FARBFORMAT, DAS DIE MESSUNG NICHT KANNTE.

   `pruef-neue-seiten` meldete auf treff-regeln.html einen Kontrast von
   1,07 -- praktisch unsichtbarer Text. Nachgemessen an Ort und Stelle:
   dunkler Text auf HELLBLAUER Flaeche, rund 10:1.

   Chromium gibt eine Farbe, die aus `color-mix()` entstanden ist, als
   `color(srgb 0.37 0.78 0.97)` zurueck -- Werte von 0 bis 1, keine
   Kommas. Die Messung las nur `rgba?(...)`, fand die Flaeche des
   Sprunglinks nicht, ging zum fast schwarzen Elternteil weiter und
   rechnete dunkel gegen dunkel. Im Haus ist `color-mix` ueberall.

   Der Farbleser kennt jetzt beide Formate und faehrt vier Proben mit
   (rgb, rgba, color(srgb), color(srgb / alpha)) -- dazu eine fuenfte,
   die beweist, dass er bei Unbekanntem NICHTS liefert. Ein Leser, der
   im Zweifel Schwarz zurueckgibt, waere schlimmer als der alte: Er
   wuerde nie wieder auffallen.

2. EINE SEITE, DIE ZU KURZ WAR, UM "GEFUELLT" ZU HEISSEN.

   Dieselbe Pruefung verlangte von jeder Seite mehr als 400 Zeichen
   Text. entwicklung.html hat 350 -- seit dem Umbau, bei dem aus
   achtzehn Zahlenkaesten zwei Personenkacheln mit einem Ring wurden.
   Die Seite sagt seither mehr und braucht weniger Worte; die Pruefung
   bestrafte damit genau die Verbesserung.

   Jede Seite darf jetzt sagen, woran man sieht, dass sie da ist: eine
   Zeichenzahl, wo Text die Sache ist -- ein MERKMAL (".e-person",
   mindestens zwei), wo Struktur die Sache ist. Eine Zahl kann beides
   nicht unterscheiden.

3. EIN ABSCHNITT, DER SPAETER KAM ALS DIE MESSUNG.

   "Die rechte Hand sieht ihre eigene Karte" war rot -- versteckt,
   obwohl der Server `hat_karte: true` sagte. Von Hand nachgesehen
   stand sie da. Die Entwicklungsseite laedt nacheinander (Aufgaben,
   Vorlagenbrett, eigene Karte), und zwischen zwei Schritten kann es
   laenger als 600 ms still sein -- die Ruheregel der Pruefung hielt
   das fuer "fertig". Jetzt wartet sie zusaetzlich auf den einen
   Abschnitt, der sich selbst aufdeckt, hoechstens zwei Sekunden.
   Nebenwirkung: Der Vergleich "zwei verschiedene Bildschirme" misst
   jetzt 632 gegen 1191 Zeichen statt 350 gegen 350.

   109 Pruefungen, 0 Fehler (vorher 8).

4. UND EINE FESTE ZAHL, DIE MIT DER SEITE GEWACHSEN IST.

   `pruef-buehne` erlaubte hoechstens ZWEI Texte, um die herum kein
   freier Untergrund zu finden ist. treff-regeln.html hat inzwischen
   40 Textstuecke, drei davon stehen dicht -- 7,5 %. Die Sorge dahinter
   bleibt richtig (eine Pruefung, die kaum hingesehen hat, darf nicht
   "bestanden" sagen), aber das ist eine Frage des ANTEILS: zwei von
   vier waere schlimm, drei von vierzig ist es nicht. Jetzt: zwei
   immer erlaubt, darueber ein Zehntel -- und die Meldung nennt die
   Textanfaenge, damit man in einer Sekunde sieht, worum es geht.

ZWEI NEUE WERKZEUGE, beide aus der Not dieser Runde entstanden:
  mess-farbe-am-text.mjs  -- liest die Farben an Ort und Stelle aus und
                             geht die Elternkette hoch bis zur ersten
                             deckenden Flaeche
  mess-seiteninhalt.mjs   -- zeigt, was auf einer Seite wirklich steht,
                             mit ROLLE=hand auch mit fremden Augen

GEMESSEN: pruef-neue-seiten 109/0, pruef-buehne alles in Ordnung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 12:48:16 +02:00
DogFatherGitandClaude Opus 5 e7cd1984d8 Ein Rollenname im ausgelieferten Code, die Seite zu breit, und drei Pruefungen, die den Umbau verschlafen hatten
Filipe: "falls es noch was gibt was fehlt oder nicht richtig funktioniert,
auf dem handy oder auf dem pc, oder als installiert, ich will dass du das
alles abcheckst und machst dass jede seite reibungslos klappt."

Gemessen wurde der ganze Bestand: 26 Pruefdateien einzeln, dazu drei neue
Messungen fuer Dinge, die keine Pruefung ansieht. Von 37 roten Meldungen
aus dem Nachtlauf sind nach dieser Runde die haelfte erledigt -- und die
Haelfte davon war gar nicht kaputt.

=== ECHTE FEHLER ===

1. EIN ROLLENNAME STAND IM AUSGELIEFERTEN QUELLTEXT.
   `aufgaben.js` sagte "Modis moechten das uebernehmen -- du
   entscheidest". Diese Datei bekommt JEDER, der die Seite oeffnet:
   jeder Manager, jeder Scout, jeder Creator im anderen Haus. Der
   verborgene Zugang haelt genau so lange, wie der Name dort nicht
   steht. Er stammte aus der Bewerbungsspalte von gestern -- einen Tag
   alt, gefunden von pruef-modi-wortleck. Jetzt: "Jemand moechte das
   uebernehmen". Wer es ist, steht ohnehin mit Namen auf den Karten
   darunter, und die sieht nur, wer sie sehen darf.

2. DIE SUPPORT-SEITE WAR AUF JEDER BREITE UNTER 1440 px ZU BREIT --
   13 px auf dem Handy, 41 px auf dem Tablet quer, 30 px auf dem
   Laptop. Schuld war das weiche Licht, das ich in der Nacht eingebaut
   habe: `inset: -8% -4% 0`. Die vier Prozent rechts sahen nach einer
   Kleinigkeit aus; ein absolut gesetztes Element zaehlt aber zur
   Scrollbreite, auch mit `pointer-events: none` und `z-index: -1`.

   `pruef-breiten` hat es gemeldet und konnte nicht sagen, WAS es ist
   ("13px Ueberstand []") -- ein Pseudoelement hat keine Box im DOM.
   Gefunden hat es eine neue Messung, die JEDES echte Element abfragt:
   keines ragte heraus, und genau das war der Hinweis.
   Jetzt: 0 px auf allen neun Breiten, 38 Seiten, 84 252 Elemente.

3. EIN AUFGABENLINK IM REPORT WAR 27 x 18 px GROSS. Mit der Maus gut,
   mit dem Daumen nicht -- eine Fingerkuppe ist rund 45 px breit.
   Jetzt fuellt der Link die Zeile und ist 44 px hoch, aber nur auf
   schmalen Bildschirmen und an groben Zeigegeraeten. Die zweite
   Bedingung habe ich beim ersten Anlauf vergessen, und der Link blieb
   27 x 21 -- eine Regel, die nur unter idealen Bedingungen greift,
   hilft niemandem auf dem halben Weg dorthin.

4. EINE SEITE LUD DAS FALSCHE MANIFEST. `anruf-probe.html` verwies
   fest auf `crew.webmanifest`; die Seite ist aber fuer ALLE Rollen
   offen und damit auf beiden Adressen erreichbar. Wer sie im
   Agenturhaus oeffnet und die App installiert, bekam "DogFather
   Universe" mit dem Crew-Symbol auf den Startbildschirm. Alle anderen
   38 Seiten machen es richtig: `app.webmanifest`, und die Adresse
   biegt es um.

5. DER KNOPF "+ GIF HINZUFUEGEN" WAR 40 px HOCH, und der Kommentar
   daneben nannte das "die Hausgroesse". Die Hausgroesse sind 44 --
   fuenfmal in derselben Datei so begruendet. Die erste Reparatur
   griff nicht: Die Fingerregel stand 400 Zeilen VOR der Grundregel,
   und bei gleicher Staerke gewinnt die spaetere. Jetzt steht sie
   direkt dahinter, und der Knopf misst 133 x 44 am Finger, 133 x 40
   an der Maus.

   KEINE PRUEFUNG KONNTE DAS FINDEN: Die GIF-Tafel ist zu, solange
   niemand sie aufmacht, und alle Rundgaenge messen, was auf dem
   Bildschirm steht. Dafuer gibt es jetzt server/mess-gifs-handy.mjs.

6. ZWEI KLEINIGKEITEN AUS DER NACHT: eine Fehlerkennung ohne Satz
   (`unbekannter_punkt`) und eine Stelle in support.js, die den
   Serverfehler roh anzeigte statt durch `fehlerText` -- die zwei
   anderen Stellen derselben Datei machen es richtig. So entsteht eine
   Ausnahme: nicht aus Absicht, sondern weil man die Hausregel beim
   Neuschreiben nicht danebenliegen hatte.

7. TOTES CSS (.e-leerwahl, vier Regeln). Der Leerkasten ist am
   24.09. auf Filipes Wunsch wieder verschwunden, sein Stil blieb
   einen Tag laenger stehen.

=== ROT, ABER NICHT KAPUTT ===

Drei Pruefungen haben den Umbau vom 24.09. nicht mitbekommen:

  pruef-anruf meldete 31 Fehler und "ein Gespraech entsteht (403)".
  Sie meldete DogFather auf der AGENTUR-Adresse an und liess ihn dann
  die rechte Hand anrufen -- die es dort seit der Haustrennung nicht
  gibt. Der Server hatte recht. Jetzt telefoniert Team Dogi auf crew.,
  und der Creator bleibt auf workspace. -- seine Gegenprobe ist damit
  sogar schaerfer als vorher (ein Fremder aus dem ANDEREN Haus).
  96 -> 127 Pruefungen, 0 Fehler.

  pruef-crew-wand-bild erwartete vier Rollen auf der Zugangswand. Es
  sind fuenf, seit die linke Hand am 21.09. dazukam. Die Zeile stand
  unter einem Kommentar, der wortwoertlich vor festen Namen in
  Pruefungen warnt ("eine Zeitbombe mit Datum") -- und war selbst
  einer. Jetzt leitet sie die Rollen aus `rollenImHaus("crew")` ab,
  derselben Quelle, aus der der Server die Wand baut.

  pruef-entwicklung-kacheln rechnete noch mit "x von 68". Seit
  Filipes Wunsch ("es sollen nur die menge angezeigt werden die wir
  zutragen") ist das Ganze das, was jemandem zugetragen ist. Sie baut
  jetzt BEIDE Faelle -- eine Person mit vier zugetragenen Punkten und
  eine ohne -- und misst Ring, Bogen, Zeile und Vorleseschild gegen
  die Zuteilung. Dazu eine Gegenprobe mit einer Einschaetzung auf
  einem NICHT zugetragenen Punkt: zaehlt die Uebersicht sie mit,
  stuende dort 2 statt 1. 31 -> 34 Pruefungen, 0 Fehler.

=== WAS DIE BILDSCHAU ANGEHT ===

Meine eigene Messung meldete erst "Mitte 174 von 640" -- die Schau sah
kaputt aus. Sie war es nicht: `querySelector("img")` nahm das
Husky-Zeichen in der Kopfzeile statt des Bildes. Nachgemessen am
richtigen Element steht es auf 195 von 195 (Handy) und 640 von 640
(Rechner), und ein GIF oeffnet sich als GIF. Ein Messfehler, der wie
ein Befund aussieht, kostet mehr Zeit als gar keine Messung -- deshalb
steht die Begruendung jetzt im Quelltext der Messung.

GEMESSEN: pruef-breiten (9 Breiten, 38 Seiten, 84 252 Elemente),
pruef-support 63/0, pruef-gifs 17/0, pruef-chat 63/0, pruef-chat-optik
62/0, pruef-tippziele 11/0, pruef-css-klassen, pruef-struktur,
pruef-meldungen, pruef-modi-wortleck, pruef-crew-adresse,
pruef-installieren, pruef-aufgabenbrett, pruef-entwicklung -- alle 0
Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 12:31:21 +02:00
DogFatherGitandClaude Opus 5 39ca642a52 Acht Pruefungen standen Nacht fuer Nacht als rot da -- sie waren gruen
Der naechtliche Lauf meldete heute frueh "37 Pruefungen sind rot".
Acht davon waren gar nicht rot: fehlerfrei durchgelaufen, Exitcode 0,
keine einzige FEHL-Zeile. Der Laeufer zaehlte bei ihnen aber NULL
Einzelpruefungen, und "null Pruefungen ist ein Fehler" -- eine Regel,
die richtig ist und hier das Falsche traf.

DER GRUND WAR EIN GROSSBUCHSTABE. Der Zaehler suchte `(ok|FEHL)`;
pruef-kachelfarben, -livepunkt, -tippziele, -turn-wege, -leerzustand,
-nachfrage, -tagesruf und -anruf-klingelt schreiben `  OK `.

Zwei Schaeden auf einmal:
  1. Ihre Punkte fehlten in der Gesamtzahl -- allein in fuenf der acht
     sind das 114 Stueck. Ausgerechnet die Zahl, die vor stillen
     Aussetzern schuetzen soll, war selbst eine Luecke.
  2. Acht dauerhaft rote Eintraege in einer Notiz, die morgens gelesen
     wird. Eine Warnung, die immer kommt, ist keine mehr.

Der Zaehler liest jetzt beide Schreibweisen. Die Dateien werden NICHT
vereinheitlicht: Acht umzuschreiben waeren acht Gelegenheiten, etwas
kaputtzumachen -- und die neunte, die morgen jemand anlegt, schreibt
ohnehin wieder, was sie will.

DAZU EINE PRUEFUNG, DIE DAS FESTHAELT (server/pruef-pruefzaehler.mjs).
Ein Kommentar haette den naechsten Fall nicht verhindert. Sie holt
sich den Ausdruck AUS alles-pruefen.mjs (zwei Fassungen desselben
Musters laufen auseinander), startet vier der schnellsten Pruefungen
wirklich -- je zwei in beiden Schreibweisen -- und hat drei
Gegenproben: Sie darf nicht wahllos zaehlen, sie muss zwei als zwei
zaehlen, und sie muss null als null sehen koennen, sonst waere "null
ist ein Fehler" blind.

UND DIE NOTIZ SAGT JETZT, WAS SIE MEINT. Statt "(keine Fehlerzeile
gefunden)" steht bei diesen Faellen: "Kein Befund -- es wurde keine
einzige Pruefung GEZAEHLT", mit der Erklaerung dazu. Fuer alles andere
ohne Fehlerzeile gibt es den dritten Ausgang ("Konnte nicht sagen,
woran es lag") samt den letzten Ausgabezeilen.

NEBENBEFUND, GEFUNDEN VON pruef-rueckmeldung: Die Tuer ins andere Haus
trug Ton 40 -- dieselbe Nummer wie "Entwicklung". Auf DogFathers Wand
standen zwei Kacheln in derselben Farbe, und er ist der Einzige, der
beide sieht; deshalb ist es nie jemandem aufgefallen.

  Die naheliegende Antwort waere eine 46. Farbe gewesen. Gerechnet kam
  ein Abstand von 0,0862 heraus -- unter der Hausgrenze von 0,09. Der
  Farbraum ist bei 45 Toenen voll, und eine zweite benannte Ausnahme
  am selben Tag waere der Anfang vom Ende der Regel.

  Richtig ist die andere Antwort: Die Tuer ist gar keine
  Bereichskachel. Sie fuehrt hinaus und gehoert keinem Bereich an --
  also bekommt sie keine Bereichsfarbe, sondern faellt auf --akzent
  zurueck. Damit unterscheidet sie sich von allen 45 anderen.

  Gefunden hat das die Pruefung erst, nachdem sie SAGEN konnte, welche
  Nummer doppelt ist. Vorher stand dort nur "kein Farbton doppelt (34
  Kacheln vom Server)" -- damit sucht man in vier Listen ueber zwei
  Dateien.

  Dazu: start.js schreibt kein data-ton="undefined" mehr (String()
  macht aus einem fehlenden Wert sonst ein Wort, und jede Pruefung,
  die Toene zaehlt, liest dann Text statt Zahl).

WAS MICH VOR DER FALSCHEN REPARATUR BEWAHRT HAT: Meine erste Vermutung
war, die FEHL-Zeilen staenden zu weit hinten im Ausgabefenster. Die
Gegenprobe dazu wurde rot -- null Dateien. Genau dafuer ist sie da.

GEMESSEN:
- pruef-pruefzaehler (neu): 7 Pruefungen, 0 Fehler.
- pruef-rueckmeldung: 30 geprueft, 0 Fehler (war rot).
- pruef-kachelfarben 26/0, pruef-haus-trennung 100/0,
  pruef-crew-adresse 153/0.
- tools/.nachtlauf-* ist jetzt ignoriert: Laufzeitzustand, der sich
  jede Nacht aendert. Das Ergebnis steht in der Vault-Notiz.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 06:46:49 +02:00
DogFatherGitandClaude Opus 5 257c01b8f0 Support: Babyblau mit Lila, und der Melder bekommt seine Knoepfe wirklich
Nachtrag zu cebdd88e. Ein Bildschirmfoto der fertigen Seite hat drei
Dinge gezeigt, die keine Pruefung sehen konnte.

1. DIE KACHEL WAR IMMER NOCH ORANGE.

   Filipe: "die hauptfarbe der kachel soll auch babyblau sein mit bissl
   lila." Die Seite trug zwar `--akzent: #a8d8ff`, aber der Kopf faerbt
   sich ueber `--ton` -- und der stand am body auf Ton 44, dem Orange
   vom 24.09. Die Variable war gesetzt und wirkungslos.

   Ton 44 ist jetzt #a8d8ff. WEIL DIE FARBE DIESMAL AUS EINEM WUNSCH
   kam und nicht aus einer Rechnung, wurde sie nachgemessen:

     Kontrast gegen den dunklen Grund   12,46:1   (Hausgrenze 4,5)
     Abstand zur naechsten Kachelfarbe  0,1125    (Ton 15)
     Engstes Paar im Haus ohnehin       0,0154
     -> 7,3-fach weiter weg als das schwaechste Glied

   Babyblau haelt die Buntheitsgrenze (0,12) NICHT und kann sie nicht
   halten -- das liegt am Wort. Nachgemessen mit einer Suche ueber den
   ganzen Blaubereich: Es gibt KEINE Farbe, die gleichzeitig babyblau
   aussieht, Buntheit >= 0,12 und Abstand >= 0,09 schafft; der beste
   Kandidat kommt auf 0,075 Abstand. Der Blaubereich ist von sechs
   Toenen besetzt.

   DIE GRENZE WURDE DESHALB NICHT GESENKT, sondern BENANNT ausgenommen
   (`blassErlaubt` in pruef-kachelfarben). Eine gesenkte Grenze gaelte
   fuer alle 45 Toene; eine benannte gilt fuer eine und steht mit
   ihrem Grund da. Und sie kostet etwas: Wer blass sein darf, muss
   beim Abstand >= 0,10 halten -- gemessen, nicht versprochen, mit
   Gegenprobe.

   NEBENBEI ZWEI ALTE ROTE BEHOBEN: Die Prüfung war seit dem 24.09.
   rot (engstes Paar 0,0862 zwischen Ton 41 und 45, und zwei Farben in
   der Regenbogenkachel stimmten nicht mehr). Ton 45 neu gerechnet
   (0,0914) und die Kachel nachgezogen. 22 -> 26 Pruefungen, 0 Fehler.

2. DIE LEITUNG SAH DIE KNOEPFE DES MELDERS.

   Der Server lehnte sie richtig mit 404 ab -- die Karte bot sie ihr
   trotzdem an. `darf_bestaetigen` war eine Aussage ueber die MELDUNG
   statt ueber den BETRACHTER. Zwei Knoepfe, die nur eine Fehlermeldung
   koennen, sind schlimmer als gar keine.

   Jetzt fragen Route UND Anzeige dieselbe Funktion `darfBestaetigen`.
   Es ist ausdruecklich "ist der Melder" und nicht "ist nicht Leitung":
   Die Leitung darf ihre EIGENE Meldung bestaetigen, nur keine fremde.

   Und die Leitung sieht bei "wartet" jetzt, wer dran ist -- "Liegt bei
   Miss" mit ruhig atmendem Punkt, dazu "Noch etwas nachschicken ..."
   statt "Behoben - nachfragen ...". Sie hat ja schon nachgefragt.

3. NACHLEGEN HAETTE DEN VERLAUF VERDOPPELT.

   Antwortet die Leitung ein zweites Mal, waehrend die Meldung beim
   Melder liegt, entstand eine ZWEITE Zeile "Runde 1" -- und sein
   spaeteres Urteil haette beide gleichzeitig beschriftet. Jetzt
   ersetzt ON CONFLICT die Antwort in derselben Runde.

   DER EIGENTLICHE FUND STECKTE IM INDEX: `CREATE UNIQUE INDEX IF NOT
   EXISTS` unter dem alten Namen tut auf dem Server NICHTS -- dort gibt
   es den Namen schon, als gewoehnlichen Index, und IF NOT EXISTS
   sieht nur den Namen, nicht die Bauart. Der Index waere nie eindeutig
   geworden, ON CONFLICT haette kein Ziel gefunden, und das Nachlegen
   waere abgebrochen -- genau dort, wo lokal alles gruen ist, weil jede
   Pruefung ihre Datenbank frisch anlegt. Gefunden beim Durchspielen
   auf einer KOPIE der echten Datenbank. Der alte Index wird jetzt
   ausdruecklich weggenommen, der neue heisst anders.

GEMESSEN:
- pruef-support 59 -> 63 Pruefungen, 0 Fehler (darunter: die Leitung
  bekommt die Knoepfe NICHT, und Nachlegen laesst EINE Runde stehen).
- Die Index-Umstellung auf einer Kopie der echten Datenbank
  durchgespielt: alter Index weg, neuer eindeutig, ON CONFLICT trifft.
- pruef-kachelfarben 26/0, pruef-css-klassen, pruef-deutsche-texte: gruen.
- server/mess-support-runde.mjs (neu) macht vier Bilder: Leitung,
  Melder, Melder auf dem Handy, und den Verlauf nach zwei Runden.
  Es misst den "wer ist dran"-Hinweis ausdruecklich mit -- der war
  einmal stumm ausgefallen (before() auf einem Element ohne
  Elternknoten tut nichts, ohne Fehlermeldung).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 05:10:58 +02:00
DogFatherGitandClaude Opus 5 cebdd88e8d Support: der Melder hat das letzte Wort -- und die Kachel bekommt ihren eigenen Stil
Filipe: "ich will dass die leute die mir was geschickt haben im support,
auch meine notiz bekommen wenn ich fertig bin. damit die bescheid wissen
und dan anklicken koennen, es funktioniert, oder noch nicht. und erst
wenn es funktioniert gedrueckt wird, will ich dass alles richtig fertig
ist. perfektionier den ganzen weg und mit dem gedanken das manchmal
sachen mehrmal nicht sofort perfekt sein werden."

DER GANZE WEG, nicht nur der Schlusspunkt:

1. Die Leitung drueckt "Behoben - nachfragen ...". Der Knopf hiess
   vorher "Erledigt ..." und tat auch das; jetzt stellt er eine Frage,
   also heisst er auch so. Eine Beschriftung, die etwas anderes sagt
   als der Knopf tut, glaubt man genau einmal.

2. Die Meldung steht auf "wartet" -- ein vierter Stand zwischen "wird
   bearbeitet" und "erledigt". Beim Melder heisst er "geht es wieder?",
   weil er aus SEINER Sicht keine Wartezeit ist, sondern eine Frage.

3. Er sieht die Notiz und zwei Knoepfe. "Geht wieder" ohne Rueckfrage
   (der haeufige, harmlose Fall). "Noch nicht" verlangt ein Wort --
   sonst faengt die Suche von vorn an und die naechste Runde waere
   dieselbe wie die letzte.

4. "Noch nicht" ist keine Beschwerde, sondern Runde 2: zurueck in
   Arbeit, Rundenzahl plus eins, Leitung bekommt eine Nachricht, und
   der Verlauf behaelt, was beim letzten Mal versucht wurde. Niemand
   faengt von vorn an -- genau der Fall, den Filipe genannt hat.

5. Erst sein "Geht wieder" schliesst die Meldung. Danach kann weder er
   noch die Leitung sie wieder aufmachen (409).

NUR DER MELDER darf bestaetigen, ausdruecklich nicht die Leitung
(`person_id !== req.person.id`, nicht "ist Leitung") -- sonst nickt sie
ihre eigene Arbeit ab und der ganze Umweg waere Zierde. Eine fremde
Meldung gibt 404, nicht 403: Wer sie nicht sehen darf, soll auch nicht
erfahren, dass es sie gibt.

DER VERLAUF STEHT UNTEREINANDER statt nur der letzten Antwort. Bei
Runde drei war sonst nicht mehr zu sehen, was beim ersten Mal versucht
wurde, und genau das loest einen wiederkehrenden Fehler. Meldungen von
vor diesem Umbau haben keinen Verlauf -- die zeigen wie bisher ihre
blosse Antwort, ein leerer Kasten waere schlechter als der alte Satz.

DIE KACHEL SIEHT ANDERS AUS (zweiter Wunsch: "viel geiler viel
profissioneller ... die hauptfarbe soll babyblau sein mit bissl lila").
`support-seite` traegt den Stil; alle Regeln haengen daran und gelten
damit nur hier. Zwei weiche Lichter, Pillen statt Kaesten als Filter,
eine leuchtende Naht ueber dem Meldefeld. Augenschonend: gedeckt, kein
Neon, Kontrast geprueft.

GEMESSEN:
- pruef-support: 45 -> 58 Pruefungen, 0 Fehler. Der ganze Weg einmal
  durch, MIT einer Runde, die schiefgeht. Dazu drei Gegenproben: die
  Leitung kann nicht fuer den Melder bestaetigen (404), "noch nicht"
  ohne Wort wird abgelehnt (400), eine geschlossene Meldung bleibt zu
  (409, aus beiden Richtungen).
- Die Schemaaenderung auf einer KOPIE der echten Datenbank
  durchgespielt: 72 Tabellen, keine Zeile und keine Spalte verloren,
  support_runden und `runde` da, 'wartet' in der CHECK-Regel.
- pruef-css-klassen, pruef-deutsche-texte, pruef-code, pruef-glocke:
  alle gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 04:55:14 +02:00
DogFatherGitandClaude Opus 5 939aa6371b Zutragen geht jetzt direkt am Punkt -- ein Griff statt eines Fensters
Filipe: "ich will sofort über diese aufgaben da spezifisch zuteilen
kann bitte. bei allen personnen."

DAS AUSWAHLFENSTER BLEIBT -- es ist der Weg, wenn man jemanden neu
einrichtet und zwoelf Punkte auf einmal vergibt. Der Knopf am Punkt ist
der andere Fall, und der haeufigere: Man liest eine Beobachtung, denkt
"das soll er machen", und will es in dem Moment erledigen -- nicht ein
Fenster aufmachen, in einer Liste von 68 denselben Punkt suchen und
wieder zumachen.

AUS DER MARKE WIRD DER SCHALTER. An derselben Stelle, an der bis eben
nur "Aufgabe" stand, sitzt jetzt der Knopf, mit dem man es tut. Er
steht IMMER da, auch wenn nichts zugetragen ist: Auf einem Handy gibt
es kein Ueberfahren, und einen Knopf, der erst beim Zeigen erscheint,
gibt es dort nicht. Im Ruhezustand ist er ein Umriss -- 68 gefuellte
Kaestchen untereinander waeren ein Balken.

EIN PUNKT, EIN AUFRUF -- und das ist nicht nur Bequemlichkeit: Wuerde
dieser Knopf die ganze Liste schicken (wie das Fenster), loeschte er
die Zutragung, die jemand anderes eine Sekunde vorher gemacht hat.
Genau das misst die Pruefung: erst zutragen, dann nachsehen, ob die
vorherigen unberuehrt sind.

ZWEIMAL DASSELBE IST KEIN FEHLER. Wer zweimal tippt oder zwei Fenster
offen hat, bekommt denselben Zustand -- nicht eine Absage und nicht
einen doppelten Eintrag (`ON CONFLICT DO NOTHING`).

DIE ZAHL KOMMT VOM SERVER ZURUECK und wird nicht im Browser
weitergerechnet: Bei zwei offenen Fenstern waere die eigene falsch, und
niemand saehe, warum. Kopf, Ring und Kachel ziehen damit sofort nach --
ohne Neuladen und ohne Sprung.

EIN FUND AM WERKZEUG, eine Ebene tiefer: tools/_um.py stellt Anker auf
die Zeilenenden der Datei um -- und entwicklung.css hat GEMISCHTE
(Bestand CRLF, ein angehaengter Block LF). Der Anker wurde auf CRLF
gestellt und traf den LF-Teil nicht; die Meldung lautete "Anker 0x",
und man sucht den Fehler im Anker, obwohl er Zeichen fuer Zeichen
stimmt. Genau die Sorte Fehlalarm, gegen die dieses Werkzeug gebaut
wurde. Es probiert jetzt beide Arten und ersetzt mit der, die an der
Fundstelle gilt.

Geprueft: pruef-entwicklung 63 -> 70 Punkte, darunter zwei Gegenproben
(ein erfundener Punkt wird abgewiesen, ein Modi traegt nichts zu).
pruef-tippziele (11) und pruef-css-klassen unveraendert gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 04:40:21 +02:00
DogFatherGitandClaude Opus 5 177c3c012a Die Bildschau und 2520 Farben statt 360
=== 1. BILDER OEFFNEN SICH MITTIG, MIT HUSKY UND HASE ===

Filipe: "wenn man bilder aufmacht, will ich dass die in der mitte sind.
beim hochformat soll links der husky sein und rechts der hase und beim
querformat soll oben dogfather und unten team dogi stehen da wo platz
ist ... ich will nicht dahin geschmissen sondern was richtig geiles mit
einem geilen hintergrund."

WAS VORHER PASSIERTE, und es war kein Fehler im Code: Der Klick fuehrte
auf die DATEI. Was man dann sah, war die eingebaute Bildanzeige des
Browsers -- schwarzer Grund, Bild oben links in der Ecke, auf seinem
Bildschirmfoto 1200 px Schwarz daneben. Eine Datei hat keine
Gestaltung. Wer Gestaltung will, muss eine SEITE zeigen.

Die Schau liegt jetzt UEBER dem Chat. Escape bringt einen dorthin
zurueck, wo man war, und das Gespraech laeuft im Hintergrund weiter --
ein zweiter Tab braeuchte eine eigene Seite, eine eigene Anmeldung und
einen eigenen Weg zurueck.

DER WEG IN DEN TAB BLEIBT TROTZDEM. Der Verweis hat unveraendert
`href`, `target` und `rel`; mittlere Maustaste, "In neuem Tab oeffnen"
und Herunterladen gehen wie seit jeher. Abgefangen wird nur der
gewoehnliche Klick -- und auch der nur, wenn die Schau geladen ist.
Wer Strg, Umschalt oder Befehl haelt, bekommt den alten Weg; das sind
die Griffe, die Leute seit zwanzig Jahren benutzen.

DIE BEGLEITER STEHEN DA, WO PLATZ IST -- und die Antwort darauf wird
GEMESSEN, nicht geschaetzt: nicht die Fensterbreite, sondern der Rest
neben dem Bild, nachdem es eingepasst ist. Eine Schwelle wie "ab
1100 px" waere fuer ein 3:4-Foto richtig und fuer ein 9:16-Foto auf
demselben Schirm falsch. Unter 170 px bleibt der Husky weg: Eine Figur,
die ein Streifen ist, steht besser nicht da.

Husky und Hase sind eigens verkleinert (2,1 MB und 1,0 MB werden 40 kB
und 32 kB) und bekommen weiche Raender -- ihr Hintergrund ist dunkel,
aber nicht durchsichtig; als Rechteck saehe man zwei Kaesten.

Der erste Anlauf spiegelte den Hasen, damit beide "nach innen" schauen
-- ein alter Reflex aus dem Plakatsatz. Auf dem Probebild stand danach
"Hasi Dog" seitenverkehrt auf seinem Hoodie. Schrift im Bild spiegelt
man nie.

pruef-chat-anhaenge bekommt 14 neue Punkte, darunter beide Formate und
zwei Gegenproben: Strg+Klick oeffnet die Schau NICHT (sonst hiesse "sie
geht auf" nur, dass der alte Weg verloren ist), und im Hochformat liegt
KEINE der Figuren ueber dem Foto.

NEBENBEFUND, den die Pruefung gefunden hat: Die Sprachnachricht war auf
165 px geschrumpft -- zu schmal fuer den Schieber. Ursache war keine
Aenderung an ihr, sondern eine Folge: Die Blase ist so breit wie ihr
breitester Inhalt, und seit die Handgriffe darunter Zeichen statt
Woerter sind (123 statt ueber 400 px), bestimmt das Abspielgeraet die
Breite selbst. Eine Reihe kuerzer zu machen hat einen Schieber schmaler
gemacht, drei Bildschirme weiter.

=== 2. DER FARBKREIS: HELLER UND DUNKLER ===

Filipe: "dieser kreis muss viel perfekter sein. ich will dass die leute
auch heller und dunkler aussuchen koennen ... so dass die leute viel
krassere moeglichkeiten haben."

WARUM DAS BIS HEUTE NICHT GING: Die 360 Toene liegen alle auf DERSELBEN
Leuchtdichte. Das war kein Zufall -- solange die Blase in der gewaehlten
Farbe stand, musste jede dieser Flaechen dieselbe Schrift tragen. Eine
hellere Farbe zuzulassen hiesse damals: irgendwo wird eine Nachricht
unlesbar.

SEIT DEM 25.09.2026 IST DIE BEDINGUNG EINE ANDERE. Die Blase ist fuer
alle dunkles Glas; der Ton ist Kante und NAME. Damit faellt die alte
Regel weg, und an ihre Stelle tritt: der Ton muss als Name auf dem
Blasengrund lesbar bleiben.

DIE ZAHLEN SIND GEMESSEN, NICHT GEWAEHLT. Ueber alle 360 Winkel, jeweils
der schlechteste Fall:

    40 % schwarz   4,468   -- unter der Schwelle, faellt raus
    34 % schwarz   4,555   -- ginge, aber ohne Reserve
    30 % schwarz   4,619   <- die dunkelste Stufe
     0 %           5,12    <- die Mitte, der alte Kreis
    55 % weiss     9,6     <- die hellste Stufe

Sieben Stufen, 2520 Farben statt 360. pruef-chat-neu rechnet jede
einzelne nach -- die knappste hat 2,6 Prozent Reserve.

DIE ALTEN SCHLUESSEL BLEIBEN GUELTIG. "ton-214" heisst weiterhin genau
dieselbe Farbe; die Stufe steht als Zusatz dahinter ("ton-214-s6"). Wer
seit dem 23.09. eine Farbe traegt, traegt nach diesem Umbau dieselbe.

DIE PROBE IN DER MITTE ZEIGT ENDLICH, WAS MAN BEKOMMT. Dort stand eine
gefuellte Flaeche mit heller Schrift -- so sah die Blase bis heute frueh
aus. Jetzt steht dort derselbe Blasengrund und darauf der Ton als Name,
mit genau der Rechnung aus chat.css. Das ist nicht nur ehrlicher, es
macht die sieben Stufen erst moeglich: Eine gefuellte Flaeche muesste
Schrift tragen, und bei sehr hellen Toenen schafft das keine der beiden
Schriftfarben mehr (4,1 statt noetiger 7). Als Name auf dunklem Grund
ist derselbe helle Ton besonders gut lesbar (8,0).

Der Erklaersatz im Fenster war damit falsch geworden ("Alle Toene
leuchten gleich stark") und sagt jetzt, was stimmt.

ZWEI PRUEFUNGEN WURDEN GENAUER:
  pruef-chat-neu mass den HINTERGRUND der Mitte -- eine Darstellung,
  die es nicht mehr gibt. Sie misst jetzt die Schriftfarbe und rechnet
  die Mischung nach. Dabei stolperte sie ueber eine dritte
  Farbschreibweise: Chromium gibt `color-mix`-Ergebnisse als
  `color(srgb 0.676 0.645 0.504)` zurueck. Die Zeile war rot, obwohl
  die Farbe auf den Bildpunkt genau stimmte -- ein Fehlalarm, der
  teurer ist als ein echter Befund, weil man am Aussehen sucht und der
  Fehler im Lesegeraet liegt.
  Und sie prueft nicht mehr 360, sondern 360 x 7 -- nur den Grundton zu
  messen hiesse, sechs Siebtel ungeprueft auszuliefern.

Geprueft: pruef-chat-anhaenge (123), pruef-chat-neu (36),
pruef-chatkachel (40), pruef-chat, pruef-chat-ausbau (69),
pruef-chat-optik, pruef-tippziele (11), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 04:35:12 +02:00
DogFatherGitandClaude Opus 5 4b48aa46f6 Berichtigt: Die Leitung sieht wieder alle 68 -- gezaehlt wird das Zugetragene
Filipe: "falsch, dogfather und die rechte hand sollen immer noch die 68
sachen sehen wie vorher und der button soll einfach perfektioniert
werden damit wir beide sie den jeweiligen als aufgabe geben koennen.
und die modis sollen nicht sehen. aber unsere sicht von dogfather und
rechte hand soll sich nicht aendern."

MEIN FEHLER WAR EINE VERWECHSLUNG von zwei Dingen, die gleich aussehen
und verschieden sind:

  EINGRENZEN  -- die Liste wird je Person kuerzer. Das habe ich gebaut.
  ZUTRAGEN    -- aus der vollen Liste bekommt jemand etwas als AUFGABE.
                 Das war gemeint.

Der Unterschied zaehlt: Die 68 sind das, woran ihr euch entlanghangelt,
wenn ihr jemanden anseht. Eine Liste, die sich je Person verkuerzt,
waere bei jedem Menschen eine andere -- und dann faellt kein Vergleich
mehr auf.

DIE KARTE ZEIGT WIEDER ALLES, und an jedem Punkt steht, ob er diesem
Menschen als Aufgabe zugetragen ist: eine Kante links und das WORT
"Aufgabe". Nicht nur die Farbe -- wer Farben schlecht unterscheidet,
saehe sonst nur einen etwas anderen Kasten.

GEZAEHLT WIRD TROTZDEM DAS ZUGETRAGENE. Filipe: "es sollen nur die
menge angezeigt werden die wir zutragen und erledigte dan auch nur die
die erledigt wurden von den zugetragenen." Aus "0 von 68 angesehen"
wird "3 von 12 erledigt".

BEIDE HAELFTEN MUSSTEN MITZIEHEN, und das ist die Stelle, an der es
leicht schiefgeht: Die Kachel nahm links die Zahl ALLER
Einschaetzungen. Waere nur das Ganze nachgezogen worden, stuende dort
"13 von 3" -- eine Zahl, die groesser ist als ihr Ganzes, und die
niemand mehr erklaeren kann. Der Verbund mit der Zuteilung steht
deshalb schon in der Abfrage, an drei Stellen: Karte, Uebersicht und
Ring.

NULL ZUGETRAGEN IST KEINE NULL, SONDERN EIN SATZ. "0 von 0 erledigt"
liest sich wie ein Versaeumnis; "noch nichts zugetragen" sagt, was zu
tun ist. Der Knopf heisst jetzt auch, was er tut: "Aufgaben zutragen".

Der leere Kasten, der im ersten Anlauf die ganze Karte ersetzte, ist
weg -- genau der hatte die Sicht der Leitung veraendert.

Geprueft: pruef-entwicklung 61 -> 63 Punkte. Die neuen halten
ausdruecklich fest, was ich falsch gemacht hatte: Die Leitung sieht den
GANZEN Katalog (68), und die Liste bleibt auch nach dem Zutragen
vollstaendig -- nur die Marke wandert. Ohne diese zwei Zeilen waere
dieselbe Eingrenzung beim naechsten Umbau wieder eine Zeile Arbeit und
niemandem aufgefallen. Dazu pruef-css-klassen und pruef-tippziele.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 04:04:29 +02:00
DogFatherGitandClaude Opus 5 7099d53da0 Die Handgriffe an der Nachricht werden Zeichen -- eine Zeile statt drei
Filipe: "diese optionen im chat. ich will dass du das viel geiler
machst und es soll viel kuerzer gestaltet sein. so dass es nicht zu
viel aufmerksamkeit auf sich zieht aber jeder sieht dass es da ist.
aber kurz und knapp was richtig geiles schnell benutzbar aber nicht zu
viel platzraubend."

HEUTE FRUEH BEKAMEN DIE FUENF WOERTER ZEICHEN DAVOR. Das war der
richtige Schritt und nur der halbe: Gemessen auf 412 px brauchte die
Reihe danach DREI Zeilen -- "23. Sept. antworten reagieren" /
"loeschen (Notfall) anheften" / "kopieren". Unter zwei Zeilen Text
standen drei Zeilen Bedienung.

WOERTER SIND HIER DER FALSCHE MASSSTAB. Man liest sie nicht -- man
sucht das eine, das man gerade braucht. Fuenf Woerter nebeneinander
sind fuenfmal Lesen fuer einen Griff; fuenf Zeichen sind ein Blick.

Nachgemessen: aus ueber 400 px in drei Zeilen werden 123 px in EINER.

DAS WORT IST NICHT WEG, ES IST NUR NICHT ZU SEHEN. Es steht weiter im
Dokument (`clip-path`, nicht `display: none`) und damit:
  * ein Vorleseprogramm liest "antworten", nicht "Schaltflaeche";
  * `textContent` liefert es weiterhin -- daran haengt
    pruef-chat-ausbau, das den Kopierknopf beim Wort nimmt;
  * unter dem Zeiger steht es als `title`.
Ein Knopf ganz ohne Beschriftung waere kuerzer gewesen und schlechter.

KEINE FLAECHE IM RUHEZUSTAND. Fuenf Kreise nebeneinander waeren wieder
ein Balken. Es gibt nur das Zeichen, gedaempft; die Flaeche entsteht
erst unter dem Zeiger. "Jeder sieht, dass es da ist" heisst sichtbar,
nicht laut. Zwei Ausnahmen, und beide sind Zustand statt Griff: Das
Notfall-Loeschen traegt auch im Ruhezustand Farbe (wer fremde Worte
entfernt, soll den Knopf nicht verwechseln), und eine angeheftete
Nachricht zeigt ihre Nadel dauerhaft -- sonst muesste man jede
Nachricht ueberfahren, um zu sehen, welche haengt.

"kopiert" HAT KEIN WORT MEHR, ALSO BRAUCHT ES EIN ZEICHEN: Der Knopf
wird fuer anderthalb Sekunden gruen und traegt einen Haken. Ohne das
waere der Druck folgenlos -- man weiss nicht, ob es geklappt hat.

44 PX AM DAUMEN (Hausregel): Fuenf mal 44 plus Abstaende sind 236 px,
eine Blase hat auf 412 px innen 251. Die Reihe bleibt damit auch auf
dem Handy einzeilig.

pruef-css-klassen hat sofort gemeldet, dass die Uhrzeit auf 11,2 px
stand -- 0,3 unter der Hausgrenze. Das ist heute das dritte Mal
derselbe Reflex ("klein wirkt leise"), und die Pruefung hat ihn
dreimal gefangen. Leise macht die Deckung, nicht die Groesse.

pruef-chat-ausbau bekommt vier neue Punkte, und sie sichern genau das
ab, was sonst als naechstes "aufgeraeumt" wuerde: dass jeder Handgriff
sein Wort und seinen Vorlese-Satz behaelt, dass KEINES davon zu sehen
ist, und dass alles in einer Zeile steht. Der erste Anlauf mass die
Breite des Kastens und meldete 486 px -- das war die Blase, nicht die
Reihe. Gemessen wird jetzt, worum es geht: die Zahl der Zeilen.

Geprueft: pruef-chat-ausbau (68), pruef-chat-optik, pruef-chat-aufloesen
(126), pruef-chat-kanaele (81), pruef-tippziele (11), pruef-css-klassen,
pruef-lesbarkeit.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 03:58:43 +02:00
DogFatherGitandClaude Opus 5 b3d3714efd Nicht jeder bekommt alle 68 Punkte -- DogFather und die rechte Hand suchen je Person aus
Filipe: "ich will dass die rechte hand und ich diese aufgaben die es da
gibt, individuel aussuchen koennen wer welche aufgabe bekommt. bis
dahin sollen die keine aufgaben sehen. nur die, die die rechte hand
oder dogfather ihnen zutragen."

VORHER GEFRAGT, UND ES WAR NOETIG. Auf entwicklung.html stehen zwei
Listen untereinander -- das Vorlagenbrett (Aufgaben zum Verteilen) und
der Beobachtungskatalog (die 68 Punkte je Person). Sein Text passte auf
das eine, sein Bildschirmfoto zeigte das andere. Am 24.09. habe ich in
genau dieser Lage geraten und an der falschen Stelle gebaut. Seine
Antwort: der 68-Punkte-Katalog, das Vorlagenbrett "nicht anfassen".

BIS HEUTE GALTEN ALLE 68 FUER JEDEN. Das war bequem und in der Sache
falsch: "Clips, Schnitt und Kommentare" gehoert nicht zu jemandem, der
nur im Chat moderiert -- und ein Punkt, der nie zutrifft, steht
trotzdem in der Zaehlung. "0 von 68" bei jemandem, fuer den zwoelf
gelten, ist keine Auskunft, sondern eine Entmutigung.

EINE TABELLE, EINE ZEILE JE PAAR. Eine kommagetrennte Liste in der
Personenzeile waere schneller gebaut und liesse sich nicht abfragen
("wer hat diesen Punkt?"), nicht zaehlen und nicht absichern. Wer und
wann stehen mit drin -- fuer die Frage "seit wann gilt das eigentlich
fuer ihn", die erfahrungsgemaess dann kommt, wenn sie niemand mehr
beantworten kann.

EINE STELLE FUER DIE ANTWORT, drei Aufrufer: die Karte der Leitung, die
Uebersicht mit den Zahlen und der Auswahl-Dialog. Drei Abschriften
waeren drei Gelegenheiten, dass eine nicht mitzieht -- und dann stuende
in der Uebersicht "von 68", waehrend in der Karte zwoelf Punkte stehen.

DIE ZAHLEN ZIEHEN MIT. "X von Y" zaehlt jetzt das Zugeteilte. Auch die
linke Zahl musste nachgezogen werden: Wer frueher zu einem Punkt
gesetzt hat, der ihm inzwischen nicht mehr zugeteilt ist, haette sonst
"13 von 12" bekommen.

GESPEICHERT WIRD DIE GANZE LISTE AUF EINMAL, in einer Transaktion. Wer
zwoelf Haken setzt und dabei die Verbindung verliert, haette sonst
sieben gesetzte und fuenf verlorene -- und saehe nicht, welche.

EINMALIG WIRD UEBERNOMMEN, wozu es schon eine Einschaetzung gibt. Ohne
das waere der Umbau Datenverlust auf dem Bildschirm: Wer zwanzig Punkte
gesetzt hat, saehe am naechsten Morgen eine leere Karte -- die Daten
liegen noch da, man kommt nur nicht mehr hin. Ein Flag verhindert, dass
die Uebernahme wiederkommt, nachdem jemand bewusst abgewaehlt hat; an
einer Wegwerf-Datenbank durchgespielt, beide Laeufe wie erwartet.

NOCH NICHTS AUSGESUCHT HEISST NICHT "KAPUTT". Die Karte sagt es dann
mit einem Satz und einem Knopf. Ein leerer Bildschirm ohne Erklaerung
ist die schlechteste Antwort von allen -- man weiss nicht, ob es laedt,
ob etwas kaputt ist oder ob schlicht noch niemand ausgesucht hat.

Geprueft: pruef-entwicklung 48 -> 61 Punkte. Zuerst das Wichtigste an
seinem Satz ("bis dahin sollen die keine aufgaben sehen"): ohne
Zuteilung ist die Karte leer -- ohne diese Zeile waere alles Folgende
auch dann gruen, wenn weiterhin alles fuer jeden gilt. Dazu vier
Gegenproben: erfundene Schluessel fallen weg, ein Modi sucht nicht aus
(404) und nach seinem Versuch steht der Bestand unveraendert da, und
alles wieder wegzunehmen geht ebenfalls (eine Auswahl, die man nur
erweitern kann, waere eine Falle). pruef-css-klassen und
pruef-tippziele (11) unveraendert gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 03:50:15 +02:00
DogFatherGitandClaude Opus 5 e5e5953c43 Die Schriftleiste klappt hinter einen Knopf -- 73 px weniger Konsole auf dem Handy
Filipe: "die sachen von screen3 verbinde die mit dem auf screen4. so
dass wenn man drauf drueckt man die anderen zu sehen bekommt. damit es
nicht zu viel platz nimmt. und dan screen5, passe die reihenfolge der
sachen danach an und dan perfektionierst du das alles auf dem handy
auch bitte."

F, K, U und S standen als EIGENE ZEILE ueber dem Schreibfeld --
dauerhaft, auf jedem Geraet. Gemessen auf 390 px: Die Konsole brauchte
dafuer drei Reihen statt zwei. Nachgemessen ist es jetzt genau
73 px Hoehe, jeden Tag, fuer vier Zeichen, die man in den seltensten
Nachrichten braucht.

DER KNOPF HEISST "Aa" UND STEHT ALS LETZTES WERKZEUG, direkt vor dem
Schreibfeld -- das ist die neue Reihenfolge, um die Filipe gebeten hat:
Bueroklammer, Mikrofon, GIF und Emoji fuegen etwas EIN; das hier
veraendert, was schon dasteht. Die Reihe geht damit von "dazu" nach
"daran", und das Letzte liegt dem Text am naechsten.

"Aa" und kein Symbol: Ein Stift heisst "bearbeiten", ein Pinsel
"malen". Fuer Fett und Kursiv gibt es seit jeher genau ein Zeichen, das
jeder liest, ohne es zu lernen.

AUF DEM HANDY IST DAS DER GANZE PUNKT: Statt drei Reihen (Zeichen /
Werkzeuge / Feld+Senden) sind es zwei. Der Aa-Knopf faellt dabei nicht
ins Gewicht, weil er IN der Werkzeuggruppe sitzt -- die bricht als
Block um, und ein Knopf mehr laesst das Schreibfeld nicht schrumpfen.
Das ist dieselbe Ueberlegung, die die Gruppe am 23.09. ueberhaupt
entstehen liess.

DIE WAHL WIRD GEMERKT. Wer viel gestaltet, gestaltet weiter -- die
Leiste bleibt dann auch nach dem Neuladen offen. Die TASTENKUERZEL
laufen unabhaengig davon: Strg+B und Strg+I haengen am Schreibfeld,
nicht an der Leiste. Zugeklappt wird sie versteckt, nicht abgebaut.

ZWEI SACHEN AN DEN PRUEFUNGEN, und die erste ist ein Fund:

  pruef-chat-optik verlangte "die vier Werkzeuge stehen in einer
  Gruppe" -- mit einer festen 4. Diese Zahl war vom ersten Tag an eine
  Rechnung von gestern: Kommt ein Werkzeug dazu, wird die Zeile rot,
  obwohl nichts kaputt ist, und wer sie dann auf 5 setzt, macht
  denselben Fehler mit einer anderen Zahl. Dieses Haus ist an festen
  Zahlen schon dreimal hereingefallen (Kopfleiste 06.09.,
  Schriftgroessen 14.09., Tippziele 20.09.). Gefragt wird jetzt, was
  gemeint ist: Liegt KEINES der Werkzeuge ausserhalb der Gruppe? Das
  misst, statt zu rechnen -- und der naechste Knopf bringt es nicht zu
  Fall. Die Anzahl steht trotzdem in der Bedingung, sonst waeren
  "0 von 0" gruen.

  Die Gegenprobe wollte im ersten Anlauf Strg+B tippen. Das lief in
  eine Zeitbombe: Der Treff hat eine Nachtruhe, und zwischen 22 und
  6 Uhr ist das Schreibfeld `disabled` -- die Pruefung waere sechs
  Stunden am Tag rot gewesen, ohne dass an der Sache etwas ist. Genau
  die Sorte Fehlalarm, die am 06.09.2026 schon einmal notiert wurde.
  Gemessen wird jetzt die Sache selbst: Die vier Knoepfe sind weiterhin
  im Dokument und haben nur keine Flaeche mehr.

pruef-tippziele meldete sofort "1 ungedeckt: .chat__schriftknopf
(40h 40w)" -- am Daumen fehlten vier Pixel. Nachgezogen, wie bei den
vier Werkzeugen daneben.

Geprueft: pruef-chat-optik (mit 6 neuen Punkten, alle gruen),
pruef-tippziele (11), pruef-css-klassen, pruef-chat-ausbau (64).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 03:39:04 +02:00
DogFatherGitandClaude Opus 5 8bf2f440d0 Bewerbungen der Modis bekommen eine eigene Spalte im Aufgabenbrett
Filipe: "ich will in dieser seite auch eine eigene kategorie fuer die
aufgaben wo die modis sich selbst bewerben. ich will dass man die
getrennt sieht. mach es richtig uebersichtlich."

WO SIE VORHER STANDEN, und warum das nicht reichte: nur auf dem
Vorlagenbrett, an der jeweiligen Karte. Das ist der richtige Ort zum
ENTSCHEIDEN -- man liest Aufgabe und Name nebeneinander. Es ist der
falsche Ort zum SEHEN: Wer morgens das Aufgabenbrett oeffnet, sieht
nicht, dass drei Leute auf eine Antwort warten, und das Vorlagenbrett
ist eine Seite weiter. Fuer den Modi gab es ueberhaupt keine Stelle,
an der stand "dein Wunsch ist angekommen".

DIE SPALTE STEHT GANZ VORN. Sie ist die einzige, in der jemand auf eine
ANTWORT wartet -- alle anderen zeigen Arbeit, die laeuft. Und sie
VERSCHWINDET, wenn nichts wartet: Eine leere Spalte "Bewerbungen"
stuende 360 Tage im Jahr im Weg, um an fuenf Tagen etwas zu sagen. Die
vier festen Spalten haben auch leer eine Aussage ("hier landet, was
fertig ist"); diese nicht.

WAS AUF DER KARTE STEHT: der TITEL der Vorlage (nicht ihr Schluessel),
wer sie uebernehmen moechte, und sein eigener Satz dazu -- als Zitat
gesetzt, mit Strich davor. Er gehoert ihm, nicht dem Brett. Ohne ihn
entscheidet man ueber einen Namen.

DER TITEL WIRD NACHGESCHLAGEN, NICHT MITGESPEICHERT. Er gehoert dem
Katalog; stuende er in der Bewerbungszeile, gaebe es zwei Wahrheiten,
und die aeltere gewinnt still, sobald jemand eine Vorlage umbenennt.

EIN EIGENER, KLEINER WEG statt eines Mitschleppens:
`/workspace/api/vorlagen/bewerbungen` liefert nur die Bewerbungen --
nicht den ganzen Katalog, der an `/workspace/api/vorlagen` haengt. Das
Brett zeichnet sich bei jedem Statuswechsel neu; der Katalog ist um ein
Vielfaches groesser als die Handvoll Bewerbungen.

WER ENTSCHEIDEN DARF, SAGT DER SERVER (`darf_entscheiden`) -- nicht der
Rollenname im Browser. `assets/js` bekommt jeder, der die Seite
oeffnet, und ein Rollenvergleich dort ist in diesem Haus allein diese
Woche dreimal veraltet. Bis die Antwort da ist, gilt `false`: lieber
einen Knopf zu spaet zeigen als einen, der eine Absage holt.

DIE FARBE IST NEU IM BRETT. Die vier vorhandenen stehen fuer einen
Arbeitsstand (grau, blau, gelb, gruen); hier wartet niemand auf Arbeit,
sondern auf eine Entscheidung. Kein Rot ("kaputt"), kein Gelb (heisst
schon "zur Freigabe") -- Violett, das im Chat seit gestern fuer "an
dich gerichtet" steht. Eine Sprache im Haus.

Geprueft: pruef-bewerbung-aufgaben 101 -> 111 Punkte. Darunter die drei
Gegenproben, ohne die "die Spalte ist da" nichts bewiese: ohne
Bewerbung gibt es sie NICHT, der Modi bekommt KEINE
Entscheidungsknoepfe (sondern "wartet auf Antwort"), und die Spalte
steht wirklich an erster Stelle. Dazu pruef-aufgabenbrett und
pruef-vorlagen (24), beide unveraendert gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 03:29:33 +02:00
DogFatherGitandClaude Opus 5 1a14eb7449 Die rechte Hand fuehrt ihre Aufgaben, jede Karte hat denselben Fuss, und Dubletten lassen sich aufraeumen
=== 1. BEARBEITEN UND LOESCHEN, WAS SIE ANGELEGT HAT ===

Filipe: "kuemmer dich bitte auch drum dass die rechte hand, wenn sie
aufgaben an die modis oder linke hand erstellt, will ich dass sie die
moeglichkeit hat die auch zu bearbeiten und zu loeschen bitte.
perfektionier das fuer sie und fuer dogfather."

WARUM ES VORHER NICHT GING, und es sah nicht danach aus: `creator_id`
heisst nicht "wer hat sie angelegt", sondern "zu wem gehoert sie" (so
steht es am Tabellenkopf). Verteilt die rechte Hand eine Aufgabe an
einen Modi, steht dort der MODI. Sie erfuellte damit an ihrer eigenen
Aufgabe keine der drei Bedingungen von `darfAendern` und bekam 403 --
auf einen Knopf, den die Oberflaeche ihr trotzdem anbot, weil sie ihn
an `darf_verteilen` haengte: eine Auskunft ueber die PERSON, wo die
Frage der AUFGABE gilt.

Die Spalte `erstellt_von` gibt es seit jeher und wird beim Anlegen
gefuellt -- die Sichtbarkeitsregeln fragen sie an sechs Stellen ab. Sie
stand nur nie in dieser einen Zeile. Und sie fehlte in SPALTEN, kam
also in keiner Aufgabe mit: Die neue Regel waere ein Vergleich gegen
`undefined` geblieben.

DIE REGEL IST ALLGEMEIN, NICHT AUF EINE ROLLE GEMUENZT: wer etwas
angelegt hat, darf es auch aendern. Ein Rollenname waere die naechste
zweite Wahrheit -- in dieser Woche ist genau das dreimal veraltet.

LOESCHEN BEKOMMT EINE EIGENE FRAGE, weil es das Einzige ist, was sich
nicht zuruecknehmen laesst: `darfAufgabenVerteilen(person) &&
darfAendern(person, aufgabe)`. Damit darf sie ihre eigenen -- und der
Modi, bei dem die Aufgabe LIEGT, darf sie weiterhin bearbeiten, aber
nicht verschwinden lassen. Ablehnen und Abbrechen sind die Wege dafuer.

Die Loesch-Route holt die Aufgabe jetzt mit der Sichtbarkeitsregel und
antwortet mit 404 statt 403, wenn es sie fuer diese Person nicht gibt
-- sonst liesse sich durch Ausprobieren herausfinden, welche Nummern
vergeben sind. Beim Aendern stand das schon so, eine Route weiter oben.

pruef-verteilen: 19 -> 30 Punkte. Mit drei Gegenproben, ohne die "sie
darf" auch dann gruen waere, wenn jeder alles duerfte: der Modi wird
abgewiesen (403), die Aufgabe steht danach noch da, und eine FREMDE
Aufgabe loescht sie nicht.

=== 2. JEDE KARTE HAT DENSELBEN FUSS ===

Filipe: "wer hat sie soll bitte bei all diesen aufgaben stehen. bei all
diesen kategorien da. ... es soll auch immer gleich aussehen und nicht
manchmal verschoben und so."

ZWEI URSACHEN, und keine davon war Zufall:

  a) "Wer hat sie?" entstand nur, solange oben "Alle" gewaehlt war
     (`if (anAlle)`). Wer auf einen Namen tippte, verlor den Knopf an
     ALLEN zwoelf Karten, ohne dass irgendwo stand, warum. Die Auskunft
     "wer aus dem Team hat diese Vorlage" haengt aber an der VORLAGE,
     nicht an der Auswahl -- sie daran zu binden war der Fehler.

  b) Der Fuss war EINE Reihe mit `flex-wrap`, und wie viele Angaben
     darin stehen, haengt von der Karte ab: "Frist" immer, "fuer:
     Rolle" manchmal, "liegt bei 4 von 5" nur, wenn schon jemand sie
     hat. Karten ohne den dritten Text hatten noch Platz fuer einen
     Knopf, Karten mit ihm nicht -- also stand "An alle" mal neben der
     Frist und mal darunter. Zwoelf Karten, drei verschiedene Fuesse.

Jetzt zwei Reihen mit fester Aufgabe: oben, was man LIEST; unten, was
man DRUECKT. Die Knopfreihe ist immer die letzte Zeile und sitzt am
unteren Rand, also stehen die Knoepfe bei allen Karten einer Reihe auf
derselben Hoehe -- auch wenn der Text darueber verschieden lang ist.

Die Rueckseite verteilt jetzt IMMER an alle. Vorher nahm sie
`katalogZiel()`; solange sie nur bei "Alle" existierte, war das
dasselbe. Seit sie immer da ist, waere es eine Falle: Der Knopf sagt
"Nachholen - 3 fehlen" und gaebe sie einer einzigen Person.

=== 3. DUBLETTEN AUFRAEUMEN ===

Filipe zu "Diene x6 - Ghost x6 - Marina x6 - Miss x6" bei "0 von 24":
"mach aus den 6 1 mal bitte, ich hab mich da geirrt."

`tools/aufgaben-doppelte.mjs` raeumt das auf. Es TUT VON SICH AUS
NICHTS: ohne `--wirklich` zeigt es nur, was passieren wuerde. Mit
`--wirklich` legt es ZUERST eine Kopie der Datenbank an (`VACUUM INTO`,
nicht `cp` -- eine blosse Dateikopie kann das WAL verlieren) und nennt
den Befehl, mit dem man zurueckkommt.

WELCHE BLEIBT, ist nicht beliebig: eine erledigte, wenn es sie gibt
(getane Arbeit wirft man nicht weg), sonst eine begonnene, sonst die
aelteste. An einer Wegwerf-Datenbank durchgespielt: 12 Aufgaben, zwei
Menschen, einer mit einer erledigten darunter -- es blieben genau die
richtigen zwei stehen, die Einzelaufgabe blieb unberuehrt, und das
Nachzaehlen am Ende meldete null Dubletten.

Geprueft: pruef-verteilen (30), pruef-vorlagen (24),
pruef-aufgaben-vorlagen, pruef-aufgabenbrett, pruef-modi-katalog (150),
pruef-bewerbung-aufgaben (101).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 03:20:04 +02:00
DogFatherGitandClaude Opus 5 c64732b16d Neuer Stil "Nachtprisma", Gespraeche anheften, und unten wieder Luft
Drei Sachen aus einem Bildschirmfoto-Satz.

=== 1. EIN ANDERER STIL, NICHT DIESELBE SPRACHE MIT NEUEN DETAILS ===

Filipe, zum dritten Mal an derselben Stelle: "du verstehst es wirklich
nicht ... ich will eine komplette aenderung vom aussehen. vom
hintergrund und von der grossen kachel. ich will einen ganz anderen
stil ... die leute sollen morgen nichts mehr wieder erkennen vom
aussehen her."

WARUM MEINE ZWEI ANLAEUFE DAVOR NICHT GEREICHT HABEN -- und das ist
kein Geschmacksstreit, sondern ein Fund:

Der Umriss des Chats kommt gar nicht aus chat.css. Er steht in
module.css, in einer Liste von 50 Klassen, und `.chat` ist eine davon:
Fase oben links, Kantenlicht, Punktraster, drei goldene Eckwinkel.
module.css wird NACH chat.css geladen -- und die Staerke des `:is(...)`
dort ist (0,2,0), weil `.gruppe[data-gruppe]` mit in der Liste steht.
Jede meiner Regeln war gleich stark und kam frueher. Deshalb stimmte
beides: "ich habe den Rand geaendert" und "der Rand ist derselbe". Ich
habe zweimal Details INNERHALB eines Rahmens geaendert, den ich nicht
angefasst hatte -- und den erkennt man zuerst.

Der neue Block steht als eine klar benannte Schicht am Ende von
chat.css, mit `.inhalt.chat-seite` -- eine Klasse mehr als module.css,
kein `!important` (das waere eine Tuer, die man nie wieder zubekommt).

  ALT                        NEU
  Fase oben links            rundum 26 px weich
  drei goldene Eckwinkel     keine -- der Koerper traegt sich selbst
  Punktraster                drei weiche Lichter im Hintergrund
  1-px-Rahmen ueberall       kein Rahmen, Lichtkante innen
  Gold als Leitfarbe         Lavendel/Violett, Blasen wie gehabt
  Kaesten nebeneinander      Koerper mit Tiefe und farbigem Schatten

DIE LEITFARBE WIRD AN EINER STELLE GETAUSCHT, nicht an zwanzig. Im
ersten Anlauf habe ich zehn Regeln einzeln umgefaerbt und danach im
Bild gesehen, dass Suchfeld, "Neu", Zaehler und Fokusrahmen weiter
golden waren -- sie nehmen alle `--akzent` und `--rand`. Jetzt stehen
beide am `<main>` der Chatseite. Uebersicht, Kalender und Aufgaben
behalten ihr Gold; nur der Chat soll nicht wiederzuerkennen sein.

#b9a7ff UND NICHT #7a5cff, und das ist gerechnet, nicht gewaehlt: Die
Akzentfarbe ist hier auch FLAECHE unter dunkler Schrift (die
Ungelesen-Marke). Das satte Violett kommt dort auf 3,7:1 -- zu wenig.
Das helle auf 8,9:1, und als Schrift auf dunklem Grund genauso.

WAS UNANGETASTET BLEIBT: `--blasengrund`, `--blase-text`,
`--blase-leise`, `--namen-anteil`. An ihnen haengen die Messungen von
pruef-chatkachel (12 Kacheln) und pruef-chat-neu (360 Ringtoene). Ein
Stilwechsel darf eine Zusage nicht nebenbei aufheben.

pruef-chat-optik hat sofort einen echten Schaden gemeldet: Der neue
Stil nahm allen Blasen den Rahmen -- und damit auch den, mit dem eine
NICHT ABGESCHICKTE Nachricht markiert ist ("der Unterschied ist auch zu
SEHEN, nicht nur im Merkmal (Rand 0px)"). Genau dafuer steht die Zeile
dort. Der Warnton sitzt jetzt zusaetzlich im inneren Saum.

=== 2. GESPRAECHE ANHEFTEN ===

Filipe: "ich will dass man auch individuel jeder fuer sich auch in der
liste chats fixieren kann. auch mehrere nicht nur eins."

Drei Aussagen, und jede wird einzeln geprueft:

  "fixieren"     -> `fixiert_am` an der TEILNEHMER-Zeile; Angeheftetes
                    steht oben, darunter geht die gewohnte Reihenfolge
                    weiter.
  "individuell"  -> die Spalte haengt an der Person, nicht am Raum. Eine
                    Spalte an `chat_raeume` haette alles andere genauso
                    erfuellt und jedem im Raum das Gespraech oben
                    hingeklebt -- gemerkt haette man es erst, wenn sich
                    jemand beschwert. Die Gegenprobe prueft deshalb
                    ausdruecklich, dass es bei Luna weder markiert ist
                    noch nach oben rutscht.
  "auch mehrere" -> keine Obergrenze. Ein Zeitstempel statt Ja/Nein
                    kostet dasselbe und beantwortet die Frage mit,
                    in welcher Reihenfolge mehrere stehen: zuletzt
                    angeheftet oben.

Die Nadel steht IMMER an der Zeile, nicht erst beim Ueberfahren -- am
Handy gibt es kein Ueberfahren (dieselbe Entscheidung wie am 23.09. bei
den Handgriffen), und eine Spalte, die mal da ist und mal nicht, laesst
die Namen daneben wandern. Sie liegt schraeg, solange nichts
angeheftet ist, und steht aufrecht, wenn doch -- das sieht man auch
ohne Farbe.

Der Zustand wird GESCHICKT, nicht errechnet (`an: true/false`): Ein
Schalter, der den Gegenwert selbst ausrechnet, kippt bei zwei schnellen
Klicks oder zwei offenen Fenstern in den falschen Zustand.

Die Karte ist seit heute die ZEILE und nicht mehr der Knopf darin --
im ersten Anlauf sass die Nadel sichtbar ausserhalb der Flaeche, wie
ein Knopf, der danebengefallen ist.

=== 3. UNTEN WIEDER LUFT ===

"schieb das bisschen hoeher bitte, weil das ist unten zu nah am rand."
14 px Polsterung. Sie geht nach INNEN (`border-box`), macht die Seite
also nicht laenger -- sonst waere das Schreibfeld wieder unter den
Bildrand gerutscht, und genau darum ging es am 09.09. schon einmal.

Geprueft: pruef-chat (neuer Abschnitt Anheften, 14 Punkte, alle gruen),
pruef-chat-optik, pruef-chatkachel (40), pruef-chat-neu (32),
pruef-chat-ausbau (64), pruef-chat-kanaele (81), pruef-chat-aufloesen
(126), pruef-chat-anhaenge (109), pruef-erwaehnung (129),
pruef-css-klassen, pruef-tippziele (11), pruef-lesbarkeit.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 03:07:05 +02:00
DogFatherGitandClaude Opus 5 63a3fb4af8 Die rechte Hand sieht die Personenseite wirklich -- Liste, Rollenkarten und das Protokoll
Filipe, zum wiederholten Mal und mit einem Bildschirmfoto genau dieser
Seite: "zum hunderstenmal, also bitte mach dass es jetzt endlich
klappt, die rechte hand sieht das immer noch nicht obwohl ich will dass
die rechte hand das auch sieht."

ZUERST NACHGEMESSEN, NICHT GERATEN. Am 24.09. habe ich auf ein
Bildschirmfoto hin an der falschen Seite gebaut und es im Commit selbst
notiert. Diesmal zuerst mess-hand-personen.mjs: dieselbe Seite, zwei
Anmeldungen, und der Unterschied wird aufgezaehlt. Ergebnis in einer
Zeile -- sie bekam vom Server alle acht Personen (HTTP 200) und sah auf
dem Bildschirm NICHTS davon. Nur das Anlege-Formular, darueber der Satz
"Codes, Sperren und das Protokoll bleiben bei DogFather".

ZWEI URSACHEN, UND NUR EINE WAR EINE SCHRANKE:

  1. Die OBERFLAECHE hat die Liste versteckt, die sie laengst geladen
     hatte. `personen.js` entschied die Ausbaustufe mit
     `ich.rolle !== 'admin'`, setzte damit `data-nur-anlegen`, und
     `personen.css` blendet darauf hin die Liste, das Protokoll und
     "Alle aufklappen" aus. Diese CSS-Regel stammt vom 07.09. und war
     fuer Manager und Spicy Media gedacht; die rechte Hand ist erst
     danach dazugekommen und fiel stillschweigend mit hinein.

     Das ist in dieser einen Datei die DRITTE Stelle, an der ein
     Rollenvergleich im Browser veraltet ist -- nach dem 22.09.
     ("keine Knoepfe") und dem 24.09. ("keine Rollenwahl"). Jedes Mal
     hatte sie das Recht und sah es nicht.

  2. Das Protokoll war am Server zu (HTTP 404). Damit ist der Satz von
     oben ueberholt: Filipes Ansage vom 24.09. -- "die selben rechte da
     haben wie dogfather, das einzige was sie nicht kann ist die
     dogfather rolle oder leute anfassen" -- laesst dafuer keinen Rest.

EINE AUSKUNFT FUER DREI STELLEN. `fuehrtDieZugaenge(person)` steht
jetzt in workspace.js und beantwortet dieselbe Frage fuer die Tuer am
Server, fuer `/api/ich` (`darf_zugaenge_fuehren`) und fuer die
Ausbaustufe der Seite. Drei Abschriften waeren drei Gelegenheiten, dass
die naechste Aenderung nur zwei davon trifft -- genau so ist dieser
Fehler entstanden.

`istHand` WAERE FALSCH GEWESEN. Es fasst beide Haende zusammen, und
fuer die linke gilt ausdruecklich das Gegenteil ("sieht weder
Bewerbungen noch den vertraulichen Meldeweg"). Wer hier den
Sammelbegriff nimmt, dreht eine ausgesprochene Entscheidung
stillschweigend um. Die Prueflung fragt sie deshalb einzeln.

DIE PRUEFUNG ZIEHT NACH (40 -> 49). Abschnitt 6 prueft beides: dass
die rechte Hand dasselbe Protokoll bekommt wie DogFather, und dass die
Auskunft, aus der die Oberflaeche ihre Ausbaustufe baut, mit der Tuer
am Server uebereinstimmt. Genau dieser Abgleich hat gefehlt: Eine
Rechtepruefung, die nur Serverantworten ansieht, hat den Fehler zwei
Tage lang nicht bemerkt. Dazu drei Gegenproben (linke Hand 404, Modi
404, linke Hand `darf_zugaenge_fuehren === false`).

Beim ersten Lauf waren diese Gegenproben rot -- mit 401 statt 404. Die
Abschnitte davor sperren und loeschen absichtlich Leute, und eine tote
Sitzung antwortet mit 401: Das sieht aus wie "darf nicht" und heisst
"gibt es nicht mehr". Ein 401 als Gegenprobe fuer ein 404 ist ein Haken
ohne Gegenstand. Abschnitt 6 legt sich deshalb frische Zugaenge an.

Geprueft: pruef-hand-personen (49, 0 Fehler), pruef-personen-liste,
pruef-personen-kachel (45), pruef-personen-loeschen,
pruef-modi-verborgen (85). Unveraendert rot und an HEAD nachgemessen,
also nicht von diesem Umbau: pruef-personen-formular (2),
pruef-community-sicht (1), pruef-spicy (3).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 02:47:07 +02:00
DogFatherGitandClaude Opus 5 24f9be8e4f Drei Fächer, Gesichter, Zeichen an jedem Handgriff -- der Chat ist nicht wiederzuerkennen
Filipe: "ich will 3 kategorien haben. chats mit einzelnen personen,
gruppen chats und kanäle." Und: "ich will dass du überhaupt die
komplette kachel veränderst, ich will dass alles anderst aussieht und
gestaltet ist, mach wirklich was verrücktes und übertrieben krank
geiles ... dass die ganze community und team morgen total überrascht
sind und den chat nicht wieder erkennen."

DIE LISTE HAT DREI FÄCHER. Personen, Gruppen, Kanäle -- als Mulde mit
drei Schaltern über dem Suchfeld, nicht als drei freie Knöpfe: Drei
Dinge, die einander ausschließen, liest man nur als EINE Entscheidung,
wenn sie in einer gemeinsamen Fassung sitzen. Das gewählte Fach liegt
oben auf (Licht, Schatten, Akzentsaum), die anderen liegen darin -- man
sieht die Wahl an der Tiefe, nicht nur an der Farbe. Die Wahl überlebt
das Neuladen; beim Suchen gilt sie nicht, wer einen Namen tippt will
ihn finden und nicht raten, in welchem Fach er liegt.

WAS EIN GESCHLOSSENES FACH NICHT VERSCHLUCKEN DARF: die Ungelesenen und
den Ruf. Beides steht deshalb AM Fach -- die Zahl in der Warnfarbe, das
@ in der Akzentfarbe. Gefunden hat die Lücke nicht ein Blick, sondern
pruef-erwaehnung: Die Erwähnung lag in einer Gruppe, offen war
"Personen", und das @ war damit nirgends zu sehen.

EIN LEERES FACH MERKT MAN SICH NICHT. Wer nur einen Kanal hat -- jeder
Neue im Haus -- landete auf "Personen" und sah eine leere Liste neben
einem vollen Kanal. Beim ersten Zeichnen wird deshalb ins erste Fach
gewechselt, in dem etwas steht; Reihenfolge: gerufen, dann ungelesen,
dann überhaupt vorhanden. Gespeichert wird das NICHT -- es ist geraten,
nicht gewählt. Gefunden von pruef-gifs.

EIN GESICHT IM KOPF DES GESPRÄCHS. Links in der Liste trägt jedes
Gespräch sein Zeichen, und ausgerechnet beim Öffnen verschwand es. Es
ist dasselbe Zeichen, nicht ein ähnliches: `zeichenFuellen()` füllt jetzt
Liste und Kopf -- rund fünfzig Zeilen standen vorher mitten im Zeichnen
und hätten sonst ein zweites Mal dagestanden. Am Handy bleibt es weg,
nachgerechnet: mit ihm blieben dem Namen 126 px bei 128 Untergrenze, die
Knopfreihe fiele eine Zeile tiefer.

JEDER HANDGRIFF BEKOMMT SEIN ZEICHEN. Unter jeder Blase standen fünf
Wörter in Versalien -- bei zwölf Nachrichten sechzig. Jetzt Pfeil,
Gesicht, Papierkorb, Nadel und zwei Blätter, das Wort klein daneben.
Die Wörter bleiben: "anheften" und "lösen" sehen als Nadel gleich aus,
und "löschen (Notfall)" darf nie ein Rätsel sein. Breiter wird es
trotzdem nicht -- gesperrte Versalien kosten rund ein Viertel mehr
Breite, genau das, was die Zeichen brauchen. Die Zeichen sind Masken:
sie folgen `currentColor` und damit jedem Zustand der Schrift daneben.

AUS DER FUSSZEILE WIRD EINE MULDE, und der Grund wird dabei dunkler,
nie heller -- das ist die Bedingung dafür, dass die Kontrastzusage
gültig bleibt. Die Uhrzeit bekommt ein eigenes Schild: eine Angabe,
keine Bedienung.

AUS DEM FARBFLECK WIRD EIN RING. Der Knopf für die eigene Kachel war
ein voller Kreis in der gewählten Farbe, direkt neben einer gleich
großen Marke -- man las ihn als Meldung, und er meldet nichts. Farbe
erscheint auf dieser Seite überall als Kontur; jetzt auch hier.

AUS DEM TOTEN TRENNER WIRD LICHT. Die senkrechte Linie am Verlauf
stammte aus der Zeit, als Liste und Verlauf EIN Kasten waren; seit dem
Umbau auf zwei Tafeln klebte sie ohne Aufgabe an der Kante. An ihrer
Stelle ein sehr weicher Schein oben rechts, unter vier Prozent Deckung
-- Tiefe, kein Leuchten.

DIE KONSOLE: Das Schreibfeld ist eine Rinne statt eines flachen
Kastens, die vier Werkzeuge sprechen dieselbe Sprache, und der
Absendeknopf ist als einziger gefüllt. Keine Maßzahl angefasst -- die
Zeile ist seit dem 23.09. auf den Pixel voll.

ZWEI PRÜFUNGEN WURDEN GENAUER, NICHT NACHSICHTIGER:

  pruef-chatkachel suchte ihre "freie Stelle" nicht, sie rechnete sie
  aus -- 6 px vom rechten Rand, halbe Höhe. Das lag mal auf dem
  Rollbalken, mal auf einer Blase, und meldete beides Mal "das
  Bühnenbild ist gar nicht da". Sie sucht die Stelle jetzt mit
  `elementFromPoint` und sagt es, wenn es keine gibt.

  pruef-erwaehnung prüft jetzt beides: dass das Fach den Ruf meldet,
  ohne geöffnet zu werden, UND dass die Zeile nach dem Wechsel dasteht
  -- mit der Gegenprobe, dass das Fach wirklich filtert. 126 -> 129.

Nachgebessert: die Ungelesen-Marke am Fach stand auf 0,64 rem = 10,24 px,
unter der Hausgrenze von 11,5. Gemeldet von pruef-css-klassen, bevor es
jemand auf einem Telefon sehen musste -- der zweite Anlauf desselben
Reflexes an einem Tag.

Geprüft: pruef-chat-optik, pruef-chatkachel (40), pruef-chat (ALLES IN
ORDNUNG), pruef-chat-neu (32), pruef-chat-ausbau (64),
pruef-chat-kanaele (81), pruef-chat-aufloesen (126),
pruef-chat-anhaenge (109), pruef-erwaehnung (129), pruef-css-klassen,
pruef-tippziele (11), pruef-lesbarkeit. pruef-gifs hat weiterhin die
zwei Fehler, die schon vor diesem Umbau da waren (an HEAD nachgemessen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 02:25:06 +02:00
DogFatherGitandClaude Opus 5 d47ccf0d5f Die Blase hört auf, eine Farbfläche zu sein -- der Chat sieht anders aus
Filipe: „es hat sich nichts verändert quasi … auch die kachel das
aussehen. die blasen. die schriften. alles soll anders und geiler
aussehen, moderner und spezieller."

ER HATTE RECHT, UND ICH WEISS JETZT WARUM. Der erste Anlauf hat
poliert statt umgebaut -- weil ich die FARBE der Blase für unantastbar
gehalten habe. Genau sie war das Problem: Zwei Drittel jeder Nachricht
waren eine deckende, kräftige Fläche, und darauf kämpfte alles andere
um Aufmerksamkeit. Jede Feinheit, die man darauf legt, verschwindet.

DIE BLASE IST JETZT DUNKLES GLAS -- für jeden dieselbe. Die persönliche
Farbe ist vollständig erhalten, sie sitzt nur woanders:
  * als leuchtende KANTE an der Sprechseite (links beim Gegenüber,
    rechts bei einem selbst),
  * im NAMEN, aufgehellt, damit auch ein dunkler Ton trägt,
  * als Hauch im oberen Verlauf und als Schein unter der Blase.
Man erkennt die Person weiterhin an der Farbe -- und der Text steht
endlich auf einem ruhigen Grund.

DAZU: Die Fußzeile bekommt eine Kante in der Farbe und wird zur
Beschriftung (Versalien, gesperrt, gedämpft); die Uhrzeit trennt sich
von den fünf Handgriffen; die Blase wird schmaler (66 % / 62 Zeichen --
darüber verliert man beim Zeilenwechsel die nächste Zeile); der Text
bekommt Durchschuss, weil helle Schrift auf dunklem Grund optisch
ausstrahlt; das Zeichen neben der Blase spricht dieselbe Sprache wie
die Liste; die offene Gesprächszeile bekommt dieselbe Kante wie die
Blasen.

WAS DAS FÜR DIE MESSUNGEN HEISST -- und das ist der wichtigere Teil:

pruef-chatkachel und pruef-chat-neu haben bis heute gerechnet „Schrift
X auf Kachelfarbe Y". Das gibt es nicht mehr. Die eine wäre GRÜN
geblieben und hätte nichts mehr über den Bildschirm gesagt (die
gefährlichste Sorte, in diesem Haus schon dreimal vorgekommen), die
andere wurde sofort rot. Beide sind mitgezogen:

  * Die feste Schrift wird gegen den festen Blasengrund gemessen --
    und zwar im SCHLIMMSTEN Fall: Die Blase ist zu 92 % deckend,
    dahinter liegt ein Foto, gerechnet wird mit Weiss dahinter.
    Gemessen 14,2:1 (nötig 7) und 7,9:1 (nötig 4,5).
  * NEU: Jede der 13 Kacheln UND alle 360 Töne des Farbrings müssen
    als NAME auf diesem Grund lesbar sein. Das ist die Stelle, an der
    es heute kippen kann.
  * Beides liest `--blasengrund` und `--namen-anteil` aus chat.css
    statt sie abzuschreiben. Wer dort etwas ändert, ändert die
    Prüfung mit.

UND SIE HAT SOFORT ETWAS GEFUNDEN: Mit 58 % Aufhellung schaffte der Ton
„Ziegel" als Name nur 4,31:1 -- unter den nötigen 4,5. Auf dem
Bildschirm sah er gut aus, weil hinter der Blase gerade nichts Helles
lag. Jetzt 50 % und 5,20:1. Dazu eine Gegenprobe, die beweist, dass das
Aufhellen keine Zierde ist (ohne sie: 2,05:1).

DREI EIGENE FEHLER, ALLE VON PRÜFUNGEN GEMELDET
  * 0,66 rem für die Fußzeile = 10,56 px, drei Stellen unter der
    Hausgrenze von 11,5 px. Jetzt 0,72 rem; leise wirkt sie durch
    Versalien und Deckung, nicht durch Kleinheit.
  * Auf dem Handy brach die Fußzeile in drei Zeilen -- schuld war meine
    eigene Regel `margin-right: auto` an der Uhrzeit, die am Rechner
    richtig ist. Dort jetzt Kleinbuchstaben und kein Schub.
  * Der Handy-Block stand MITTEN in der Datei. Eine Medienabfrage
    erhöht die Spezifität nicht -- jede spätere Basisregel gewann
    gegen ihn, und er wirkte halb. Er steht jetzt am Ende.

GEPRÜFT: chatkachel, chat-optik, chat-ausbau, chat, chat-neu,
chat-kanaele, css-klassen, lesbarkeit, tippziele — alle 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 01:42:19 +02:00
DogFatherGitandClaude Opus 5 bfae4447cd Der Chat bekommt Tiefe -- Lichtkante, Glas und Schatten statt flacher Flächen
Filipe: „ich will dass du die komplette seite viel geiler und moderner
machst. die komplette kachel. die chat liste und die chats selber. …
es soll komplett aus der rolle fahren und was was wir noch nie hatten,
ich will es wirklich übertrieben krass."

EIN SYSTEM, NICHT ZWANZIG EINFÄLLE. Alles Folgende geht auf dieselben
drei Regeln zurück, und deshalb passt es zusammen:

  1. LICHTKANTE  — jede Fläche hat oben eine haardünne helle Linie und
     unten eine dunkle. Damit wird aus einer Fläche ein Körper: Licht
     fällt von oben. Was VERTIEFT ist (Suchfeld, Knopfgruppe), bekommt
     es genau andersherum.
  2. TIEFENSCHATTEN — lang und weich, weit unterhalb. Er trägt, er
     umrandet nicht.
  3. GLAS — was oben liegt, ist leicht durchscheinend und verwischt,
     was dahinter ist. Dadurch sieht man die Ebene, ohne eine Linie.

WAS DAS KONKRET HEISST
  * Der Rahmen hat eine Kante statt eines Strichs; zwischen den beiden
    Spalten stossen zwei Platten aneinander.
  * Die Gesprächszeile HEBT sich beim Überfahren, statt sich zu färben
    — der Unterschied zwischen einer Tabelle und einer Bedienung.
  * Das Zeichen (Kreis mit Buchstabe) ist ein Körper mit Licht, Saum
    und eigenem Schein in der Rollenfarbe.
  * Die fünf Handgriffe unter jeder Blase waren unterstrichene Wörter
    — im Netz heisst das seit dreissig Jahren „führt woandershin", und
    genau das tun sie nicht. Jetzt leise Marken. Sie bleiben SICHTBAR:
    Die Entscheidung vom 23.09. gilt weiter (auf dem Handy gibt es kein
    Überfahren).
  * Der Datumstrenner ist ein Schild auf der Linie statt nackter
    Grossbuchstaben.
  * Die Eingabe ist eine Konsole: Glas, Lichtkante, Schatten nach oben.
    Die vier Buchstaben (F K U S) standen frei im Raum — jetzt Schalter
    in einem Streifen über dem Schreibfeld.
  * Der Verlauf hat einen weichen Saum: Nachrichten laufen UNTER Kopf
    und Konsole, statt an einer harten Kante abzubrechen.
  * Titel, Unterzeile und die vier Kopfknöpfe (jetzt eine Gruppe in
    einer Mulde) bekommen eine Rangfolge.

WAS ABSICHTLICH UNANGETASTET BLEIBT: die FARBE der Blase. Sie ist die
persönliche Kachel und wird von pruef-chatkachel gemessen — die Prüfung
rechnet mit dem Farbwert selbst. Ein Verlauf oder Glas darauf hätte den
gemessenen und den gesehenen Wert auseinandergebracht, und zwar still.
Die Blase bekommt Tiefe über Kante und Schatten, nicht über den Grund.

DREI EIGENE FEHLER, VON DEN PRÜFUNGEN GEFUNDEN
  * Die Formatknöpfe hatte ich auf 32 px verkleinert — hübscher, und
    damit unter der Grenze von 44 px, unter der ein Daumen danebentrifft.
  * Vier statt zwei Pixel Abstand dazwischen = sechs Pixel mehr an der
    schmalsten Stelle. Die Zeile ist dort seit dem 23.09. auf den Pixel
    voll.
  * Meine erste Fassung der Gestaltungsleiste zerlegte die Konsole in
    drei Zeilen.

UND EIN FEHLALARM, DER SEIT LANGEM ROT WAR: pruef-chat-optik verglich
die OBERKANTEN von Schreibfeld und Senden-Knopf. Die Zeile ist aber
unten bündig, das Feld zwei Zeilen hoch — die Oberkanten liegen
zwangsläufig 24 px auseinander, obwohl beide nebeneinander stehen.
Gemerkt habe ich es erst, als zwei Reparaturen die Zahl nicht bewegt
haben: Eine Zahl, die sich durch die Reparatur nicht ändert, misst
etwas anderes, als man denkt. Sie fragt jetzt nach der GEMEINSAMEN
Höhe (44 von 44) und ist damit strenger als vorher.

GEPRÜFT: chat-optik, chatkachel, chat-ausbau, chat, chat-neu,
chat-kanaele, css-klassen, tippziele, lesbarkeit — alle 0 Fehler.
Dazu mess-chat-optik.mjs: vier Bilder (Liste und Verlauf, 1440 und
412 px) auf eigener Wegwerf-Datenbank.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 01:20:16 +02:00
DogFatherGitandClaude Opus 5 e5cd016b60 Aus jedem Fenster kommt man heraus, die Kachel dreht sich, der Eingang sieht aus wie das Haus
VIER DINGE, und das erste ist eine Meldung aus dem Support.

1. MISS KAM AUS "AUFGABE BEARBEITEN" NICHT HERAUS.
   „Ich konnte da wieder nicht zurück gehen, musste die App schließen
   damit ich wieder auf die Hauptseite kam."
   Gemessen (mess-dialog-ausgang.mjs), vier Größen:
     412x915 App      782 px Inhalt in 784 px  -- knapp ja
     412x780 Browser  782 px Inhalt in 742 px  -- SACKGASSE
     360x640 klein    794 px Inhalt in 602 px  -- SACKGASSE
     412x430 Tastatur 794 px Inhalt in 392 px  -- SACKGASSE
   `.dialog` hatte `overflow: hidden`, eine Scroll-Höhe gab es NUR für
   `.dialog--breit`. Alles unterhalb des Rands wurde abgeschnitten --
   samt "Abbrechen". Jetzt rollt JEDES Fenster, und Kopf wie Knopfzeile
   bleiben stehen (`position: sticky`), damit man den Ausgang SIEHT,
   ohne erst durch acht Felder zu scrollen. Alle vier Größen: ja.

2. DIE VORLAGENKACHEL DREHT SICH.
   „wenn ich drauf drücke dreht sich die kachel und dan seh ich wer es
   gemacht hat, und immer noch die option es nochmal zu verteilen falls
   neue leute ins team zustoßen."
   Vorne bleibt die Kurzfassung ("liegt bei 3 von 4"), hinten stehen
   die Namen mit ihrem Stand und zwei Knöpfe: "Nachholen – 1 fehlt"
   (oder "Nochmal an alle", wenn wirklich alle sie haben) und "Zurück".
   Nach dem Verteilen dreht sie sich von selbst; wer nur nachsehen
   will, drückt "Wer hat sie?".

3. DER EINGANG SIEHT AUS WIE DAS HAUS.
   Fase und Leuchtschiene statt flachem Kasten, die Schiene in der
   Farbe des Stands. Die drei Zahlen werden drei Felder -- und die
   "0 neu" leuchtet nicht mehr rot: Eine Warnung, die immer kommt, ist
   keine Warnung. Ab 760 px steht das Bild neben dem Text statt
   darunter; die Karte war dadurch dreimal so hoch wie nötig.

4. DER CREATOR-KATALOG IST AUF DER TEAM-SEITE WEG.
   „es gibt keine creator auf dieser seite" -- dort stand "Wähle oben
   einen Creator", eine Aufforderung zu etwas Unmöglichem. Gefragt wird
   jetzt nach den Daten (gibt es jemanden, dem ich das geben kann?),
   nicht nach der Adresse.

DAZU FERTIG GEMACHT, WAS VON GESTERN OFFEN WAR:
  * Die zwei Serien ohne Haus ("Community-Call", "Schulung-Agentur").
    Ursache war meine eigene Abschrift: Bei den Terminen frage ich die
    Teilnehmerliste, bei den Serien hatte ich sie vergessen. Auf einer
    Kopie der echten Datenbank: 0 offene Zeilen.
  * Sieben Schreibwege setzen jetzt `haus` (Aufgaben, Einträge,
    Dateien, Material, Wissen, Video-Titelbild). Dabei gefunden:
    `material` verwaltet seine Spalten SELBST -- meine Spalte stand in
    der falschen Liste und fehlte auf einer frischen Datenbank
    (78 Fehlschläge in pruef-material, jetzt 159/0).
  * unterstuetzen.html lud meldung.js gar nicht -- dort stand das
    Maschinenwort des Servers statt eines Satzes (pruef-meldungen 8/0).

DREI VERALTETE PRÜFUNGEN NACHGEZOGEN, jede STRENGER als vorher:
  * "der Modi legt eine Aufgabe an (201)" -- seit dem 22.09. ist das
    403 und gewollt. Geprüft wird jetzt auch das WORT.
  * "calls.html ist verboten" -- Filipe hat die Kachel selbst verlangt
    ("jeder der einen kalender hat"). Mit Gegenprobe ersetzt.
  * "Review" heißt seit dem 20.09. "Zur Freigabe". Der Name wird jetzt
    aus STATUS_NAME GELESEN statt abgeschrieben.

GEPRÜFT: modi-katalog 150/0 (war 144), modi-verborgen 85/0 (war 80/2),
haus-trennung 97/0, material 159/0, meldungen 8/0, abbrechen-optik 0
Fehler. Dazu grün: an-alle, vorlagen, support, css-klassen,
aufgabenbrett, aufgaben-vorlagen, unterstuetzung, formulare, loeschen,
nachfrage, kalender, chat, leerzustand.

OFFEN UND NICHT ANGEFASST: pruef-breiten meldet auf report.html ein
Berührziel von 27x18 px. Der Link (`class="zurueck"`) ist auf 30
Seiten derselbe und hat gar keinen eigenen Stil; beanstandet wird nur
diese eine Seite, weil dort hinter ihm nur "· Review" steht und die
Prüfung Fließtext-Links erst ab 12 Zeichen Umgebung ausnimmt. Eine
Klasse auf 30 Seiten ohne Prüflauf zu ändern wäre geraten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 00:50:15 +02:00
DogFatherGitandClaude Opus 5 14d6f5000c Der Titel heisst wieder "Zentrale" -- und gehoert jetzt der Adresse
Filipe: "anstatt irrenanstalt soll da auch Zentrale stehen bitte. auch
getrennt von der team dogi seite da steht was anderes und soll auch so
bleiben."

NACHGEMESSEN, BEVOR ETWAS GEAENDERT WURDE -- und es stand NICHT etwas
anderes. `crewWeiche` biegt fuer das Teamhaus sechs Dinge um (Manifest,
App-Symbole, Zugangswand, Buehnen, Marke, Haus-CSS); `start.html` ist
nicht dabei, und kein Skript hat `#ztitel` je angefasst. Auf BEIDEN
Adressen stand seit dem 22.09.2026 derselbe fest eingebaute Titel.
Verschieden war nur die Zierzeile darueber -- "Spicy Media" gegen
"Team Dogi" --, und die hat vermutlich den Eindruck gemacht.

WAS JETZT GILT
  * In start.html steht "Zentrale". Das ist die Vorgabe und gilt fuer
    das Agenturhaus.
  * Das Wort des Teamhauses kommt vom Server (`titelFuer`, direkt neben
    `markeFuer`, nach demselben Muster). Dort bleibt damit woertlich
    stehen, was vorher dastand -- ab dem 24.09. wird jeder Umbau je
    Haus getrennt gefuehrt, und dies ist der des Agenturhauses. Ob
    Filipe dort etwas anderes will, entscheidet er; geraten wird es
    nicht.

NACH DER ADRESSE UND NICHT NACH DER ROLLE, anders als bei der Marke:
Ein Titel sagt, WO man ist, eine Marke sagt, zu WEM man gehoert. Auch
der Sicht-Umschalter aendert ihn nicht -- wer eine fremde Sicht oeffnet,
wechselt die Zahlen, nicht das Haus.

UND ER STEHT NICHT MEHR IN EINER DATEI, DIE JEDER HERUNTERLAEDT.
Derselbe Grund wie bei MODI_MARKE zwei Zeilen darueber: Was nur das
Teamhaus angeht, gehoert nicht in start.html, die jeder Creator beim
Oeffnen bekommt. Die Schreibweise mit grossem A in der Mitte ist
weiterhin so gewollt und steht jetzt in workspace.js.

GEMESSEN
  * pruef-haus-trennung 97 -> 100 Pruefungen, 0 Fehler. Die drei neuen
    verlangen den UNTERSCHIED, nicht den Wortlaut: auf crew. ein
    eigener Titel vom Server, auf workspace. keiner (dort gilt die
    Seite), und die Zierzeilen sind ebenfalls verschieden. Ein
    Vergleich mit "Zentrale" waere beim naechsten Umbenennen rot, ohne
    dass etwas kaputt ist -- diese Sorte Fehlalarm hatte ich heute
    schon einmal.
  * pruef-start-ansicht 157, pruef-deutsche-texte 12 -- unveraendert.
  * Angesehen bei 1280 px und 390 px: "◆ SPICY MEDIA ◆" darueber,
    "Zentrale" darunter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 00:03:19 +02:00
DogFatherGitandClaude Opus 5 e1ee778c04 Vier Stufen im Agenturhaus -- und die Modi-Liste verschwindet von dort
Filipe, mit dem Bildschirmfoto der LIVE-Punkte: "diese aufgaben auf
screen. alle auf dieser app getrennt von denen auf der team dogi
website bitte, sehr wichtig. die sollen die manager und scouts bewerten
können mit passt passt nicht verbesserung möglich und was weiß ich. und
die creator sollen sehen was bei ihnen passt oder nicht mit der notiz
vom manager oder scout. spicy und dogfather sollen auch bewerten können
wie vorher. ... und wie gesagt von der team dogi seite da ist ein
anderes system auf diesen aufgaben."

WAS AUF DEM BILDSCHIRMFOTO STAND, WAR NICHT SEINE SEITE
Unter "Vor der Sendung" stand die Liste eines MODIS -- erkennbar am
Satz darueber ("Was du vor und beim Start gesehen hast") und an den
Punkten ("Die Ankuendigung kam rechtzeitig"). Am echten Bestand
nachgemessen: Die Auswahl "Person" fuellte sich aus allen Creatorn PLUS
allen Modis, sortiert nach Namen. Der erste Name im Haus ist "Diene",
eine Modi -- und ohne ausdrueckliche Wahl nimmt die Seite den ersten.
DogFather bekam auf der Agenturadresse also zuverlaessig das Teamhaus
zu sehen, und druecken konnte er dort nichts, weil ein Modi-Bericht nur
dem Modi selbst gehoert.

DIE GRENZE, AN DREI STELLEN STATT AN EINER
  * Die Auswahl geht durch EIN Sieb (hat diese Person ueberhaupt eine
    Liste, und steht sie in diesem Haus?) statt durch drei einzeln
    gepflegte Bedingungen.
  * Die Grenze haelt auch gegen eine von Hand eingetragene Nummer --
    eine ausgeduennte Auswahlliste ist Kosmetik, solange ?creator_id=
    durchgeht.
  * Die gueltigen Punkt-Schluessel lagen fuer beide Haeuser in EINER
    Menge. Ein Scout konnte damit bei einem Creator den Stand eines
    Modi-Punktes setzen: angenommen, gespeichert, nie zu sehen.
  * Dazu: `darfCreator` sagt fuer DogFather bei JEDER Nummer ja -- er
    konnte einen Stand an einer Managerin oder an sich selbst setzen.
Drei Lagen wie bei den Aufgaben: crew / agentur / keine Adresse. Der
dritte Ausgang ist kein Schlupfloch, sondern die Bedingung dafuer, dass
die Pruefungen ueberhaupt noch etwas messen koennen.

ZWEI SKALEN, WEIL ES ZWEI VERSCHIEDENE DINGE SIND
Agentur (Betreuung urteilt, Creator liest): Passt / Verbesserung
moeglich / Passt nicht / Trifft nicht zu. Team (Modi berichtet,
DogFather behandelt im Eingang): Passt so / Verbessern, unveraendert --
eine Stufe "Passt nicht" haette dort keinen Empfaenger.
"Trifft nicht zu" ist kein Beiwerk: Ohne sie steht ein Punkt, der bei
diesem Creator gar nicht vorkommt, fuer immer auf "offen" und die
Bilanz zaehlt ihn als unerledigt mit.
Die Worte, die Toene und die Frage im Nachfragefenster kommen vom
Server. Der Browser baut Knoepfe, Marken und Kacheln daraus und kennt
keine Stufe beim Namen -- sonst muesste er ausserdem wissen, WANN
welche gilt, und das waere ein Rollenvergleich in einer Datei, die
jeder herunterladen kann.

DIE NOTIZ TRAEGT JETZT AUCH DIE ROLLE
"mit der notiz vom manager oder scout" -- bis hierher stand am Satz nur
ein Vorname. Wer die Namen im ersten Monat nicht kennt, weiss nicht,
wer da urteilt. Jetzt: "Patrick, Scout · 24.09., 23:43".

DIE UMSTELLUNG DER DATENBANK KOMMT NICHT VON MIR
Eine CHECK-Regel laesst sich in SQLite nicht aendern; die Tabelle muss
neu gebaut werden. Ich hatte den Griff hier zuerst ein zweites Mal
geschrieben -- mit Zeilenzaehlung und PRAGMA-Spaltenliste, aber OHNE
die Sicherung davor, ohne die Indizes und ohne `foreign_key_check`
danach. Drei von fuenf Absicherungen fehlten, und keine davon haette
gefehlt, wenn ich die vorhandene Funktion benutzt haette. Genau davor
warnt ihr eigener Kommentar seit dem 09.09.2026.
Jetzt: `checkListeErweitern` aus workspace.js, ausgegeben statt
nachgebaut. Der Marker ist die erste fehlende Stufe und keine
hingeschriebene -- eine feste Angabe waere an dem Tag falsch, an dem
eine weitere dazukommt.
Und danach wird NACHGESEHEN, was wirklich erlaubt ist: Bricht die
Umstellung ab, werden die neuen Stufen auch nicht angeboten. Ein Knopf,
der beim Druecken scheitert, ist schlechter als kein Knopf.

WAS SONST NOCH NACHGEZOGEN WURDE
  * Der Zaehler auf der Creator-Startseite zaehlte fest
    `stufe = 'verbessern'`. Die staerkste Rueckmeldung, die es gibt,
    waere als Einzige nicht dort erschienen. Jetzt aus dem Katalog.
  * Der Satz unter "Feste Punkte" stand im Browser und sprach in BEIDEN
    Haeusern vom "Creator". Die Teamfassung bleibt wortgleich -- ab dem
    24.09. wird jeder Umbau je Haus getrennt gefuehrt, und dies ist der
    des Agenturhauses.
  * "Passt" setzt weiterhin mit einem Klick. Ein Nachfragefenster vor
    dem haeufigsten Klick einer Betreuung, die vierzig Punkte durchgeht,
    macht aus einem Durchgang eine Sitzung.

GEMESSEN
  * pruef-checkliste-stufen.mjs, neu: 56 Pruefungen, 0 Fehler. Darin
    die Umstellung an einer Datenbank mit dem ALTEN Bauplan und echten
    Zeilen -- Zeilen, Spalten UND Spalteninhalte nachgezaehlt, plus die
    Sicherung. Zu jeder Schranke die Gegenprobe, die durchkommen muss.
  * pruef-checkliste 97, pruef-modi-checkliste 75, pruef-haus-trennung
    97, pruef-manager-sicht 43 -- alle unveraendert gruen.
  * pruef-checkliste rechnete mit festen Zahlen (drei Bilanzkacheln,
    zwei Knoepfe je Punkt) und war rot, ohne dass etwas kaputt war. Sie
    fragt die Zahlen jetzt bei der Schnittstelle ab und zaehlt sie im
    Browser nach. Gleich viele Pruefstellen, 57.
  * Bildschirmfotos bei 1280 px und 390 px (Betreuung, Creator, das
    Nachfragefenster): vier Knoepfe passen auf dem Handy als 2x2, 0 px
    Ueberhang, keine Konsolenfehler.
  * Die neuen Toene sind gerechnet, nicht gegriffen: #d97f87 hat die
    relative Helligkeit 0,317 -- so hell wie das vorhandene Gruen
    (0,320) und heller als das Blaugrau von "offen" (0,241), das den
    Barrierefreiheits-Lauf schon bestanden hat. Kein Signalrot: Ein
    gedaempftes Rosé sagt "das gehoert geaendert", ein Rot sagt "du
    hast versagt".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 23:49:01 +02:00
DogFatherGitandClaude Opus 5 9863645952 Die zwei Häuser sind getrennt -- und die Tür geht in beide Richtungen
Filipe: "ich will dass du zuerst die komplette site vn der workspace
seite trennst. da soll nichts verknüpft sein. wenn ich bei der einen
was mache soll nichts bei der anderen passieren. … es soll nur für
dogfather eine kachel geben wo er mit einem einfachen klick von der
einen auf die anderen seite kommt aber sonst garnichts."

Das kehrt die Entscheidung vom 10.09.2026 um ("getrennt wird das
AUSSEHEN, nicht der Bestand"). Wer den alten Kommentar liest, liest
einen überholten Stand -- das steht jetzt an jeder betroffenen Stelle.

WAS GEMESSEN WAR, BEVOR ETWAS GEBAUT WURDE
  * Nur drei Module trennten nach Haus (Aufgaben, Bereiche, Dateien).
    Chat, Kalender, Wissen, Material, Personenlisten und der Rest nicht.
  * Die Trennung war EINSEITIG: nurHaus() griff nur auf crew.
  * siehtModis() hebelte sie für DogFather auf der Agenturseite aus --
    in seiner Gesprächsliste standen dort beide Häuser nebeneinander.
  * Keine haus-Spalte in der Datenbank.
  * Der Bestand kreuzte aber kaum: 0 von 86 Terminen gemischt, 0 von 7
    Zweier-/Gruppengesprächen, genau EIN Kanal.

WAS JETZT DASTEHT
  * Drei Rollenmengen in crew-adresse.js (crew / agentur / beide) und
    hausVonRolle(); eine unbekannte Rolle bekommt null, kein Haus.
  * Spalte `haus` an acht Wurzeltabellen, nachgetragen aus Belegen:
    238 Zeilen eindeutig, die Wissensablage geschlossen der Agentur,
    vier Restzeilen namentlich, der gemischte Kanal aufgelöst
    (die zwei Scouts gehen heraus, die 7 Nachrichten sind alle vom
    Team). Offen bleiben: null.
  * nurHaus, hausBedingung und darfAnlegen gelten in BEIDE Richtungen.
  * Der siehtModis-Durchgriff ist weg -- aber in DREI Fällen, nicht
    zwei: Prüfadressen bekommen gar kein Haus und verhalten sich exakt
    wie vorher. Die erste Fassung hatte das übersehen und 19 Prüfungen
    umgeworfen, an denen nichts kaputt war.
  * Kalender: getrennt, aber "belegt" bleibt (Filipes Entscheidung).
    Die Blöcke tragen NUR Beginn und Dauer -- kein Titel, keine Person.
    Gebaut als Gegenstück zur Liste (meine Termine MINUS die sichtbaren),
    damit beide nicht auseinanderlaufen können.
  * Die Wissens-Kachel ist auf der Team-Adresse weg UND die Route
    antwortet dort mit 404 -- eine fehlende Kachel ist nur eine Bitte.
  * Die Wechsel-Kachel für DogFather geht jetzt in beide Richtungen.

GEPRÜFT: pruef-haus-trennung 97 statt 81, 0 Fehler (vorher 7, alle
haben die alte Regel behauptet). Die neuen Abschnitte sind DORT
eingezogen statt in eine zweite Datei -- `pruef-haustrennung.mjs` hätte
sich von `pruef-haus-trennung.mjs` um einen Bindestrich unterschieden.
Dazu grün: haus-seiten, crew-adresse, chat, chat-kanaele,
kanal-besetzung, kalender, serien, treffchat, wissen-neu,
modi-checkliste, modi-katalog, rechtetafel, personen-liste, sicht,
verborgen, fremde-sicht, alle-wege.

NICHT VON MIR: pruef-treff (3) und pruef-kachel-universum (2) waren
schon vorher rot -- beim Treff auf dem Stand 40b48e89 nachgemessen,
bei den Farben steht dieselbe Zahl im Kopf der Prüfung selbst.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 20:40:20 +02:00
DogFatherGitandClaude Opus 5 40b48e89f1 Bei "An alle" sieht man jetzt, wer sie hat und wer nicht
Filipe: "wenn ich eine aufgabe an alle verteile will ich dass dogfather
und die rechte hand individuel von jedem sehen wer es gemacht hat oder
nicht."

Vier Fehler, die zusammenhingen -- alle gemessen, keiner geraten:

1. "Alle" waehlen, "An alle" druecken, nichts passiert. Die Zeile
   verglich verantwortlich_id !== "alle"; niemand heisst so, also wurde
   jede Aufgabe uebersprungen und die Liste blieb leer. Die Aufgaben
   entstanden, man sah es nur nicht.

2. Der Vermerk an der Karte haette bei "alle" den Stand EINER fremden
   Person gezeigt -- welcher, haengt von der Reihenfolge der Daten ab.
   Jetzt steht dort, wie weit es ist, und darunter namentlich, wer sie
   hat: Offen / Erledigt / ueberfaellig / hat sie nicht. Das Wort steht
   immer dabei, die Farbe ist nur die Abkuerzung.

3. Die "An wen"-Reihe zeigte SECHS Personen, der Server belieferte VIER.
   Rechte und linke Hand gingen leer aus, ohne ein Wort; einzeln
   angeschrieben kam "Das gibt es nicht mehr, lade die Seite neu" zu
   jemandem, den es sehr wohl gibt. Empfaenger sind jetzt Modis UND
   linke Hand (Filipes Regel vom 22.09.), und die Menge steht EINMAL in
   workspace.js -- SQL-Abfrage, Annahme und Browserliste leiten sich
   daraus ab und koennen nicht mehr auseinanderlaufen.

4. Zweimal "An alle" legte alles doppelt an. Der Kommentar im Server
   behauptete das Gegenteil; aktiv war die Sperre nur beim Massenknopf.
   "An alle" fuellt jetzt Luecken. Die bewusste Wiederholung bleibt:
   Steht am Knopf "Nochmal" (weil wirklich alle sie haben), sagt der
   Browser das ausdruecklich, und dann legt der Server neu an.

Geprueft: pruef-modi-katalog 144 statt 133, 0 Fehler -- elf neue
Pruefungen fuer Empfaenger, Luecken und die Gegenprobe, dass ein
gewolltes "Nochmal" sehr wohl anlegt. Dazu mess-alle-einzelsicht.mjs
(eigene Wegwerf-Datenbank, nie die echte) mit Bildern bei 412 und
1280 px.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 17:26:35 +02:00
DogFatherGitandClaude Opus 5 67b5e00150 Die Support-Kachel steht zwischen "Wer sieht was" und "Vertraulich melden"
Filipe, mit zwei Bildschirmfotos: "die kachel auf screen 2 soll zwischen
den kacheln auf screen3 sein bitte."

ZWEITER FEHLER, DEN ER NICHT GEMELDET HAT -- und der groessere: Die
Support-Kachel hatte GAR KEINE Gruppe. Sie stand deshalb bei JEDER
Rolle in einem eigenen Abschnitt OHNE UEBERSCHRIFT, allein, mit einem
Aufklapp-Kopf, auf dem nur "1" stand. Gebaut habe ich das heute frueh;
auf seinem Bildschirmfoto war es zu sehen, und mir ist es nicht
aufgefallen, weil ich auf die Kachel geschaut habe und nicht auf das,
was um sie herum steht.

Jetzt gehoert sie zu "Fuer dich" und wird VOR den vertraulichen
Meldeweg eingeschoben statt ans Ende gehaengt. Gesucht wird der NACHBAR
ueber sein Ziel, nicht eine Position -- eine feste Zahl waere beim
naechsten Umbau still falsch. Findet sich der Nachbar nicht (ein Modi
hat den Meldeweg nicht), steht sie am Ende der Gruppe.

GEMESSEN, NICHT GERECHNET (1280 px, angemeldet, je Rolle):

  admin/hand  Fuer dich:  Steckbrief | Wissen | Personen & Zugaenge
                          Wie geht's dir? | Wer sieht was | Support
                          Vertraulich melden
  modi        Fuer dich:  Steckbrief | Wissen | Wie geht's dir?
                          Support
  gast        Fuer dich:  Support | Vertraulich melden | Steckbrief

Bei DogFather und der rechten Hand bricht "Vertraulich melden" in eine
eigene Zeile um: Die Gruppe hat jetzt sieben Kacheln, das Raster drei
Spalten, und sieben geht durch drei nicht auf. Die Luecke faellt ans
ENDE -- das ist die bessere der beiden Moeglichkeiten und dieselbe
Regel, die im Kommentar zur Treff-Reihe steht: "eine Luecke in der
Mitte sieht kaputt aus, eine am Ende sieht grosszuegig aus."

NEU: server/mess-kachelreihen.mjs. Die Reihenfolge im Quelltext ist
NICHT die auf dem Bildschirm -- "Willkommen" belegt zwei Spalten,
gruppiert wird nach dem ersten Auftreten einer Gruppe, und eine Kachel
ohne `gruppe` bekommt einen eigenen namenlosen Abschnitt. Genau daran
ist dieser Fehler entstanden. Die Datei misst es im Browser, je Rolle,
und meldet einen Abschnitt ohne Ueberschrift ausdruecklich als Befund.
Sie prueft nichts.

Gemessen: pruef-support 45, pruef-unterstuetzung 70,
pruef-start-ansicht -- gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 14:37:47 +02:00
DogFatherGitandClaude Opus 5 e11f775489 "Unterstuetzen" steht jetzt links, "Mitmachen" in der Mitte
Filipe, mit Bildschirmfoto der letzten Reihe: "wechsel bitte die
reihenfolge, links soll unterstuetzen sein, in der mitte dan mittmachen
und recht regeln & hilfe."

Mein erster Entwurf hatte "Unterstuetzen" HINTER "Mitmachen", mit der
Begruendung "erst dazugehoeren, dann etwas beitragen". Die Ueberlegung
war nicht falsch -- sie war nur meine. Der Kommentar an der Kachel sagt
das jetzt auch so; stehengelassen haette er beim naechsten Lesen eine
Reihenfolge begruendet, die es nicht mehr gibt.

GEMESSEN STATT GERECHNET: Die Reihenfolge im Feld ist nicht die
Reihenfolge im Raster -- "Willkommen" belegt zwei Spalten und
verschiebt alles danach. Im Browser nachgesehen (Gast, 1280 px):

  Reihe 1   Willkommen (2) | Rudel-Chat
  Reihe 2   Highlights | Anschlagbrett | Was ansteht
  Reihe 3   Wunschliste | Dogi-Media | Draussen
  Reihe 4   Unterstuetzen | Mitmachen | Regeln & Hilfe

Nebenbei: Die Rasterskizze im Kommentar darueber zeigt den Stand vom
17.09.2026 und stimmt seither nicht mehr mit der Kachelliste ueberein.
Sie ist jetzt als solche gekennzeichnet, statt als aktuelle Karte
gelesen zu werden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 14:23:23 +02:00
DogFatherGitandClaude Opus 5 aa2e1a4ae2 Eine Kachel "Unterstuetzen" -- und die Gespraechsliste neu gebaut
=== 1. UNTERSTUETZEN (neue Kachel im Community-Bereich) ===

Filipe: "ich brauch auch noch eine kachel im community bereich. wo mein
paypal und meine amazon liste sein wird. wo die leute alle supporten
koennen auf andere art anstatt nur tiktok. jeder soll diese kachel sehen
aber nur dogfather soll sie veraendern koennen oder vieles mehr sehen."

Neu: unterstuetzen.html + css + js, server/workspace-unterstuetzung.js,
server/unterstuetzung-tabellen.js. Ton 45 (#7567fe) ist gerechnet, das
Kachelzeichen ist ein Herz ueber zwei offenen Haenden.

DIE WEGE STEHEN IN DER DATENBANK, nicht im Quelltext -- ein Recht, das
man nur ueber einen Entwickler ausueben kann, ist keines. DogFather
schreibt Titel, Text, Knopf und Adresse selbst, blendet Wege aus und
nimmt neue dazu.

DER PAYPAL-LINK STEHT LEER UND UNSICHTBAR DA. Filipe schrieb "mein
paypal kennst du ja schon" -- gesucht im ganzen Haus und im Vault,
nirgends gefunden. Eine Zahlungsadresse zu RATEN waere der
gefaehrlichste Fehler dieser Seite: Geld an einen Fremden, und niemand
merkt es. Also steht dort nichts, der Weg ist ausgeblendet, und die
Seite sagt genau EINER Person, dass er fehlt.

DREI ARTEN, UND DIE DRITTE IST DIE WICHTIGSTE: geld, geschenk, frei.
"Kostet nichts" bekommt dieselbe Kartenform und dieselbe Groesse.
Waeren die freien Wege kleiner oder weiter unten, waere die Aussage
"das ist zweite Wahl" -- und die Mehrheit derer, die hier lesen, waere
damit zweite Wahl.

DIE ZAEHLUNG IST ANONYM, UND ZWAR VON DER TABELLE HER. DogFather sieht,
wie oft ein Weg geoeffnet wurde (7/30 Tage/gesamt). Was er NICHT sieht,
ist WER -- weil es in unterstuetzung_striche keine Spalte dafuer gibt.
Geprueft wird das ueber PRAGMA table_info, nicht ueber eine Abfrage:
eine Abfrage liesse sich morgen erweitern, eine fehlende Spalte nicht.

Schreiben haengt an istDogFather, nicht an istLeitung -- die rechte Hand
fuehrt dieses Team mit und kommt trotzdem nicht an diesen Link. Jede
Adresse wird beim SCHREIBEN geprueft (nur https:// oder eine Seite
dieses Hauses); javascript:, data:, http:// und // werden abgewiesen.

Neu: server/pruef-unterstuetzung.mjs -- 70 Pruefungen, alle gruen.
Sie misst ueber die CREW-ADRESSE: Beim ersten Lauf waren vier Rollen
gruen und der Zuschauer rot, und es sah nach einem Rechtefehler aus.
Es war ein Messfehler -- ueber 127.0.0.1 landet man still im
Agenturhaus, und dort gibt es die Rolle "gast" gar nicht.

=== 2. DIE GESPRAECHSLISTE (screen1 + screen2) ===

Filipe: "ich will dass die komplette kachel viel krasser und geiler
aussieht. der hintergrund der kachel soll gleich bleiben."

Der Hintergrund ist unangetastet. Zwei echte Fehler auf seinem Bild:

  - Bei "Das Rudel" stand das "@" rechts oben und die orange "6" eine
    ZEILE TIEFER. Der Knopf ist ein Raster mit DREI Spalten und bekam
    VIER Kinder -- das vierte fiel um. Ausgerechnet die wichtigste
    Auskunft der Liste landete an der unauffaelligsten Stelle.
  - Jedes Gespraech mit Profilbild haengte das Bild ZWEIMAL ein: ein
    Block stand Zeichen fuer Zeichen doppelt da. Zu sehen war nichts,
    gekostet hat es die doppelte Ladelast bei jedem Neuzeichnen.

Und eine tote Regel: `.chat-raum__knopf[data-an="ja"]` beschrieb die
Schiene am offenen Gespraech -- gesetzt wird aber `data-offen` am <li>.
Die Regel griff nie, und daneben stand eine zweite, blassere Fassung
derselben Sache. Jetzt steht alles einmal, und die Schiene ist da.

Dazu: Zeilen als Karten, 44px-Gesichter, ein Zaehler neben "Gespraeche",
Suchfeld als Pille mit Lupe, runder Farbfleck statt Quadrat, und der
leere Raum rechts bekommt eine Mitte statt eines Satzes in der Ecke.

=== 3. VIER FUNDE NEBENBEI ===

  - supportAufraeumen() war exportiert und wurde NIRGENDS gerufen. Die
    Meldungen samt Bildschirmfotos waeren fuer immer liegen geblieben,
    obwohl "90 Tage" dokumentiert ist. Jetzt im Loeschkonzept.
  - aufgaben_zuteilung fehlte ebenfalls im Loeschkonzept (seit 21.09.).
    pruef-aufbewahrung ist damit wieder gruen.
  - pruef-start-ansicht war rot, seit "Aufgaben" am 23.09. die silberne
    Kachel bekam -- die ueberschreibt ihr --ton absichtlich. Die
    Pruefung nimmt sie jetzt aus UND prueft die Ausnahme selbst.
  - pruef-buehne meldete auf entwicklung.html 1,79:1 Kontrast bei "Wen
    gehst du durch?" -- die Ueberschrift lag direkt auf dem Foto. Sie
    steht jetzt auf einer deckenden Flaeche.

OFFEN: pruef-buehne meldet treff-regeln.html mal "0 von 40", mal "3 von
40" -- dieselbe Seite, verschiedene Antworten. Eine Pruefung, die
schwankt, ist schlimmer als eine rote. Nicht in diesem Zug behoben.

Gemessen: pruef-unterstuetzung 70, pruef-start-ansicht, pruef-buehne
(bis auf den Wackler), pruef-workspace-seiten, pruef-aufbewahrung 45,
pruef-rechtetafel 19, pruef-css-klassen, pruef-chatkachel 36,
pruef-chat-ausbau, pruef-entwicklung-kacheln 31 -- gruen.

Neu: server/mess-chat-liste.mjs (zeigt die Liste mit sieben Gespraechen
verschiedener Art, prueft nichts).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 14:14:25 +02:00
DogFatherGitandClaude Opus 5 5b244ab7ec Die Personenkachel wird eine Standkarte -- mit den Aufgaben darauf
Filipe: "mach das bitte viel krasser und viel geiler. die aufgaben die
wir verteilen sollen da auch zu sehen sein und so. viel
uebersichtlicher, viel moderner. nicht so banal und einfach."

VORHER: Name, Rolle, ein flacher Balken und drei Zahlenkaesten. Auf
seinem Bildschirmfoto standen in fuenf von sechs Kacheln exakt
dieselben drei Zahlen (0 / 68 / 0) -- achtzehn Kaesten fuer eine
einzige Auskunft. Und die Aufgaben, die er eine Handbreit darunter
verteilt, kamen auf der Kachel derselben Person gar nicht vor.

JETZT drei Zonen in der Reihenfolge, in der man fragt:
  KOPF     Ring mit dem Anteil in der Mitte, Name, Rolle.
  AUFTRAG  Wie viele offen, wie viele ueberfaellig, und die naechste
           mit Namen und Frist ("seit gestern ueberfaellig").
  FUSS     "46 von 68 angesehen" -- und "verschieden gesehen" nur
           dann, wenn es dort etwas gibt.

Der Ring rechnet mit stroke-dasharray auf einem Kreis vom Umfang 100:
der Anteil in Prozent ist damit buchstaeblich die Strichlaenge. Keine
Ampelfarbe -- was "genug" ist, haengt davon ab, wie lange jemand dabei
ist. Gewarnt wird nur an einem Massstab, der nicht geraten ist: einer
ueberschrittenen Frist.

Die Aufgabenzeile wird aus derselben Liste gerechnet wie das
Verteil-Band und der Katalog (alleAufgabenV) und in aufgabenHolen()
nachgezogen -- an EINER Stelle, damit es keine gibt, die es vergisst.

GITTER STATT FLIESSREIHE: Bei sieben Leuten stand die letzte Kachel
allein in ihrer Zeile und wuchs auf 1160 px neben 379 px der anderen.
Eine Person sah dreimal so wichtig aus, weil die Teamgroesse ungerade
ist.

Drei Funde nebenbei, alle durch die neuen Messungen:

  - pruef-schritt verlangte seit dem 23.09. einen Sprung, der an dem
    Tag ABSICHTLICH entfernt wurde. Sie war seither rot, ohne dass
    etwas kaputt war. Neu gefasst auf den Sprung, den es noch gibt:
    den Weg von aussen ueber "?zeigen=person-...".
  - Und der war kaputt. Gesprungen wurde, waehrend in der Karte nur
    "wird geladen ..." stand -- die Seite war zu kurz zum Scrollen,
    der Browser klemmte bei 0 ab. Wer der Talentseite folgte, landete
    oben auf der Liste. Gesprungen wird jetzt, wenn die Karte steht.
  - Auf demselben Weg rief karte() das Vorlagenbrett, bevor es
    eingerichtet war ("zeigen() vor einrichten()"). Das Einrichten ist
    vorgezogen. Ueber "?zeigen=" ging vorher KEINE Pruefung.

Gemessen: pruef-entwicklung-kacheln 31 (vorher 16), pruef-schritt 68
(vorher 65), pruef-entwicklung 48, pruef-modi-katalog 133,
pruef-bewerbung-aufgaben 101, pruef-zuteilung, pruef-css-klassen --
alle gruen. Schriftgroessen unter 11,5 px: 40 statt 42.

Neu: server/mess-entwicklung-kacheln.mjs. Die Pruefung braucht einen
kargen Bestand und zeigt deshalb den Sonderfall (alles auf null); diese
Datei legt sieben Leute mit verschiedenen Staenden und Aufgaben an und
macht Bilder fuer 1280 und 390 px. Sie prueft nichts.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 13:30:17 +02:00
DogFatherGitandClaude Opus 5 1d41e29bf7 Drei weitere Prüfungen, die das Falsche prüften
Der Durchgang, zweiter Teil. Gesucht nach dem schaerfsten Filter, den
es dafuer gibt: dem VERSPRECHEN GEGEN DIE WIRKLICHKEIT -- Pruefsaetze,
die „jede Rolle" oder „jede Seite" sagen. Genau so ist pruef-glocke
heute frueh aufgefallen.

1. pruef-workspace-seiten meldete aufgaben.html als „Breite 1560px".
   KEIN FEHLER: Die Seite traegt zusaetzlich `.inhalt--brett`, und die
   setzt ausdruecklich 1560 px -- mit Begruendung in aufgaben.css („ein
   Brett darf breiter sein als Text, der Kopfbereich bleibt lesbar
   schmal"). Die Pruefung verglich starr mit 1240 und kannte die
   Klasse nicht; sie meldete damit eine Absicht als Fehler, seit dem
   Tag, an dem die Klasse entstand. Mit Gegenprobe belegt: schon vor
   allen Aenderungen von heute rot.
   Jetzt kommt die erwartete Breite aus den KLASSEN des Elements.
   Beide Zahlen bleiben stehen, weil sie etwas aussagen -- kommt
   weder 1240 noch 1560 an, ist die Regel verloren.

2. pruef-meldungen meldete „ohne Satz: ungueltiger_stand". Zuerst ein
   ECHTER Fehler, und zwar meiner vom selben Tag: In
   workspace-support.js stand eine nackte Kennung statt eines Satzes.
   Behoben -- und danach meldete die Pruefung sie WEITER, weil sie das
   Zitat im Kommentar las, der die Behebung begruendet.
   Dieselbe Falle wie ein Grep ueber eine Datei, die ihre eigene
   Geschichte enthaelt; mir ist sie heute schon einmal passiert. Eine
   Pruefung, die verbietet, ueber einen behobenen Fehler zu SCHREIBEN,
   erzieht dazu, die Begruendung wegzulassen. Kommentare zaehlen jetzt
   nicht mehr; Adressen mit // in Zeichenketten bleiben unberuehrt.

3. pruef-auskunft meldete „NICHT EINGEORDNET:
   vorlagen_bewerbungen.aufgabe_id". ECHT: Die Spalte zeigt auf eine
   Aufgabe, nicht auf einen Menschen, und stand in keiner der beiden
   Listen. Das ist mehr als Ordnungsliebe -- bei einer
   Auskunftsanfrage muss das Haus sagen koennen, welche Spalten auf
   eine Person zeigen. Eine unbekannte Spalte ist eine, bei der
   niemand weiss, ob sie mitgehoert.

ZWISCHENSTAND: ACHT Pruefungen an einem Tag, die rot waren oder das
Falsche prueften. Das Muster ist immer dasselbe -- eine Liste oder
Zahl, die zum Zeitpunkt des Schreibens stimmte. Sie wird nicht falsch,
sie wird unzustaendig.

Gruen: pruef-meldungen (8), pruef-auskunft (46),
pruef-workspace-seiten, pruef-support (45), pruef-vorlagen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 13:01:25 +02:00
DogFatherGitandClaude Opus 5 24523cdd4b Die GIF-Kiste ist fertig – eine Prüfung sagt es jetzt auch
Filipe: „mach das mit den gifs auch fertig."

ERGEBNIS: SIE WAR ES SCHON. Hineinlegen, Kiste anzeigen, verschicken,
herausnehmen, Duplikat-Erkennung am Inhalt, Fortschrittsanzeige mit
Abbruch, Standbild bei „Bewegung reduzieren" -- alles vorhanden, alles
live, und pruef-chat-anhaenge deckt die Wege ab.

SECHS „BEFUNDE" MEINES ERSTEN LAUFS WAREN ALLE MESSFEHLER:
  * zu frueh gemessen (`darf_gif` kommt mit den Raumdaten; der Knopf
    wird erst danach freigeschaltet)
  * kein Content-Type gesetzt -> „Keine Datei empfangen"
  * Selektor `.chat-raum` erfunden; die Klasse heisst
    `chat-raum__knopf`
  * nach `title*='ausnehm'` gesucht; der Knopf heisst „Aus der Kiste
    nehmen" und hat die Klasse `gif-kachel__weg`
  * `x-name` geschickt; der Server liest `x-dateiname`
  * nach einer GIF-Adresse im Verlauf gesucht -- das GIF wird beim
    Verschicken KOPIERT und haengt danach als Anhang
  * und einmal `curl` auf chat.html ohne Anmeldung: eine Umleitung,
    null Treffer, und ich hielt die Tafel fuer nicht ausgeliefert

Ich haette beinahe gebaut, was es laengst gibt -- wie heute frueh beim
Farbwerkzeug. Der Unterschied: Diesmal habe ich vor dem Bauen
nachgesehen.

WAS BLEIBT, IST DIE PRUEFUNG. pruef-chat-anhaenge prueft die WEGE;
ungeprueft war die OBERFLAECHE -- dass der Knopf fuer Team Dogi
erscheint und fuer einen Creator nicht, dass die Tafel aufgeht, dass
an jeder Kachel ein Weg zum Herausnehmen steht, dass ein verschicktes
GIF wirklich im Verlauf landet. Genau dort haette ich gebaut, was es
schon gibt. 17 Pruefungen, 0 Fehler.

=== Und der Durchgang durch die Pruefungen (erster Teil) ===

Gesucht nach dem Muster, das heute fuenfmal zugeschlagen hat:
abgeschriebene Listen. Gefunden: pruef-buehne kennt 19 von 38 Seiten,
pruef-workspace-seiten 18 -- je VIERZEHN mit Kopfzeile und damit
ungeprueft. support.html fehlt in beiden; sie ist heute entstanden.

In pruef-workspace-seiten steht die Lehre woertlich im Kopf: „Eine
Pruefung, die eine Seite nicht kennt, kann auf ihr nichts finden."

Ein Probelauf mit abgeleiteter Liste: 30 statt 18 Seiten, 28 rot --
24 davon, weil die Seite gar kein `data-buehne` traegt. Das ist eine
Gestaltungsfrage (welche Szene wohin), keine Reparatur. DIE
ERWEITERUNG IST DESHALB WIEDER DRAUSSEN: Eine Pruefung, die ab sofort
dauerhaft rot ist, wird ab dem zweiten Mal ueberlesen -- und dann auch
die echte Meldung.

Nebenbefund mit Gegenprobe belegt: `aufgaben.html` ist 1560 px breit
statt 1240 (verursacht von BUTTON.schnitt) -- und war das schon VOR
meiner Aenderung. Die fuenfte bestehende rote Pruefung an diesem Tag.

Beides steht in der Vault-Notiz zum Entscheiden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 12:42:37 +02:00
DogFatherGitandClaude Opus 5 d231e989cf Screenshots an einen Beitrag hängen
Filipe (Runde vom 23.09.2026): „mach das man da bitte screenshots
oder kurzschnitte von den live reinposten kann. nach dem selben
prinzip wie bei den anderen nebendran."

ES FEHLTE WENIGER, ALS MEINE EIGENE NOTIZ BEHAUPTETE. Dort stand „ein
eigener Brocken (Upload, Groessenpruefung, Sicherheit), kein
Nebenbei". Nachgemessen statt geglaubt: Die Spalte `dateien.eintrag_id`
gibt es seit dem Video-Einlesen, die Karten zeichnen ihren
Bildstreifen bereits, und die Auslieferung entscheidet die
Sichtbarkeit schon am BEITRAG statt an der Ablage. Gefehlt hat genau
ein Weg -- das Hochladen. Wieder ein Beleg dafuer, dass auch meine
eigenen Listen altern.

„NACH DEM SELBEN PRINZIP" IST WOERTLICH GENOMMEN: `dateiErkennen` aus
dem Chat (eine Fassung, drei Benutzer -- Chat, Support, Beitraege),
derselbe Ordner wie die Dateiablage (die Auslieferung kennt nur einen
Pfad), `express.raw` mit Rechtepruefung VOR der Annahme des Rumpfes.

DER KNOPF STEHT AN DER KARTE, nicht im Anlege-Formular. Ein
Bildschirmfoto faellt einem meist spaeter ein -- beim Nachschauen,
wenn jemand fragt. Wer es nur beim Anlegen mitgeben koennte, muesste
den Beitrag loeschen und neu schreiben. Er erscheint nur, solange
noch Platz ist (drei je Beitrag), damit er nie eine Absage bringt.

ZWEI FEHLER IN MEINEM EIGENEN CODE, beide beim ersten Laden gefunden:
`DATEN_ORDNER` war nicht importiert, und `bereichVon()` hatte ich
erfunden -- es gibt sie nicht. Der Bereich steht am Eintrag selbst
und ist dort auch richtiger: Er kommt aus der Datenbank, nicht aus
der Adresse.

UND ZWEI MESSFEHLER, beide dieselbe Sorte wie den ganzen Tag: Ich
fragte „darf die Community?" an einem Beitrag, den sie gar nicht
sieht (404 -- richtige Antwort, falsche Frage), dann an einem
freigegebenen (403 -- sie braucht eine Stufe zum Schreiben, auch das
richtig). Die Frage, die wirklich zaehlt, ist eine andere: Gilt fuer
ein Bild dieselbe Regel wie fuer einen Beitrag? Gemessen: Beitrag
403, Bild 403. Ein zweiter Weg mit anderen Rechten waere die Tuer,
die niemand bemerkt.

Gemessen: pruef-eintrag-bild, 24 Pruefungen, 0 Fehler -- darunter
als Bild getarntes HTML (415), SVG (415, es ist XML und darf Skripte
enthalten), PDF (415), die Grenze von drei am Server, und das
Abnehmen samt Datei. Gruen: pruef-highlights (31), pruef-anhaenge,
pruef-fassungen, pruef-galerie, pruef-video, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 11:44:01 +02:00
DogFatherGitandClaude Opus 5 7acd7a7674 Das Suchfeld nimmt wieder Eingaben, eigene Kanalnamen, Community in Kanälen
screen1, drei Teile.

=== 1. „bei suchen kann man nichts reinschreiben" ===

MEIN FEHLER VOM SELBEN TAG. Beim Popover-Umbau heute frueh hing die
Liste am <body>, damit sie nicht hinter dem modalen Dialog
verschwindet. Gemessen: Das Suchfeld war da, der Fokus landete nicht
darin, ein getipptes Zeichen kam nicht an.

DER GRUND IST DER FOKUS-KAEFIG. Ein Dialog aus showModal() sperrt den
Fokus auf seinen eigenen DOM-Baum ein. Die Liste lag im Top Layer --
sichtbar und anklickbar, aber ausserhalb des Kaefigs. Klicken braucht
keinen Fokus, Tippen schon. Deshalb fiel es niemandem auf: Die Liste
stand da, sie filterte auf Knopfdruck, nur eine Eingabe kam nicht an.

DIE LOESUNG WAR EINE KOMBINATION, die ich vorher fuer unmoeglich
hielt. Im Dialog wurde die Liste vom clip-path der abgeschraegten
Ecke abgeschnitten, draussen war sie nicht bedienbar. Ein POPOVER
wird aber in die Top Layer gehoben und dort gezeichnet -- der
Beschnitt des Elternteils erreicht es nicht mehr, waehrend der
DOM-Baum (und damit der Fokus) der des Dialogs bleibt. Jetzt haengt
sie wieder im Dialog UND ist ein Popover: bedienbar und unbeschnitten.

Gemessen beides gegeneinander: „comm" getippt -> kommt an, filtert auf
1 Treffer; und die LETZTE Zeile der vollen Liste ist wirklich
anklickbar (elementFromPoint trifft sie selbst, nicht den Dialog).
Daraus ist pruef-suchfeld geworden -- der Fehler war von aussen nicht
zu sehen, und gefunden hat ihn Filipe, nicht ich.

=== 2. „einen neuen namen erstellen den es noch nicht gibt" ===

`data-frei="ja"` war in wahl.js seit langem gebaut und wurde NIE
GESETZT -- dieselbe Sorte Lueue wie `grund_min` heute Morgen. Jetzt
gesetzt; der getippte Text erscheint als eigener Eintrag ganz oben.

Der Server nahm bisher nur die neun festen Zustaendigkeiten. Jetzt
auch einen eigenen Namen: Der SCHLUESSEL wird daraus abgeleitet
(„Technik & Ton" -> "eigen-technik-ton"), der ANGEZEIGTE Name bleibt
wie geschrieben. Das Praefix ist kein Schmuck -- ohne es entstuende
aus dem Namen „Community" derselbe Schluessel wie beim festen Thema,
und der eindeutige Index lehnte ihn ab, obwohl der Kanal nicht
existiert. Gemessen: genau dieser Fall antwortet jetzt mit 201.

Der alte Kommentar sprach sich gegen freie Namen aus („der sichere
Weg zu Clipping neben Clipping-Team"). Das Risiko bleibt und wird
begrenzt: Derselbe Name zweimal ergibt denselben Schluessel und damit
409.

=== 3. „kanäle mit den leuten mit der community rolle" ===

Seit dem 19.09. nimmt `ohneAussen()` die Community aus den Listen
aller anderen. Die Begruendung dort ist ausdruecklich: „Ein
Zweier-Gespraech ist kein Kanal. Es hat keine Nachtruhe, kein Modi
liest mit, und niemand koennte moderieren."

Genau diese Begruendung laesst den Kanal offen. Die Sperre bleibt
fuer GESPRAECHE und faellt fuer KANAELE -- `?fuer=kanal` an der
Partnerliste, und nur fuer den, der Kanaele ueberhaupt aufmachen
darf. Sonst waere der Parameter ein Weg, an `ohneAussen` vorbei Namen
zu erfahren.

Gemessen: DogFather sieht im Gespraech VanVan und Miss, im Kanal
zusaetzlich Kessi und Tom (beide Community). Ein Modi bekommt die
erweiterte Liste auch mit dem Parameter nicht.

Gruen: pruef-suchfeld (8, neu), pruef-chat-kanaele (81),
pruef-treffchat, pruef-chat-neu, pruef-freie-namen (32),
pruef-nachfrage (53), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 11:37:03 +02:00
DogFatherGitandClaude Opus 5 8c599d7961 „An alle" bei „An wen" – und kein Creator mehr auf der Team-Seite
=== screen6: „An alle" ===

Filipe: „bei an ween fehlt noch die option alle neben den namen
allen."

Der Knopf steht VORN, nicht hinten: Wer etwas an das ganze Team geben
will, soll nicht erst an sechs Namen vorbeilesen. Er traegt die
Anzahl der Leute und faerbt sich wie die Stufe „Alle" -- beide
bedeuten „keine Einschraenkung", und zwei Farben dafuer waeren zwei
Aussagen fuer einen Gedanken. Ab zwei Personen; bei einer waere
„alle" derselbe Handgriff wie ihr Name.

EINE ANFRAGE, NICHT SECHS. Der Browser koennte fuer jede Person
einzeln fragen -- und beim dritten von sechs Aufrufen die Verbindung
verlieren. Dann haette die Haelfte des Teams die Aufgabe und die
andere nicht, und niemand saehe, wo es abgebrochen ist.

DER DUPLIKAT-SCHUTZ WANDERTE IN DIE SCHLEIFE. Er fragt, was EINE
Person schon liegen hat; stuende er davor, bekaeme nur die erste ihre
Pruefung und alle anderen die Vorlage doppelt -- genau so entstanden
am 22.09. aus zwei Klicks 112 Aufgaben. Gemessen: erster Klick 36
Aufgaben (12 mal 3 Modis), zweiter Klick 0 angelegt und 36
uebersprungen.

Ein gesperrter Modi bekommt nichts, ein Creator auch nicht, und wer
gar nicht verteilen darf, kommt ueber „alle" ebenfalls nicht weiter.

=== screen4: kein anderer Creator ===

Filipe: „in dieser app gibt es keinen und wird es niemals einen
anderen creator geben wie mich."

Auf der Aufgabenseite war eine Filterreihe mit „Alle Creator"
beschriftet -- und darunter standen Modis. Auf der Team-Adresse gibt
es keine Creator und wird es nie geben. Sie heisst dort jetzt „Für
wen".

DAS HAUS KOMMT VOM SERVER. `/api/ich` schickt jetzt `haus`; es stand
schon an der Sitzung und wurde nie mitgeschickt. Eine Rollenliste im
Browser waere die naechste zweite Wahrheit -- und falsch fuer
DogFather, der in beiden Haeusern arbeitet.

NEUN WEITERE „BEFUNDE" WAREN MEINE MESSFEHLER. Meine erste Messung
lief ueber 127.0.0.1 und meldete unter anderem „CREATOR WORKSPACE" in
der Kopfleiste JEDER Seite. Auf der echten Adresse steht dort „Team
Dogi" -- `person.haus` wird aus dem Host abgeleitet, und auf
127.0.0.1 ist das Haus nun einmal die Agentur. Nachgemessen mit
gesetztem Host: crew. -> haus="crew", marke="Team Dogi". Die
Uebersichtsseite mit ihren Creator-Texten steht auf crew. gar nicht
erst in den Kacheln.

Gemessen: pruef-an-alle, 19 Pruefungen, 0 Fehler. Gruen:
pruef-aufgabenbrett, pruef-aufgaben-vorlagen, pruef-vorlagen,
pruef-entwicklung (48), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 11:27:10 +02:00
DogFatherGitandClaude Opus 5 710b766ffa Die rechte Hand: dieselben Rechte, außer an DogFather
Filipe: „die rechte hand soll das auch sehen. und die selben rechte
da haben wie dogfather. das einzige was sie nicht kann ist die
dogfather rolle oder leute anfassen. also da kann sie nichts
verändern."

ZUERST EIN IRRTUM VON MIR. Ich hatte den Bildschirmfoto-Ausschnitt
fuer die Rechtetafel gehalten und dort gebaut. „Vertritt dich im
Alltag und koordiniert das Team" steht aber in workspace-personen.js:
Gemeint war die PERSONENSEITE. Die Arbeit an der Rechtetafel ist
trotzdem drin (siehe unten) -- sie loeste dasselbe Problem an einer
zweiten Stelle.

=== DIE PERSONENSEITE ===

DIE OBERFLAECHE WAR STRENGER ALS DER SERVER. In personen.js stand
`if (ich.rolle === 'hand') { keine Knoepfe }` mit der Begruendung
„Der Server antwortet ihr auf jeden davon mit 404". Am 22.09. stimmte
das. Seither wurde der Server ZWEIMAL erweitert -- sie durfte
Personen anlegen, Codes neu erzeugen und Rollen aendern -- und diese
Zeile blieb stehen. Sie hatte drei Rechte und sah keinen einzigen
Knopf. Ein Rollenvergleich im Browser ist genau die zweite Wahrheit,
die still veraltet.

Jetzt fragt die Oberflaeche den Server (`darf_zugaenge_verwalten`,
`darf_personen_loeschen`, `rollen_anfassbar`). Dazu kommen SPERREN
und LOESCHEN, die bis heute ausdruecklich bei DogFather lagen --
Filipes „das einzige" ist juenger und eindeutig.

Die Knoepfe „Neuer Code" und „Sperren" standen inline im
DogFather-Zweig; sie sind jetzt Funktionen und werden von beiden
Stellen benutzt. Eine zweite Abschrift waere die geworden, die beim
naechsten Umbau nur halb nachgezogen wird.

ZWEI ECHTE LOECHER FAND DIE NEUE PRUEFUNG:
  * `PUT /personen/:id/rolle` hatte `nurDogFatherBeiLeitung` NICHT.
    Bis heute folgenlos; mit den neuen Rechten konnte die rechte Hand
    darueber die Rolle eines MANAGERS aendern -- waehrend derselbe
    Manager fuer DogFather auf crew. gar nicht in der Liste steht.
    Zwei Wege, zwei Antworten, und der laxere galt fuer die Rolle mit
    weniger Rechten.
  * Die Loesch-Route hatte dieselbe Middleware ebenfalls nicht. Das
    war harmlos, solange die Route selbst nur DogFather durchliess --
    seit die rechte Hand loescht, ist es die Stelle, an der DogFather
    geschuetzt wird.

=== DIE RECHTETAFEL (nicht bestellt, aber dasselbe Problem) ===

Dort durfte sie sehen, aber nichts umstellen. Jetzt umstellen wie
DogFather -- ausser der Spalte „DogFather" und der Zeile „Personen &
Zugaenge" (wer die freischaltet, hat Zugaenge vergeben, ohne einen
anzulegen). Zuruecksetzen bleibt bei DogFather: Der Knopf naehme
genau diese zwei Sperren mit, und eine Sperre, die ein zweiter Knopf
daneben aufhebt, ist keine.

Sie kann sich auch selbst nicht aussperren -- das hat er nicht
gesagt, aber eine Sperre, aus der man sich aussperren kann, ist eine
Falle. Ein festes Feld ist jetzt gar kein Knopf mehr und nennt den
Grund, der fuer DIESE Person gilt.

=== DREI MESSFEHLER VON MIR ===
Ein Manager ist auf crew. fuer die LISTE unsichtbar, fuer DogFathers
direkten Zugriff aber nicht -- ich hielt das eine fuer das andere und
erwartete, dass beide abgewiesen werden. Die Loesch-Vorschau (GET)
fehlte in meinem Waechter. Und `rollen_anfassbar` beantwortet eine
andere Frage als „wen sehe ich".

Gemessen: pruef-hand-personen, 40 Pruefungen, 0 Fehler -- jeder der
fuenf Wege einzeln gegen DogFather und die linke Hand, plus die
Gegenprobe, dass DogFather es kann. pruef-rechte-umstellen von 46 auf
56 Pruefungen. Gruen: pruef-personen-liste, pruef-personen-loeschen,
pruef-personen-kachel, pruef-rollen-anlegen, pruef-rechtetafel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 11:16:26 +02:00
DogFatherGitandClaude Opus 5 9f5dbb42e3 Support: melden mit Bild, und für die Leitung ein Eingang
Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
benutzen kann. jeder kann da probleme von der seite melden und
hinweisen. fotos mit schicken mit einem text. … so einfach wie
möglich, will nichts kompliziertes oder zu viel. kurz und knapp …
bei dogfather und der rechten hand soll die kachel anders gebaut sein
weil die sind die die sich um die probleme kümmern."

WARUM DAS NICHT DER VORHANDENE MELDEWEG IST. Es gibt ihn schon
(hilfe.html) und er sieht aehnlich aus. Drei Unterschiede machen ihn
zu etwas anderem:
  1. Er ist NICHT fuer alle -- in rechte.js steht
     `["gast","hand","admin"]`. Modis, Creator, Scouts, Manager und
     die linke Hand hatten gar keinen Weg, einen kaputten Knopf zu
     melden.
  2. Er kann keine Bilder. Bei „der Knopf tut nichts" ist ein
     Bildschirmfoto die halbe Antwort.
  3. Er ist bewusst schwer: strikter Wechsel, hoechstens drei offene
     Faelle, ein Betreff. Richtig bei einem Vorfall zwischen
     Menschen, zu viel fuer „das Datum steht falsch da".

Deshalb eigen und sehr kurz: EIN Feld, EIN Bild, fertig. Kein
Betreff, keine Kategorie, keine Dringlichkeitsstufe -- wer ein
Formular mit fuenf Feldern sieht, meldet den kleinen Fehler nicht,
und genau die kleinen erfaehrt sonst niemand.

DIE LEITUNG SIEHT ETWAS ANDERES: einen Eingang mit Zahlenband
(neu / in Arbeit / erledigt), drei Filtern und zwei Handgriffen --
„Ich kuemmere mich" und „Erledigt …". Zwei, nicht fuenf; eine
Zuweisung an eine Person und Prioritaeten waeren ein Ticketsystem
fuer zwei Menschen, die nebeneinander sitzen. Das Melde-Feld bleibt
auch fuer sie da, rutscht aber unter den Eingang.

Wer zumacht, schreibt einen Satz dazu -- der Melder sieht nur diesen
Satz, und ohne ihn weiss er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Beide Seiten bekommen eine Benachrichtigung.

UEBERNOMMEN STATT NEU ERFUNDEN:
  * `dateiErkennen` aus workspace-chat.js -- EXPORTIERT, nicht
    abgeschrieben. An ihr haengt die ganze Sicherheit der Uploads
    (der Typ kommt aus den ersten Bytes, nicht aus Name oder
    Content-Type), und eine zweite Fassung waere die, die beim
    naechsten Dateiformat vergessen wird.
  * Die zweigeteilte Sicht und `meldungFuer()` (gibt den Datensatz
    oder null zurueck, nie true/false) aus dem vertraulichen
    Meldeweg.
  * Der Upload-Weg (express.raw, Text im Kopf, Ordner unter
    DATEN_ORDNER, damit die Sicherungspruefung ihn findet).

DIE KACHEL BEKOMMT JEDER -- an EINER Stelle angehaengt: `bereicheFuer`
haengt sie an jede Rollenliste, statt sie in fuenf Listen
einzutragen. Genau so ist heute der Benachrichtigungs-Knopf auf 14
Seiten verschwunden. Dazu der Eintrag in rechte.js (ohne ihn
verschwindet die Kachel lautlos, `nurOffeneKacheln` filtert dagegen)
und in der Browser-Kachelliste fuer die Agentur-Rollen.

TON 44 GERECHNET, NICHT GEWAEHLT -- und dabei zweimal danebengelegen:
  * Ich habe erst einen eigenen Modus in kachel-farben.mjs gebaut.
    `tools/kachel-farbe-einzeln.mjs` gibt es aber seit dem 22.09. fuer
    genau diesen Zweck. Meine Dopplung ist wieder weg.
  * Von Hand hatte ich #bcdbff eingetragen. pruef-kachelfarben lehnte
    es ab (Buntheit 0,060, verlangt sind 0,12) -- und das vorhandene
    Werkzeug verteidigte es trotzdem, weil sein Abstand gut war. Ein
    Werkzeug, das einen regelwidrigen Zustand haelt, weil er zufaellig
    gut misst, behebt genau den Fehler nicht, fuer den es da ist.
    Behoben: Zuerst die Regeln, dann der Abstand. Ergebnis #fd6401,
    Abstand 0,0914 (engstes vorhandenes Paar: 0,0154).

DREI MESSFEHLER VON MIR, die wie schwere Befunde aussahen: `fetch`
verwirft den Host-Kopf (die Community kommt nur auf crew. herein),
die Community bestaetigt ihr Alter (`alter_ok`), und die Startroute
heisst /api/ich. Alle drei stehen in pruef-treff.mjs richtig.

Ein ECHTER Fehler kam dazu: support.html lud bereiche.js nicht, und
kopf.js stuerzte mit „Cannot read properties of undefined (reading
'LEITUNG')" ab -- sichtbar nur in der Browserkonsole. Und
pruef-css-klassen fand sofort, dass auch wahl.js fehlte.

Gemessen: pruef-support 45 Pruefungen, 0 Fehler (alle neun Rollen
kommen herein, jede sieht die Kachel, als Bild getarntes HTML wird
abgelehnt, eine fremde Meldung bleibt fremd, erledigt bleibt
erledigt). Dazu 24 Messungen am Bild in drei Groessen. Gruen:
pruef-kachelfarben (22), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 01:32:55 +02:00
DogFatherGitandClaude Opus 5 4d36f1cbf0 Der Benachrichtigungs-Knopf steht jetzt wirklich überall
Filipe: "dieser button soll bei jeder rolle perfekt funktionieren
bitte."

GEMESSEN, NICHT GERATEN. Serverseitig war nichts rollenabhaengig:
alle drei Routen (Schluessel, Stand, Probe) antworten jeder der acht
Rollen mit 200, und `willHaben` haengt an der Person, nicht an der
Rolle. Der Mangel lag woanders -- und genau dort, wo die Rolle
entscheidet: BEI DEN SEITEN.

14 Seiten haben eine Kopfleiste und luden glocke.js nicht. Welche
Seiten jemand benutzt, haengt an seiner Rolle:

  Creator -> befinden.html, werdegang.html, teilen.html
  Scout   -> talente.html, bewerbungen.html
  Modi    -> treff-moderation.html, treff-regeln.html
  Manager -> entwicklung.html, teamlage.html

Keine dieser Seiten hatte den Knopf. Wer dort war, konnte
Benachrichtigungen nicht einschalten -- und hat nicht einmal gesehen,
dass es sie gibt. Das CSS war ueberall schon da (start.css), es
fehlte allein die eine Skriptzeile. Jetzt steht er auf allen 34
Seiten mit Kopfleiste.

AUSGENOMMEN, UND ZWAR NAMENTLICH: anruf-probe.html. Die Seite hat
kein kopf.js, ist eine eigenstaendige Diagnoseseite, und
anruf-probe.js verweist ausdruecklich auf "im Chat oben auf die
Glocke tippen". Die Ausnahme steht in der Pruefung als Name, nicht
als Schweigen.

WARUM DIE PRUEFUNG DAS NICHT GEFUNDEN HAT -- zwei abgeschriebene
Listen, beide unter einer Ueberschrift, die mehr versprach:

  "Der Knopf ist UEBERALL"   sah 8 von 37 Seiten an.
  "JEDE Rolle bekommt ihn"   sah 4 von 8 Rollen an -- es fehlten
                             rechte Hand, linke Hand, Modi und
                             Spicy Media. Ausgerechnet die Modis
                             sind die groesste Gruppe im Haus.

Beide waren gruen. Sie haben nicht falsch gemessen, sie haben das
Falsche gemessen. Jetzt kommt die Seitenliste aus dem Verzeichnis
(jede Seite mit Kopfleiste) und die Rollenliste umfasst alle acht --
eine Liste, die niemand pflegt, kann nicht veralten. Und die Zahl
steht in der Bedingung, damit "auf allen 0 geprueften" nicht gruen
sein kann.

Dabei fielen zwei Dinge an der Pruefung selbst an: Ihr `anlegen`
setzte keine `code_kennung`, und ihre Anmeldung klickte stur auf
`.rolle[data-rolle="..."]`. Beides brach bei rechter Hand, linker
Hand und Modi ab -- den drei Rollen mit dem STILLEN ZUGANG, die
absichtlich keine eigene Kachel haben (Filipe, 09.09.2026: "damit die
von der workspace auch nicht mal sehen dass die modis von mir einen
eigenen zugang haben"). Sie tippen auf irgendeine Kachel, der Code
entscheidet. Derselbe Stolperstein hat vorher auch meine eigene
Messung dreimal "kommt nicht hinein" melden lassen -- ein Messfehler,
der wie ein schwerer Befund aussah.

31 -> 36 gepruefte Punkte, keiner weggefallen. Gruen: pruef-glocke,
pruef-push, pruef-push-ziel, pruef-css-klassen. Dazu eine eigene
Messung ueber alle acht Rollen: Knopf da, Routen 200/200/200,
8 Messungen, 0 Befunde.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 01:06:04 +02:00
DogFatherGitandClaude Opus 5 d85694bd92 Keine fertigen Vorschläge mehr bei den Highlights
Filipe: "nimm die sachen da weg bitte. da sollen nur die sachen rein
kommen die die modis, linke und rechte hand oder dogfather
reinsetzen."

In der Spalte "Noch einzusortieren" standen drei vorgefertigte
Vorschlaege ("Was hier hingehoert", "Fanart von DogFather und Casper
ist ausdruecklich erwuenscht", "Ein Ausschnitt, der auf die Clips
soll"). Jemand hatte sie uebernommen, und danach standen sie zwischen
den echten Clips -- mit Datum, mit Verfasser, aeusserlich nicht von
einem echten Eintrag zu unterscheiden.

WARUM DIESES BRETT ANDERS IST ALS DIE UEBRIGEN: Anderswo ist ein
Vorschlag eine ANREGUNG, die man ausfuellt ("Der naechste Stream mit
DogFather" -> Datum eintragen). Highlights ist eine SAMMLUNG echter
Sachen. Ein vorgefertigter Text ist dort kein Anfang, sondern ein
Platzhalter, der aussieht wie Inhalt. Die anderen Bretter behalten
ihre Vorschlaege -- dazu hat er nichts gesagt.

Nebenbefund: Seine Rollenaufzaehlung ist genau TREFF_TEAM_ROLLEN
(modi, hand, linke, admin). Die Rechteregel stimmte also schon.

ZWEI ECHTE MAENGEL FIELEN DABEI AUF, beide beim Versuch, die drei
Eintraege wegzunehmen:

1. Der Knopf "loeschen" schickte DELETE OHNE GRUND. Der Server
   verlangt bei einem fremden Beitrag auf einem Treff-Brett einen
   (DSA Art. 17) und antwortet mit 400 -- die Oberflaeche zeigte nur
   "Loeschen hat nicht geklappt." Zwei Knoepfe nebeneinander, einer
   ging ("entfernen"), einer nicht, und die Meldung erklaerte nichts.
   Jetzt fragt auch "loeschen" nach dem Grund, wenn der Server einen
   braucht -- erkannt an `treffBrett` vom Server, nicht an einer
   abgeschriebenen Brettliste -- und die Fehlermeldung gibt wieder,
   was der Server gesagt hat.

2. Der Dialog liess DREI Zeichen als Grund durch, der Server verlangt
   ZEHN. Wer "spam" tippte, kam durch die Nachfrage und bekam danach
   eine Absage. `grund_min` steht seit dem 11.09. in /api/treff/lage
   und wurde nie benutzt; jetzt ist es angeschlossen. In nachfrage.js
   bestimmt der Aufrufer die Mindestlaenge (`grundMin`), Vorgabe
   bleibt 3 -- fuer alle anderen Nachfragen aendert sich nichts.

UND ZWEI PRUEFUNGEN, DIE ROT WAREN, OHNE DASS ETWAS KAPUTT WAR:

- pruef-treff-start verlangte `>= 8` Vorschlaege auf dem Schirm. Das
  stimmte bis zum Fenster-Umbau vom 20.09. -- seither kommen
  hoechstens VIER (FENSTER = 4). Vier Tage rot, ohne dass es jemand
  erfuhr. Gefragt wird jetzt der Server selbst. Und die Brettliste
  ["treff","anschlag","wunsch","highlight"] wird gegen den Bestand
  abgeglichen statt abgeschrieben -- mit ausdruecklichem Nachweis,
  dass Highlights keine mehr hat, damit das Wegfallen nicht einfach
  eine Pruefung weniger bedeutet. 41 -> 42 Pruefungen.

- pruef-nachfrage zaehlte `installieren.js` als "diese Seite fragt
  nach", obwohl der Aufruf dort hinter `if (typeof window.frageNach
  === 'function')` steht. Weil die Datei auf fast jeder Seite liegt,
  wurde damit JEDE Seite zur fragenden -- fuenf rote Zeilen, kein
  einziger echter Mangel. Ausserdem wurde die Kurzschreibweise
  `{ titel, … }` als "ohne Titel" gemeldet. 46 -> 53 Pruefungen, alle
  gruen; zwei davon sind neu (Gegenprobe plus Benennung der
  Ausnahmen).

Beide mit Gegenprobe (git stash) belegt: schon vor dieser Aenderung
rot.

Gemessen: 11 Messungen, 0 Befunde -- darunter die Gegenprobe, dass
der alte Weg (DELETE ohne Grund) wirklich mit 400 gescheitert waere.
Gruen: pruef-treff-start (42), pruef-nachfrage (53), pruef-highlights
(31), pruef-anschlagbrett (12), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:50:42 +02:00
DogFatherGitandClaude Opus 5 428235508f Noch einmal auf dieselbe Person: die Liste geht wieder zu
Filipe: "ich will das wenn ich wieder auf die person drücke die liste
unten dan wieder zu geht."

Dasselbe Muster, das die Stufenleiste auf derselben Seite schon hat
("Ein zweiter Klick auf dieselbe Stufe hebt den Filter auf") -- nicht
ein zweites, neu erfundenes. Der Weg zurueck ist derselbe Knopf wie
der Weg hin; ein eigener Schliessen-Knopf daneben waere ein zweites
Ziel fuer dieselbe Absicht.

WIEDERHERGESTELLT WIRD DER AUSGANGSZUSTAND, nicht "alles versteckt" --
und das ist ein Unterschied, der beinahe zu einem Fehler geworden
waere. Das Verteil-Band und das Vorlagenbrett stehen naemlich AUCH
OHNE AUSWAHL da; der Start ruft "verteilBandZeigen(null, null)" selbst
auf. Sie mit auszublenden haette ausgesehen wie Zumachen und waere
Wegnehmen gewesen. Sie fallen deshalb nur auf "niemand gewaehlt"
zurueck. Wirklich weg gehen die zwei Kaesten, die es ohne Person gar
nicht gibt: ihre Aufgaben und ihre Karte.

Gemessen wird genau das: Der Zustand VOR der Auswahl wird aufgenommen
und hinterher Feld fuer Feld verglichen (Band, Brett, Aufgaben, Karte,
eigene Karte, Meins). Dazu: ein dritter Druck macht wieder auf (kein
Einwegschalter), und eine ANDERE Person wechselt, statt zuzumachen.

14 Messungen, 0 Befunde. Gruen: pruef-entwicklung (48),
pruef-entwicklung-kacheln.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:36:21 +02:00
DogFatherGitandClaude Opus 5 362d883392 Personen als Kacheln statt Zeilen
Filipe: "gestalte diese seite auch anders bitte. viel krasser geiler
uebersichtlicher." Auf das Angebot, die Rollen als Kacheln statt
Zeilen zu bauen: "will ich." Entwurf gezeigt, Antwort: "so live".

Eine Person war eine Zeile ueber die volle Breite -- links der Name,
darunter fuenf halbe Saetze, rechts die Knoepfe. Gemessen: 1128 x 74
px, eine pro Reihe, mit achthundert Pixeln Luft in der Mitte. Jetzt
368 x 221 px, drei nebeneinander: sechs Creator brauchen zwei Reihen
statt sechs.

Die Angaben stehen in einem Zahlenband statt als Satzreihe -- aus
"offene Aufgaben: 0 · 2 offene Sitzung(en) · betreut 3 Creator"
werden Felder mit grosser Zahl und kleinem Wort, in jeder Kachel an
derselben Stelle. Man vergleicht zwei Personen mit dem Auge, statt in
jeder Zeile an einer anderen Stelle nach derselben Zahl zu suchen.

Dazu: "vor 21 Tagen" statt "2026-09-15 00:26" (das genaue Datum bleibt
als Titel dran), ein Namenszeichen in der Rollenfarbe mit schmalem
Farbstreifen oben, und "gesperrt" als Marke in der Warnfarbe statt als
graues Wort zwischen fuenf grauen Woertern.

ES FAELLT NICHTS WEG. Name, Rolle, gesperrt, Anmeldung, Aufgaben,
Sitzungen, betreute Creator, zugeteilte Scouts, "gehoert zu", die
Zustaendigkeitsauswahl und alle Knoepfe -- die Messung prueft das
ausdruecklich mit, huebsch und unvollstaendig waere schlechter als
vorher.

Keine feste Spaltenzahl: "auto-fill" laesst den Browser rechnen, bei
1160 px sind es drei, bei 390 px eine. Eine Zahl waere die sechste
Wiederholung desselben Fehlers in diesem Haus.

Nebenbei zwei Altlasten: .marke-rolle und .betreuung__schild standen
auf 11,2 px, unter der Hausgrenze von 11,5. Mit Gegenprobe belegt,
dass das schon vorher so war -- jetzt .75rem.

Gemessen: 14 Messungen, 0 Befunde (1765 px und 390 px). Gruen:
pruef-personen-kachel (45), pruef-personen-liste, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:33:09 +02:00
DogFatherGitandClaude Opus 5 14c8964b24 Die Unterkategorien nach Alter erscheinen jetzt wirklich
Filipe: "wieso sehe ich nichts von diesen unterkategorien?"

Weil ich gestern eine Schwelle eingebaut hatte, die er nie verlangt
hat: Gegliedert wurde erst ab 13 Eintraegen. Seine Spalten haben 3,
1, 1 und 4 -- er hat die Gliederung also nie zu sehen bekommen.

Die Begruendung von gestern stand als Kommentar daneben und klang
vernuenftig: "Bei wenigen Eintraegen waere eine Gliederung Aufwand
ohne Nutzen: sieben Ueberschriften fuer fuenf Karten." Sie war in
beiden Haelften falsch. Erstens war die Ansage klar -- eine eigene
Bedingung daranzuhaengen ist keine Sorgfalt, sondern eine
Entscheidung, die mir nicht zusteht. Zweitens gab es die sieben
Ueberschriften nie: Leere Stufen werden ohnehin uebersprungen, drei
Karten ergeben hoechstens drei Ueberschriften. Genau das misst die
Messung jetzt mit.

Nachgestellt wurde sein Bildschirmfoto: vier Spalten mit 3, 1, 1, 4.
DogFather zeigt "Heute 1 (offen) | Gestern 1 (zu) | Diese Woche 1
(zu)", die Sammelspalte vier Stufen bis "Über sechs Monate". Keine
Stufe steht leer da (9 geprueft), die neueste ist offen, ein Druck
auf die Ueberschrift klappt zu.

Gemessen: 10 Messungen, 0 Befunde. Gruen: pruef-highlights (31),
pruef-anschlagbrett (12), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:31:58 +02:00
DogFatherGitandClaude Opus 5 08ff3e5e40 Die Liste im Dialog rollt, das Anschlagbrett schreibt nicht mehr ab
Zwei Dinge aus Filipes Durchgang.

ERSTENS: "das hoch und runter scollen geht nicht in der liste da."
Die Auswahlliste im Kanal-Dialog liegt seit dem Popover-Umbau in der
obersten Ebene. Das loest das Abschneiden -- aber der Zeiger steht
danach ueber dem Dialog, nicht ueber der Liste, und das Mausrad rollt
den Dialog. Der Horcher haengt jetzt am Dokument (capture) und fragt
selbst, ob der Zeiger im Rechteck der Liste steht. Gemessen:
5 Messungen, 0 Befunde.

ZWEITENS: "gestalte diese seite auch anders ... viel uebersichtlicher."
Am Anschlagbrett stand die Herkunft als vollstaendige Abschrift des
Titels darueber -- derselbe Satz zweimal, direkt untereinander. Jetzt
nennt sie nur noch das Brett, wenn der Titel uebernommen wurde, und
kuerzt sonst auf 60 Zeichen an der Wortgrenze. Dazu Karten mit
620 px Hoechstbreite, zwei nebeneinander statt einer Zeile ueber die
ganze Breite -- lesbar bleibt, was eine begrenzte Zeilenlaenge hat.
Gemessen: 11 Messungen, 0 Befunde; Titel von 352 px statt voller
Breite, zwei Spalten a 571 px.

Geprueft: pruef-anschlagbrett (12, 0), pruef-css-klassen,
pruef-highlights, pruef-chat-kanaele (81, 0), pruef-freie-namen (32, 0).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:18:04 +02:00
DogFatherGitandClaude Opus 5 7f732cc403 Gegliedert nach Alter: sieben Stufen, klappbar
Filipe: "ich will da unterkategorien die man auf und zu klappen kann,
so wie heute gestern, diese woche, diesen monate, über ein monat,
über 6 monate, über ein jahr."

DAS IST DIE BESSERE ANTWORT AUF DIE LANGEN LISTEN als die Zahl von
gestern. Zwoelf ist willkuerlich; eine Gliederung nach Alter
beantwortet die Frage, die man an so eine Liste wirklich stellt --
was ist neu?

DIE ERSTEN VIER STUFEN SIND KALENDERBEZOGEN, die letzten drei
altersbezogen:

  Heute, Gestern          der Kalendertag
  Diese Woche             seit MONTAG -- am Dienstag ist der Freitag
                          davor nicht "diese Woche"
  Dieser Monat            seit dem Ersten
  Ueber einen Monat       der Abstand, weil "Mai" niemandem sagt,
  Ueber sechs Monate      wie lange das her ist
  Ueber ein Jahr

Das klingt uneinheitlich und ist genau richtig.

DIE NEUESTE GRUPPE STEHT OFFEN, die aelteren zu. Wer die Seite
aufmacht, will sehen was neu ist -- und alles Aeltere ist sichtbar
VORHANDEN, ohne den Weg zu verstellen. Die eigene Wahl gewinnt und
wird gemerkt, je Gruppe und je Spalte.

EINE LEERE STUFE ERSCHEINT NICHT. Sieben leere Ueberschriften waeren
schlimmer als eine lange Liste. Und gegliedert wird erst UEBER zwoelf
Eintraegen -- darunter waeren es sieben Ueberschriften fuer fuenf
Karten.

Die Zwoelfergrenze von gestern bleibt, gilt aber jetzt JE GRUPPE: Auch
"Ueber ein Jahr" kann dreihundert Eintraege haben, und dann hilft die
Gliederung allein nicht.

GERECHNET WIRD IN ORTSZEIT (window.heuteLokal), nicht in UTC. Ein
Eintrag von gestern 23:40 waere in UTC schon heute und stuende unter
"Heute", waehrend das Datum daneben gestern sagt.

ZWEI DINGE NACHGESEHEN STATT GERATEN:

  abschnittKlappbar nimmt als vierten Wert ein BOOLEAN (zuVorgabe),
  kein Objekt. Ich hatte "{ offen: ... }" angenommen; beim Nachsehen
  in kopf.js stand etwas anderes da.

  Jede Gruppe braucht einen eigenen Merker. Ohne ihn teilten sich
  "Heute bei DogFather" und "Heute bei HasiDog" denselben Zustand,
  und wer die eine zuklappt, klappt die andere mit.

Gemessen mit Eintraegen ueber alle sieben Stufen: 10 Messungen,
0 Befunde -- Reihenfolge, Zahlen, offen/zu, Klapp-Pfeil, kein
seitlicher Ueberstand, und ein Tipp macht eine zugeklappte Gruppe
wirklich auf. pruef-highlights 31, pruef-galerie und
pruef-css-klassen in Ordnung.

NOCH OFFEN -- screen1: "Screenshots oder Kurzschnitte reinposten"
braucht eine Route, die eine Datei an einen EINTRAG haengt. Die gibt
es heute nicht: workspace-video.js legt Coverbilder beim Einlesen
eines TikTok-Links an, workspace-dateien.js laedt hoch, aber ohne
eintrag_id. Das ist ein eigener Brocken und kein Nebenbei.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:06:59 +02:00
DogFatherGitandClaude Opus 5 2bea133feb Das Protokoll spricht deutsch -- und zwei Pruefungen messen wieder
Filipe: "ich will dass das alles viel anders und krasser und geiler
gestaltet ist bitte ... viel profissioneller und moderner."

--- DIE PERSONENSEITE ---

Auf seinem Bildschirmfoto stand woertlich, was in der Datenbank steht:
"treff_freigegeben", "video_eingelesen", "einstellung_geaendert". Das
sind Spaltenwerte, keine Saetze fuer Menschen. Daneben die rohe
IP-Adresse, quer ueber ein Viertel der Zeile.

KEINE TABELLE MIT 77 EINTRAEGEN. So viele Aktionen gibt es; eine
Liste davon waere am Tag der naechsten unvollstaendig, und niemand
merkte es -- dann stuende einfach wieder der Rohname da. Dieselbe
Falle wie jede abgeschriebene Liste in diesem Haus.

Stattdessen eine REGEL: Unterstriche werden Leerzeichen, der erste
Buchstabe gross. Das ergibt fuer jede Aktion einen lesbaren Ausdruck,
auch fuer die, die es noch nicht gibt. Nachgeprueft an allen echten
Namen:

  treff_freigegeben      -> Treff freigegeben
  einstellung_geaendert  -> Einstellung geändert
  chat_zurueckgenommen   -> Chat zurückgenommen
  vorlage_uebernommen    -> Vorlage übernommen

Die Umlaute sind der zweite Teil: In der Datenbank stehen sie als
ae/oe/ue. Blind zurueckzusetzen waere falsch ("neue" wuerde "neü"),
deshalb nur in Wortteilen, die sicher sind -- gemessen an den 77
echten Namen, nicht geraten.

Der Rohname bleibt als Titel an der Zeile: Wer im Server danach sucht,
braucht ihn genau so, wie er in der Spalte steht.

DIE IP TRITT ZURUECK, verschwindet aber nicht: feste schmale Spalte,
leiser Ton. Sie beantwortet eine Frage, die man selten stellt.

UND DIE BESCHREIBUNGEN BRECHEN UM. Die der linken Hand lief ueber 150
Zeichen in einer Zeile; der Augensprung ans naechste Zeilenende ist
dann so weit, dass man die Zeile verliert. Setzer rechnen seit
Jahrhunderten mit 60 bis 80. Gekuerzt wird nichts -- "78ch" misst in
ZEICHEN und stimmt darum auch, wenn die Schrift groesser gestellt wird.

--- UND DIE ZWEI ALTLASTEN, BEIDE GESTERN GEMELDET ---

pruef-chatkachel suchte dreizehn Toene als dreizehn Knoepfe. Das
stimmte, bis die Kachelfarbe ein FARBKREIS wurde (7a674963): Seither
liegen die meisten auf einem Schieberegler und werden durch Drehen
gewaehlt. Sie meldete seither "mit allen 13 Toenen (2)" -- nichts war
kaputt, sie stellte eine Frage, die es nicht mehr gibt. Jetzt fragt
sie, was zaehlt: Ist der Kreis da, ist er bedienbar, sagt er welche
Farbe eingestellt ist, und stehen die uebrigen daneben. 36 Pruefungen,
alles in Ordnung.

pruef-galerie mass "#liste". Das war richtig, bis die Highlights am
20.09. in Kanalspalten umzogen -- seither nimmt bereich.js dem
Listenkasten sein data-galerie ausdruecklich wieder weg und setzt es
an die einzelne Spalte. Die Pruefung mass also eine leere Huelle.
Dazu erwartete sie "mindestens zwei Spalten", und das ist in einer
schmalen Kanalspalte schlicht falsch: Dort gehoert EINE Kachel je
Reihe. Jetzt sucht sie das Raster ueber das BILD (nicht ueber einen
Kastennamen -- der naechste Umbau darf umbenennen) und fragt nach der
KACHELBREITE: "1 Spalte à 219 px in einem 243 px breiten Kasten".
Eine Galerie, die sich in vier Spalten à 90 px quetscht, waere der
Fehler -- nicht eine Spalte in einem schmalen Kasten.

Beide waren monatelang rot, ohne dass etwas kaputt war. Eine Warnung,
die immer kommt, liest irgendwann niemand mehr -- und dann faellt die
echte daneben auch nicht mehr auf.

Gemessen: pruef-chatkachel 36, pruef-galerie, pruef-css-klassen,
pruef-deutsche-texte -- alle ohne Befund.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 00:00:47 +02:00
DogFatherGitandClaude Opus 5 7aef7e4b9a Keine Liste ohne Ende -- und die vierte Spalte sagt, was zu tun ist
Filipe: "der ohne account ist total sinnlos, mach was anderes draus.
und mach die ganze seite noch viel uebersichtlicher und ohne so dass
es extrem lange listen gibt mit der zeit."

ZWEI SACHEN, UND BEIDE FANGEN MIT NACHSEHEN AN.

--- 1. Was liegt eigentlich in "Ohne Account"? ---

Nachgezaehlt in den echten Daten:

  2 x clip     <- eine Luecke: ein Clip kommt IMMER von einem Kanal
  1 x moment
  1 x fanart   <- gehoert zu Recht zu keinem Account

Die Spalte mischte also zwei Dinge, die nichts miteinander zu tun
haben: etwas, das einsortiert gehoert, und etwas, das dort richtig
liegt. Und sie hiess nach dem, was FEHLT. Wer die Zahl sah, wusste
nicht, ob er etwas tun muss -- genau das macht sie "sinnlos".

Jetzt entscheidet der Inhalt ueber Namen und Satz:

  Clips dabei   -> "Noch einzusortieren" + "2 Clips hier haben keinen
                   Account. Öffne sie und trag ihn nach."
  nur Bilder    -> "Ohne TikTok-Quelle" + "Eigene Bilder und Fanart
                   gehören zu keinem Account. Hier ist nichts zu tun."

NICHT IN ZWEI SPALTEN GETRENNT: Das waere eine mehr, und Filipe hat
im selben Satz um weniger gebeten.

--- 2. "mit der zeit" ist der Kern ---

Heute liegen neun Highlights da und alles passt. In einem Jahr sind es
dreihundert, und dann ist jede Spalte eine Rolle ohne Ende. Der Fehler
faellt erst auf, wenn er schon laestig ist -- deshalb jetzt.

Je Abschnitt zwoelf, der Rest auf einen Druck. Zwoelf, weil zwei
nebeneinander passen: sechs Reihen, genug um zu sehen was zuletzt war,
ohne bis zum Anfang der Zeit zu scrollen.

AN EINER STELLE FUER ALLE DREI FORMEN. Die Seite legt Karten an drei
Stellen in einen Kasten -- Kanalspalten, Abschnitte nach Art,
Zeitstrahl. Dreimal dasselbe hinzuschreiben hiesse, dass beim
naechsten Umbau zwei nachgezogen werden und eine vergessen wird.

ES VERSCHWINDET NICHTS, und die Zahlen in den Koepfen zaehlen weiter
ALLE: Eine Ueberschrift, die 12 sagt und 30 meint, waere schlimmer als
eine lange Liste. Gemessen mit 30 Eintraegen: Kopf zeigt 30, Spalte
zeigt 12, Knopf bietet "18 ältere zeigen", nach dem Druck sind alle 30
da und der Knopf ist weg -- er haette nichts mehr zu tun.

Sortiert ist ohnehin nach Datum absteigend, "die ersten zwoelf" sind
also die neuesten zwoelf und nicht die erstbesten.

Gemessen: 8 Messungen mit dem Datenbestand "ein Jahr spaeter",
0 Befunde. pruef-css-klassen und pruef-highlights in Ordnung.

NICHT VON MIR, mit Gegenprobe belegt: pruef-galerie meldet "mit
mehreren Spalten (1)" -- auch mit zurueckgenommener Aenderung. Eine
Altlast, die nachgezogen gehoert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 23:50:21 +02:00
DogFatherGitandClaude Opus 5 d868bb1e35 Die Personenkacheln: aus drei Zahlen wird ein Bild
Filipe: "ich will dass das alles viel anders und krasser und geiler
gestaltet ist. mal was krass modernes ... viel viel viel
uebersichtlicher."

WAS AUF SEINEM BILDSCHIRMFOTO STAND: sechs Kacheln, jede mit drei
gleich grossen Zahlenkaesten -- und bei fuenf davon exakt dasselbe:
"0 angesehen, 68 noch offen, 0 verschieden gesehen". Achtzehn Kaesten
fuer eine einzige Auskunft: Hier ist noch nichts passiert.

DREI ZAHLEN MUSS MAN LESEN UND VERRECHNEN. Einen Balken sieht man.
Und die Frage, die man an diese Liste stellt, ist nicht "wie viele
genau", sondern "wer ist wie weit". Darum liegt unter jedem Namen
jetzt eine schmale Spur, vier Bildpunkte hoch, die genau das zeigt.

KEINE AMPELFARBEN. Was "genug" ist, haengt davon ab, wie lange jemand
dabei ist; eine Schwelle waere geraten und bei der naechsten Person
falsch. Der Balken sagt, WIE WEIT -- er urteilt nicht. Und er traegt
die Akzentfarbe der Seite, nicht Gruen oder Rot.

EINE NULL IST KEINE WARNUNG. "Verschieden gesehen" ist die einzige
der drei Zahlen, die zu einer Handlung fuehrt: Dort lohnt das
Gespraech. Steht dort eine Null -- der Normalfall --, tritt der Kasten
zurueck. Gleiche Groesse, gleicher Platz, nur leiser. Sonst waeren es
sechs gelbe Nullen, und die eine echte siebte faellt dann nicht mehr
auf.

NICHTS WURDE WEGGENOMMEN. Alle drei Zahlen stehen weiter da, gemessen:
drei Kaesten je Kachel, auf Rechner und Handy. Und ein
Vorleseprogramm bekommt den Balken als Satz ("0 von 68 angesehen,
0 Prozent") -- eine Breite allein sagt ihm nichts.

Augenschonend nach Hausregel: kein Leuchten, kein blendender Verlauf,
und die kurze Bewegung beim Neuzeichnen faellt bei
prefers-reduced-motion ganz weg.

Gemessen: 16 Messungen auf beiden Groessen, 0 Befunde.
pruef-entwicklung-kacheln, pruef-entwicklung (48) und
pruef-css-klassen alle in Ordnung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 23:28:13 +02:00
DogFatherGitandClaude Opus 5 03220b3924 Kanaele machen nur zwei auf
Filipe: "kanaele sollen auch nur die rechte hand und dogfather
aufmachen koennen."

Bis heute galt `fuehrtTeamDogi` -- und das schliesst die LINKE Hand
ein (WIE_RECHTE_HAND = {hand, linke}). Genau die zwei Worte im Auftrag
schliessen sie aus.

`fuehrtTeamDogi` SELBST WIRD NICHT ANGEFASST. Es haengt an elf
weiteren Stellen: Bereiche, Bretter, Sichtbarkeiten, wer einen Kanal
ueberhaupt sieht. Wer die Funktion aendert, aendert zehn Dinge, die
niemand verlangt hat. Stattdessen eine eigene Regel fuer genau diese
eine Frage -- dieselbe Bauweise wie `darfJedeNachrichtLoeschen` ein
paar hundert Zeilen weiter unten, die aus demselben Grund entstanden
ist ("zwei Rollen, woertlich die zwei aus dem Auftrag").

Nicht `istLeitung` uebrigens: Das schlösse Spicy Media ein, und
genannt wurden zwei Rollen, nicht drei.

AN EINER STELLE, NICHT AN ZWEIEN. Der Server lehnt ab, und die
Oberflaeche bietet es gar nicht erst an -- beide fragen dieselbe
Funktion. Ein Knopf, den man sieht und der dann mit 404 antwortet,
ist schlimmer als keiner.

WAS ES NICHT BETRIFFT: Wer in einem BESTEHENDEN Kanal die Leute
aendert. "Aufmachen" beantwortet diese Frage nicht, also bleibt es
dort beim Alten. Falls das auch enger werden soll, sagt Filipe es.

Gemessen, alle vier Rollen durchgespielt:

  DogFather     darf        -> 201, Seite bietet es an
  rechte Hand   darf        -> 201, Seite bietet es an
  linke Hand    darf NICHT  -> 404, Seite bietet es nicht an
  ein Modi      darf NICHT  -> 404, Seite bietet es nicht an

Dazu die Gegenprobe, dass die Absage nichts verraet: Eine erfundene
und eine echte Kategorie sehen fuer die linke Hand gleich aus (404 /
404). Sonst waere aus der Fehlermeldung abzulesen, welche Kanaele es
gibt. 9 Messungen, 0 Befunde. pruef-chat-kanaele: 81 geprueft,
0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 23:25:42 +02:00
DogFatherGitandClaude Opus 5 f2f3586b5b Die Auswahlliste lag hinter dem Dialog
Filipe: "wenn ich auf zustaendigkeit druecke dan erscheint die auswahl
im hintergrund kontrollier das."

KONTROLLIERT, UND ER HAT RECHT. Gemessen im Browser: Der Kanal-Dialog
ist modal, die Liste hing an BODY, und ein Klick in ihre Mitte traf
DIALOG.dialog -- also den Dialog, nicht die Liste. Sie war da, sichtbar
war sie nicht, bedienbar erst recht nicht.

DER GRUND IST EINE EBENE, KEINE ZAHL. Ein Dialog aus showModal() liegt
in der TOP LAYER, einer Schicht ueber dem ganzen Dokument. Kein
z-index holt etwas aus dem Body davor; die Ebene entscheidet.

DER ERSTE VERSUCH WAR FALSCH, und das Bildschirmfoto hat es gezeigt:
Die Liste IN den Dialog zu haengen bringt sie zwar nach vorn -- und
laesst sie am unteren Rand abschneiden. `.dialog` traegt ein
clip-path fuer die abgeschraegte Ecke, und ein clip-path beschneidet
ALLE Nachkommen, auch "position: fixed". Die Liste endete mitten im
Wort "Events".

Damit ging beides nicht: draussen dahinter, drinnen beschnitten.

DER POPOVER IST GENAU DAFUER GEMACHT. Er hebt ein Element in dieselbe
Ebene wie den Dialog, ohne es zu seinem Kind zu machen: kein Beschnitt,
kein z-index-Wettlauf, und der Browser raeumt ihn beim Schliessen
selbst weg. Fehlt er im Browser, bleibt alles wie bisher -- ausserhalb
eines Dialogs aendert sich ohnehin nichts.

Die drei Zeilen in gate.css nehmen die Vorgaben zurueck, die ein
Popover mitbringt (Rahmen, Polster, und "inset: 0" plus "margin: auto",
was ihn in die Bildmitte stellt).

UND MEINE MESSUNG WAR ZUERST FALSCH, nicht der Code: Sie fragte
document.elementFromPoint und bekam DIALOG -- auch als die Liste
sichtbar darueber lag. Top-Layer-Elemente erfasst elementFromPoint
nicht verlaesslich. Gemessen wird jetzt, was ein Mensch tut: auf einen
Eintrag tippen und nachsehen, ob er ankommt. Er kommt an
("chat -> events"), und die Liste schliesst sich danach.

DAZU EINE EIGENE SCHLAMPEREI VON VORHIN: Die neuen Handy-Kacheln auf
"Eure Aufgaben" hatten .7rem = 11,2 px. Die Grenze des Hauses liegt
bei 11,5 px, und sie steht dort aus einem Grund -- Augenschonung ist
Pflicht, nicht Geschmack. pruef-css-klassen hat es gefangen ("43
Stellen unter 11,5 px, eine mehr als die Grundlinie 42"). Genau dafuer
zaehlt sie mit. Jetzt .75rem, und die Zahl steht wieder bei 42.

Gemessen: 7 Messungen am Dialog ohne Befund, css-klassen wieder in
Ordnung.

NICHT VON HEUTE ABEND, aber gefunden: pruef-chatkachel meldet "mit
allen 13 Toenen (2)". Seit Commit 7a674963 liegen die meisten Toene
auf einem Ring und nur der Rest im Gitter; die Pruefung zaehlt nur das
Gitter. Sie ist damit dauerhaft rot und gehoert nachgezogen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 23:22:05 +02:00
DogFatherGitandClaude Opus 5 cbaa529350 Eure Aufgaben: die Reihenfolge, in der man denkt
Filipe: "die seite eure aufgaben, ich will dass du die so krass
perfektionierst, ich will dass du die so krass uebersichtlich machst."

ERST GEMESSEN, DANN ANGEFASST. Mit sechs Leuten, so wie im echten
Team:

  Handy, Person gewaehlt      8281 px  = 10,6 Bildschirme
  Handy, Katalog offen       11636 px  = 14,9 Bildschirme

Und die Personenwahl -- der ERSTE Schritt -- begann am Handy bei
Bildschirm 5,3. Man scrollte an allem vorbei, um anzufangen; und was
man dabei ueberscrollte (Formular, Katalog), betraf genau die Person,
die man noch gar nicht gewaehlt hatte.

DER PLAN STAND SCHON DA. Im HTML steht seit dem 15.09. ein Kommentar:
"1. Die Ampel. 2. Die Personen. 3. Die Karte." Genau so war es gedacht
-- und genau so war es nicht mehr: Am 22.09. kam das Verteilen auf die
Seite, am 23.09. der Katalog, und beide sind davor gerutscht. Der
Kommentar beschrieb eine Ordnung, die es nicht mehr gab. Wieder eine
Bestandsliste, die altert, waehrend jemand weiterarbeitet.

Die Reihenfolge ist jetzt die, in der man denkt: Wie steht das Team?
-> Wen nehme ich mir vor? -> Was gebe ich ihm? -> Was liegt schon bei
ihm? -> Wie steht er da?

  Handy: Personenwahl beginnt bei Bildschirm 0,5 statt 5,3
  Rechner: bei 0,4 statt 2,0

DIE KACHELN AM HANDY kosteten 1176 px fuer sechs Leute -- anderthalb
Bildschirme nur fuer die Frage, wen man sich vornimmt. Grund war
"min-width: 260px", und der Grund DAFUER steht daneben: Die drei
Bilanz-Kaesten wurden sonst gequetscht. Das stimmt, solange sie
NEBENEINANDER stehen. Am Handy stehen sie jetzt untereinander, jeder
eine Zeile (Ziffer links, Wort rechts) -- so kommt die Kachel mit der
halben Bildschirmbreite aus und zwei passen nebeneinander: 618 px.

KEINE ZAHL FAELLT WEG. Wer verteilt, muss sehen, wer schon wie viel
hat; das ist der Zweck dieser Kaesten. Sie werden kleiner, nicht
weniger. Unter 380 px wieder eine Kachel je Zeile -- zwei haetten dort
je 145 px, und "verschieden gesehen" waere nicht mehr zu lesen.

DER SPRUNG BEIM KACHELKLICK IST WEG. Er war richtig, solange die Karte
direkt unter der Auswahl stand. Jetzt liegen Formular, Katalog und ihre
Aufgaben dazwischen -- ein Sprung zur Karte uebersaehe genau die drei
Dinge, die man nach der Wahl zuerst braucht. (Filipe, 15.09.: "die
seite soll sich nicht immer bewegen wenn ich auf was druecke.") Der
Sprung von der Talentseite bleibt, dort ist er gemeint.

UND DER CHAT KEHRT DAHIN ZURUECK, WO MAN AUFGEHOERT HAT. Die Linie
"Ab hier neu" gibt es seit Tagen -- sie wurde gezeichnet und sofort
ueberscrollt, weil der Verlauf beim Oeffnen ans Ende sprang. Wer nach
zwei Tagen zurueckkam, landete unten und suchte die Stelle, indem er
Uhrzeiten las. Jetzt springt er EINMAL beim Oeffnen dorthin, auf ein
Viertel Hoehe: darueber der Zusammenhang, darunter das Neue. Danach
gilt wieder die alte Regel, damit eine eintreffende Nachricht einen
nicht aus dem Lesen reisst.

Gemessen: entwicklung 48, entwicklung-kacheln 15, modi-katalog 133,
bewerbung-aufgaben 101, chat-optik -- alle ohne Befund.

NOCH NICHT FERTIG: Die Entwicklungskarte ist mit 5975 px weiterhin
72 % der Seite. Das ist der naechste Schritt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 23:07:10 +02:00
DogFatherGitandClaude Opus 5 5b7708fd10 Die Kopfleiste bleibt stehen -- jetzt auf allen Seiten
Filipe: "die leiste soll immer da fest stehen bleiben auch wenn man
runterscrollt, sonnst muss man immer wieder hoch scrollen um zurueck
zu koennen oder so."

GEMESSEN, BEVOR ETWAS ANGEFASST WURDE -- und das war noetig, denn der
Quelltext sagte das Gegenteil:

  entwicklung.html   sticky    klebt
  start.html         sticky    klebt
  aufgaben.html      relative  wandert weg
  chat.html          relative  wandert weg
  wissen.html        relative  wandert weg

Dieselbe Leiste, dasselbe CSS, zwei Verhalten. Der Unterschied war
eine Regel, die es gar nicht darauf angelegt hatte:

  body[data-ton] .kopfleiste { position: relative; }

Sie stand dort einzig, damit ein ::before darunter einen Bezugspunkt
bekommt -- die farbige Kante der Seite. Ihre Staerke ist (0,2,1),
genau wie die der Regel, die das Kleben setzt, und sie steht 8400
Zeilen spaeter. Bei gleicher Staerke gewinnt die spaetere.

WARUM MAN DAS IM QUELLTEXT NICHT SIEHT: "data-ton" haengt kopf.js
erst NACH dem Laden an den Body. Im HTML steht es nirgends. Welche
Seite betroffen ist, entscheidet sich also im Browser -- und nur dort
war es zu messen.

ERSATZLOS WEG, nicht ersetzt: "position: sticky" ist selbst ein
Bezugspunkt fuer absolut positionierte Kinder. Das ::before braucht
die Zeile nicht. pruef-kopfleiste-farbe bestaetigt das: 9 geprueft,
0 Fehler, die Kante traegt weiter die Farbe der Seite.

Dazu gilt die Regel jetzt fuer jedes Haus statt nur fuer "body.start"
-- anruf-probe.html traegt "body.haus" und war nie erfasst.

UND DAS SPRUNGZIEL. Wer von "Eure Aufgaben" auf eine Aufgabe tippt,
landet auf aufgaben.html#a123. Mit einer festklebenden Leiste liegt
das Ziel danach exakt darunter -- die Seite springt, und die gesuchte
Karte ist trotzdem nicht zu sehen. Das sieht aus wie ein kaputter
Link. "scroll-padding-top" haelt jetzt Abstand, und zwar aus der
gemessenen Hoehe (--kopf-hoehe, die kopf.js ohnehin fuehrt und in der
auch das Band der fremden Sicht steckt) -- keine feste Zahl: Am
Rechner sind es 118 px, auf einem 390er-Schirm 115.

DAS WAR DIE FUENFTE SPIELART DERSELBEN FALLE. Die vier anderen stehen
seit dem 07.09. im Kommentar daneben; jedes Mal hat eine Regel
"position" gesetzt, um etwas ganz anderes zu erreichen. Damit es
keine sechste gibt, misst pruef-kopf-messen ab jetzt das VERHALTEN:
Sie scrollt und sieht nach, wo die Leiste danach steht. Auf sechs
Seiten statt drei -- die drei neuen sind die, auf denen es gebrochen
war, plus eine ohne Farbton als Gegenprobe. Seiten, die zu kurz zum
Scrollen sind, melden "nicht nachsehbar" statt stillschweigend gruen
zu werden.

Gemessen: pruef-kopf-messen 42 Breiten (davon 14 Klebe-Messungen),
0 beanstandet. pruef-kopfleiste-farbe 9, pruef-ueberlappung 20
Seiten-Breiten-Paare, alle ohne Befund. Sprungziel auf Rechner und
Handy: 6 Messungen, 0 Befunde.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 22:56:04 +02:00
DogFatherGitandClaude Opus 5 47cea0533b Die Personenreihe im Katalog kommt zurueck
Filipe: "ich hab gesagt du sollst die kachel von aufgabe in die eure
aufgabe kategorie machen und du machst sie ganz weg was soll das, da
ist scheisse wenn du einfach sachen machst die ich nicht verlange."

Er hat recht. Der Auftrag war, den Katalog zu VERSCHIEBEN. Ich habe
ihn verschoben und dabei die Personenreihe darin geloescht -- mit
einer Begruendung, die ich mir selbst gegeben habe. Verlangt war das
nicht.

Sie steht wieder vollstaendig da: die Ueberschrift "An wen", ein Knopf
je Person, daneben wie viel offen ist, und was ueberfaellig liegt
faellt auf ("1 spaet"). Dieselben Bausteine wie vorher, dieselbe
Gestaltung -- am CSS musste nichts geaendert werden, es stand noch da.

WAS SICH GEAENDERT HAT, IST NUR, WAS SIE SETZT. Frueher hatte sie eine
eigene Auswahl (kZiel), die nichts von der Seite wusste: Man konnte
oben den einen und unten den anderen waehlen, und dann standen zwei
Antworten auf einem Bildschirm. Auf "Aufgaben" fiel das nicht auf,
weil es dort oben gar keine Personenwahl gab. Auf "Eure Aufgaben"
waere es aufgefallen.

Jetzt ruft ein Tipp in der Reihe dieselbe Funktion wie ein Tipp auf
eine Kachel -- nicht etwas Aehnliches, sondern denselben Weg. Damit
KANN die Reihe nichts anderes meinen als die Kacheln. Zwei Stellen zum
Bedienen, eine Antwort.

Gemessen, in beide Richtungen: Ein Tipp in der Reihe markiert die
Kachel oben, und ein Tipp auf die Kachel markiert den Knopf in der
Reihe. Dazu Namen, Zahlen und die Spaet-Markierung. 14 Messungen,
0 Befunde. pruef-modi-katalog misst wieder drei Reihen statt zwei --
und neu auch die Kopplung selbst: 133 geprueft (vorher 129), 0 Fehler.

WAS ICH DARAUS MITNEHME: "Verschieben" heisst verschieben. Wenn mir
beim Umzug etwas auffaellt, das ich fuer ueberfluessig halte, ist das
eine Frage an Filipe und keine Entscheidung von mir.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 17:27:35 +02:00
DogFatherGitandClaude Opus 5 4454c6d064 Ein zweiter Druck legt nichts mehr doppelt an
Filipe: "diese aufgaben die da alle verteilt wurde, die hab ich nicht
gemacht also sollen die alle da weg, keine ahnung was du da gemacht
hast."

WAS WIRKLICH PASSIERT IST, steht in der Datenbank:

  2026-09-22T16:05:24   35 Aufgaben
  2026-09-22T16:05:25   21 Aufgaben
  2026-09-22T16:05:53   56 Aufgaben

Zweimal "Alle uebernehmen", 29 Sekunden auseinander, von Ghost. 112
Aufgaben an vier Modis -- 14 Vorlagen, jede doppelt, je 28 pro Person.
Alle noch offen, keine einzige angefasst.

Der eine Weg dorthin ist seit dem 22.09. zu: Ein Modi darf nicht mehr
verteilen (darfAufgabenVerteilen). Der andere war offen -- der zweite
Druck selbst. Wer verteilen darf, konnte den Massenknopf beliebig oft
betaetigen, und nichts hat ihn aufgehalten.

DIE SPERRE GAB ES IM HAUS SCHON, im Termin-Zweig derselben Route: "Was
es schon gibt, wird nicht doppelt angelegt." Beim Massenknopf fehlte
sie. Dass sie fehlte, ist nicht aufgefallen, weil niemand zweimal
drueckt -- bis es jemand tat.

NUR DER MASSENKNOPF, NICHT DER EINZELNE. Ein "Nochmal" an einer Karte
ist eine bewusste Entscheidung; manches macht man jede Woche neu, und
der Knopf sagt es sogar. Ein Griff, der zwoelf Aufgaben auf einmal
holt, ist etwas anderes: Ob er schon gedrueckt wurde, sieht man ihm
nicht an, und beim zweiten Mal richtet er zwoelffachen Schaden an.

UND "OFFEN" HEISST OFFEN. Was erledigt oder abgebrochen ist, darf
wiederkommen -- sonst liesse sich eine woechentliche Aufgabe nach dem
ersten Abhaken nie wieder holen. Gefragt wird nicht "gab es die schon
mal", sondern "liegt die gerade noch da".

Die Oberflaeche sagt es jetzt auch: "3 uebernommen. 9 lagen schon
offen da - die kommen nicht doppelt." In Gruen, nicht in Rot: Das ist
keine Stoerung, sondern die Auskunft, dass die Sperre gegriffen hat.
Ein Knopf, der weniger tut als er verspricht UND schweigt, ist
schlimmer als einer, der zu viel tut -- man drueckt ihn noch einmal.

Gemessen am nachgestellten Vorfall: erster Druck 12 Aufgaben, zweiter
Druck 0 statt 12 (waeren 24 geworden). Gegenproben: Abgehaktes laesst
sich neu holen, und der einzelne Nochmal-Knopf legt weiter an.
12 Messungen, 0 Befunde. pruef-modi-katalog 129 und
pruef-aufgaben-vorlagen bleiben gruen.

Die 112 Aufgaben selbst stehen noch in der Datenbank -- sie gehoert
dogiweb, ich habe dort nur Leserecht. Der Befehl dafuer geht an Filipe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 17:21:15 +02:00
DogFatherGitandClaude Opus 5 84e4048ab4 Der Aufgabenkatalog zieht dorthin, wo verteilt wird
Filipe: "das soll auch bitte nicht in der kategorie aufgaben sondern
eure aufgaben sein bitte. setzt das perfekt da rein und pass es
uebeertrieben krass rein."

Und das ist richtig: "Aufgaben" zeigt, WAS liegt. Verteilt wird seit
dem 22.09. auf "Eure Aufgaben" -- dort wird die Person gewaehlt, dort
steht das Formular, dort ihre Aufgaben. Der Katalog ist nichts anderes
als ein zweiter Weg zu derselben Handlung: 101 fertige Aufgaben statt
einer selbst getippten.

WAS DER ERSTE ANLAUF ZERSTOERT HAETTE. Der Block enthaelt ZWEI
Kataloge, und sie gehen an zwei verschiedene Personenkreise -- das
entscheidet der Server. Team Dogi bekommt die 101 Aufgaben in 14
Kategorien, die Agentur den Creator-Katalog (vier Bereiche, vier
Stufen). Ihn einfach herauszuschneiden und drueben hinzulegen haette
dem zweiten Katalog die Heimat genommen. Gemerkt hat das keine
Ueberlegung, sondern die Frage, wer die Zeile "ich.rolle === 'creator'"
eigentlich bedient -- pruef-aufgaben-vorlagen misst sie seit Tagen.

Deshalb eine gemeinsame Datei (vorlagenbrett.js) statt zweier
Abschriften: Die gemeinsamen Teile -- Abruf, Klappkopf, Uebernehmen --
gibt es weiter genau einmal, und jede Seite sagt beim Einrichten, ob
sie den Team- oder den Creator-Zweig zeigt. Fehlt ihr dabei eine
Angabe, sagt die Datei das in der Konsole, statt stumm nichts zu
zeichnen.

WAS DER UMZUG NEBENBEI LOESCHT: Drueben brauchte der Katalog eine
EIGENE Personenwahl, weil es dort keine gab -- eine dritte Knopfreihe
unter zwei anderen, und die Moeglichkeit, oben den einen und unten den
anderen zu waehlen. Hier ist die Person laengst gewaehlt, mitsamt ihren
Zahlen. Eine Auswahl statt zwei.

DREI DINGE, DIE ERST DADURCH AUFFIELEN:

"schon uebernommen" galt im Team-Katalog fuer JEDEN. Sobald irgendwer
eine Vorlage hatte, stand es an der Karte -- auch fuer alle anderen.
Auf einer Seite ohne Personenwahl fiel das kaum auf; hier waere es
offen falsch: Man waehlt Frida, und der Katalog behauptet, sie habe die
Aufgabe schon, weil Rieke sie hat. Der Creator-Zweig machte es von
Anfang an richtig. Und wer verteilt, bekommt ohne gewaehlte Person gar
keine Markierung mehr: "irgendwer hat sie" liest man als "brauche ich
nicht mehr zu vergeben" und ueberspringt, was dem Menschen vor einem
fehlt.

Der Katalog blieb fuer einen Modi GANZ weg. window.__ich kommt ueber
das Netz und ist beim ersten Zeichnen noch nicht da; ein stummes
"return" liess den Block dauerhaft verschwinden, weil niemand ein
zweites Mal zeichnet. Gefunden hat das kein Codelesen, sondern ein
Bildschirmfoto -- die Seite sah vollstaendig aus, nur ohne den Block.
Gewartet wird jetzt mit der Wartestelle des Hauses.

Und er markierte bei einem Modi nichts mehr: Die Aufgabenliste wurde
nur beim Personenwechsel geholt, und ein Modi waehlt nie jemanden.
Damit war die Sperre gegen das zweite Uebernehmen derselben Vorlage
weg. Jetzt gibt es einen Abrufweg fuer beide.

Der Knopf nennt den Namen ("An Rieke"), der Satz darueber auch. Statt
einer Wegbeschreibung zur Personenwahl steht ein Knopf, der hinfuehrt
-- "waehle oben" waere falsch, die Kacheln stehen weiter unten. Und
"auf dem Brett darunter" stimmt hier nicht mehr: Der Kopftext sagt
jetzt, WAS entsteht, nicht WO es landet.

Gemessen: pruef-modi-katalog 129 (vorher 125), pruef-modi-kategorien
29 (vorher 25 mit 3 Fehlern), pruef-aufgaben-vorlagen, pruef-struktur
und pruef-css-klassen alles in Ordnung. Dazu eine Abnahme ueber beide
Seiten und drei Rollen: 32 Messungen, 0 Befunde -- darunter die
Gegenprobe, dass Marinas uebernommene Aufgabe bei Frida NICHT als
uebernommen gilt.

Die 403-Zeilen in pruef-modi-kategorien waren ebenfalls eine Altlast:
Seit dem 22.09. legt im Team Dogi nur die Leitung an ("sie sich nicht
selber aufgaben geben"), die Pruefung tat es weiter als Modi. Sie misst
das jetzt -- samt der Gegenprobe, die es vorher nicht gab.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 17:14:01 +02:00
DogFatherGitandClaude Opus 5 290d7c8d88 Zwei Pruefungen massen einen Stand, den es nicht mehr gibt
Beide Befunde sind heute beim Umzug des Vorlagenkatalogs aufgefallen,
gehoeren aber nicht dazu -- sie lagen schon vorher da und haetten bei
jedem Lauf mitgemeldet, bis sie niemand mehr liest.

pruef-struktur las Server-Routen nur in doppelten Anfuehrungszeichen.
Wer eine Route in einer Schleife registriert, schreibt sie aber als
Template:

    for (const weg of ["annehmen", "ablehnen"])
      router.post(`/workspace/api/vorlagen/bewerbung/${weg}`, ...)

Diese Routen fehlten in der Liste, und die Aufrufe dorthin galten als
"Schnittstelle gibt es nicht" -- obwohl sie laufen. Die Erfassung der
AUFRUFE kannte alle drei Zeichen laengst; nur die der ROUTEN nicht.
Zwei Regeln fuer dieselbe Frage, und eine davon war aelter. Gemessen:
319 statt 315 Routen, und der Fehlalarm ist weg. Die Gegenproben
schlagen weiter an ("erfundene Schnittstelle wird als fehlend
erkannt").

pruef-aufgaben-vorlagen zaehlte KARTEN und erwartete +1. Seit das
Brett gleiche Aufgaben zu einer Sammelkarte zusammenfasst, stimmt das
nicht mehr -- die Pruefung legt kurz davor sieben Vorlagen derselben
Stufe an, und die neue wanderte in eine bestehende Karte. Sie meldete
"9 -> 9" und behauptete damit, das Uebernehmen sei kaputt; drei Zeilen
weiter erkannte sie dieselbe Aufgabe als "schon uebernommen" wieder.
Jetzt zaehlt sie Aufgaben: "9 -> 10 Aufgaben in 9 Karten".

Dieselbe Stelle gab es zweimal. In pruef-modi-katalog ist sie gestern
repariert worden -- hier nicht, weil ich nach dem ersten Fund nicht
weitergesucht habe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 17:13:35 +02:00
432 changed files with 92173 additions and 5065 deletions
+11
View File
@@ -26,3 +26,14 @@
*.woff binary
*.woff2 binary
*.conf text eol=lf
# Die git-Haken haben KEINE Endung -- keine der Regeln oben greift auf
# sie. Nachgemessen am 01.10.2026: Im Verlauf lagen 73 CR-Bytes in
# tools/git-haken/pre-commit.
#
# Auf Windows blockiert der Haken damit trotzdem richtig (gemessen:
# Rueckgabe 1, 0 Commits) -- auf Linux nicht, dort ist "#!/bin/sh␍"
# ein Programmname mit einem unsichtbaren Zeichen am Ende. Das ist
# Vorsorge, keine Reparatur: Ein Haken, der still nicht laeuft, ist
# genau die Sicherung, die aussieht, als waere sie da.
tools/git-haken/* text eol=lf
+35
View File
@@ -80,6 +80,10 @@ pruef-*.png
server/pruef-*.png
abnahme-*.png
server/abnahme-*.png
# Dasselbe fuer die Messlaeufe (server/mess-*.mjs): Bilder eines
# Laufs, kein Quelltext.
mess-*.png
server/mess-*.png
# Messlaeufe von tools/mess-rueckgabewerte.sh -- Ergebnis eines Laufs,
# kein Quelltext. Gehoert nicht in die Geschichte.
@@ -106,3 +110,34 @@ zz-*
# ist die Entscheidung "gehoert das ins Verzeichnis?" ein Umbenennen
# und kein Vergessen.
tools/_*
# Der naechtliche Lauf schreibt hier seinen Zustand hin -- Rohausgabe,
# Vergleichszahlen und die Schlossdatei. Alles Laufzeit, nichts davon
# gehoert in die Geschichte: Es aendert sich jede Nacht, und im Repo
# waere es ein taeglicher Konflikt ohne Aussage. Das ERGEBNIS steht in
# der Vault-Notiz (02 Projekte/Pruefstand.md), die Werkzeuge selbst
# sind versioniert.
tools/.nachtlauf-*
# Zwischenstaende meiner Mess- und Sammellaeufe. Rohausgaben, die sich
# bei jedem Lauf aendern -- im Repo waeren sie taeglicher Konflikt ohne
# Aussage. Die Werkzeuge selbst sind versioniert.
tools/.sammellauf*
tools/.nachpruef*
tools/.handy-detail.txt
tools/.rest*
videos/
tools/.vid/
# Commit-Texte und Drehprotokolle sind Arbeitsmaterial, kein Code.
tools/.commit-*
tools/.dreh*
tools/.nach-*
tools/.messkopf.txt
# Das Arbeitsschloss gehoert dem Rechner, nicht dem Verlauf: Es sagt,
# wer GERADE arbeitet. Committet waere es eine Behauptung von gestern.
.arbeitsschloss
+8 -8
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -19,10 +19,10 @@
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<meta name="robots" content="noindex" />
<title>Seite nicht gefunden — DOGFATHER UNIVERSE</title>
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body>
<div id="site-header"></div>
@@ -42,7 +42,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-404.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-404.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+84 -3
View File
@@ -1,5 +1,51 @@
# DOGFATHER UNIVERSE — Go-Live-Anleitung
## ⚠️ ARBEITET HIER GERADE SONST JEMAND? (seit 01.10.2026)
An diesem Tag arbeiteten **zwei Claude-Sitzungen gleichzeitig** in diesem Verzeichnis,
ohne voneinander zu wissen. Keine hat etwas falsch gemacht — sie konnten es nicht wissen.
Zweimal ist es nur gut gegangen:
* Beide haben `git add -A` benutzt. Hätte die eine unfestgeschriebene Arbeit der anderen
im Baum gehabt, wäre sie **mitcommittet** worden — unter fremdem Namen, in einer fremden
Begründung, und niemandem wäre es aufgefallen.
* Beide haben `tools/workspace-stempel.mjs` laufen lassen. Der schreibt 45 Dateien um. Wer
dort eine offen hatte, bekam sie **unter den Händen weg** geändert.
Dasselbe hat in RunOne am 03.09.2026 sieben Minuten Ausfall gekostet.
**Vor dem Arbeiten:**
```bash
node tools/arbeitsschloss.mjs ansehen # arbeitet hier jemand?
node tools/arbeitsschloss.mjs nehmen "was ich tue"
node tools/arbeitsschloss.mjs freigeben # am Ende
```
Du musst nichts von Hand tun, wenn du nur stempelst oder committest — die beiden
Stempelwerkzeuge nehmen das Schloss selbst, und ein **git-Haken** (`tools/git-haken/pre-commit`)
bricht jeden Commit ab, solange **jemand anders** das Schloss hält.
**Was es NICHT tut** — das ist der wichtigere Teil:
* Es blockiert **nicht**, wenn das Schloss **deines** ist. Wer ordentlich abschließt, soll
nicht bestraft werden.
* Es **verfällt nach zwei Stunden**. Ein Schloss, das man vergessen kann, blockiert sonst
dauerhaft — und wird beim ersten Ärger umgangen. Ab da ist es wertlos.
* Es blockiert **nicht**, wenn es selbst kaputt oder nicht lesbar ist.
* Notausgang, falls du trotzdem musst: `SCHLOSS_ZWANG=ja git commit …`
**Nach einem frischen Klon einmal:** `node tools/arbeitsschloss.mjs einrichten`
(setzt `core.hooksPath`; `.git/hooks` wird nicht versioniert und wäre sonst leer).
`node server/pruef-arbeitsschloss.mjs` sagt dir, ob alles sitzt — 33 Prüfungen,
darunter ein echter Commit gegen ein fremdes Schloss.
**Und bei zwei Claude-Sitzungen:** Das Schloss macht die Gleichzeitigkeit sichtbar, es
ersetzt das Reden nicht. `SendMessage` an die andere Sitzung kostet zehn Sekunden.
---
## ⚠️ ZUERST LESEN: Die echte Seite läuft auf dem NETCUP-SERVER, nicht auf Cloudflare
Seit dem öffentlichen Start (21.08.2026) wird `dogfather-universe.com` vom **eigenen
@@ -25,6 +71,9 @@ Erkennungsmerkmal im Zweifel: `curl -sI https://dogfather-universe.com/ | grep -
```bash
cd ~/Documents/Obelix/DogiHompage
# 0. STEMPELN — sonst kommt die Änderung bei niemandem an (siehe unten)
node tools/workspace-stempel.mjs # wenn workspace/ angefasst wurde
node tools/seiten-stempel.mjs # wenn assets/ angefasst wurde
# 1. Änderung committen
git add <dateien> && git commit
# 2. In die Ablage schieben (Gitea ist die Quelle der Wahrheit)
@@ -33,6 +82,27 @@ git push gitea master:main
ssh dogfather-server "cd /home/dogiweb/dogfather-universe && git pull --ff-only origin main"
```
### ⚠️ Schritt 0 ist kein Beiwerk
Der Server schickt zu Stilvorlagen, Skripten und Bildern:
```
Cache-Control: public, max-age=31536000, immutable
```
`immutable` heißt: Der Browser **fragt nicht einmal nach**. Wer die Seite einmal geladen
hat, behält diese Dateien bis zu einem Jahr — oder bis sich ihre Adresse ändert. Genau
dafür hängt der Stempel (`?v=…`) daran.
**Das ist schon passiert:** Auf der öffentlichen Website stand der Stempel vom 27.08.2026,
während sechs Commits `assets/` geändert hatten — darunter der Partnercode DOGI10 und der
komplette Sprachumbau. Fünf Wochen lang kam keine dieser Änderungen bei einem
wiederkehrenden Besucher an. Sie lagen auf dem Server, sie waren ausgeliefert, und niemand
sah sie.
`pruef-zwischenspeicher` prüft seit dem 30.09.2026, dass der Stempel **nicht älter ist als
die Dateien, auf die er zeigt** — nicht bloß, dass einer dasteht.
Statische Dateien (HTML/CSS/JS/Bilder) sind damit **sofort** live — der Express-Dienst liefert
das Verzeichnis direkt aus, ein Neustart ist dafür **nicht** nötig.
@@ -166,17 +236,28 @@ ssh dogfather-server "rm -f /home/dogiweb/dogfather-universe/server/package-lock
ssh dogfather-server "cd /home/dogiweb/dogfather-universe && git pull --ff-only origin main"
```
## ⚠️ Am Webdesign-Bereich geändert? Dann ZWEI Zahlen hochzählen
## Der Webdesign-Bereich: zwei Zahlen — seit 30.09.2026 automatisch
Die App speichert Dateien zwischen. Nach einer Änderung an `webdesign/`
oder `assets/` müssen **beide** Stellen hoch, sonst bekommen Geräte, die
die Seite schon einmal geöffnet haben, weiterhin den alten Stand:
```
webdesign/sw.js const CACHE_NAME = "dogfather-webdesign-vNN";
assets/js/wd-core.js .register("/webdesign/sw.js?v=NN", …)
webdesign/sw.js const CACHE_NAME = "dogfather-webdesign-<Stempel>";
assets/js/wd-core.js .register("/webdesign/sw.js?v=<Stempel>", …)
```
**Das macht `node tools/seiten-stempel.mjs` jetzt mit** — dieselbe Zahl wie
die Seiten. Von Hand muss hier nichts mehr gezählt werden.
> **Warum das nötig wurde.** Dieser Abschnitt stand seit dem 26.08.2026 hier,
> mit Begründung und Messwerten. Gemessen am 30.09.2026 standen beide Zahlen
> seit dem **27.08.** auf `v64`, während **sieben Commits** die Dateien
> geändert hatten, die der Service Worker vorhält — darunter
> `/assets/css/main.css`. Ein Kommentar, der vor einem Fehler warnt,
> verhindert ihn nicht. `pruef-zwischenspeicher` prüft jetzt, dass beide
> Zahlen gleich und nicht älter als die vorgehaltenen Dateien sind.
**Warum zwei und nicht eine.** `CACHE_NAME` wirft den Zwischenspeicher
weg, sobald der Service Worker startet. Die Nummer in der Adresse sorgt
dafür, dass er überhaupt neu geladen wird — und das ist hier nicht
+12 -12
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DogiCrew 🩵 — DOGFATHER UNIVERSE</title>
<meta name="description" content="Unterstütze Dogfather dauerhaft mit der DogiCrew 🩵 und sammle exklusive Treueprämien — die Teddy-Kollektion entsteht in Kooperation mit VanVan." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,10 +20,10 @@
<meta name="twitter:description" content="Unterstütze Dogfather dauerhaft mit der DogiCrew 🩵 und sammle exklusive Treueprämien — die Teddy-Kollektion entsteht in Kooperation mit VanVan." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
<style>
/* =====================================================================
Preis-/Anmelde-Kiste — komplett neu gestaltet (Nutzer-Feedback
@@ -92,7 +92,7 @@
darunter sauber lesbar bleiben (Nutzer-Feedback: Logo lag vorher
über dem Namensfeld). */
position: absolute; top: 0; left: 0; right: 0; height: 420px; z-index: -1; pointer-events: none;
background-image: url('assets/img/logo-dogfather-transparent.png');
background-image: url('assets/img/logo-dogfather-transparent.png?v=202610012001');
background-repeat: no-repeat;
background-position: center 6%;
background-size: min(62%, 320px) auto;
@@ -345,12 +345,12 @@
.abo-vorteile li::before { content: "👑"; flex-shrink: 0; }
</style>
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-abonnieren.jpg');--page-bg-mobile:url('/assets/img/bg-abonnieren-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-abonnieren.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-abonnieren-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-abonnieren.jpg" alt="Dogfather Supporter-Abo" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-abonnieren.jpg?v=202610012001" alt="Dogfather Supporter-Abo" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="ab_eyebrow">👑 DogiCrew 🩵</span>
<h1 data-i18n="ab_h1">Mehr als ein Abo — deine Unterstützung wird Teil unserer Geschichte</h1>
@@ -604,9 +604,9 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-abonnieren.js?v=20260828e"></script>
<script src="assets/js/supporter.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-abonnieren.js?v=202610012001"></script>
<script src="assets/js/supporter.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
(function () {
const S = window.DogiSupporter;
+9 -9
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AGB — DogiCrew-Supporter-Abo — DOGFATHER UNIVERSE</title>
<meta name="description" content="Allgemeine Geschäftsbedingungen für das DogiCrew-Supporter-Abo von DOGFATHER UNIVERSE." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -21,10 +21,10 @@
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<meta name="robots" content="noindex" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body>
<div id="site-header"></div>
@@ -45,10 +45,10 @@
<p><strong>1. Geltungsbereich und Vertragspartner</strong><br>
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für den Abschluss und die Durchführung des
kostenpflichtigen „DogiCrew-Supporter-Abo" auf der Website dogfather-universe.com. Anbieter und
kostenpflichtigen „DogiCrew-Supporter-Abo“ auf der Website dogfather-universe.com. Anbieter und
Vertragspartner ist:<br>
Filipe Pereira Queiroz, 3, rue Arthur Thinnes, L-3919 Mondercange, Luxemburg —
<a href="mailto:[email protected]">[email protected]</a> (im Folgenden „Anbieter").
<a href="mailto:[email protected]">[email protected]</a> (im Folgenden „Anbieter“).
Näheres siehe <a href="kontakt.html">Impressum</a>.</p>
<p><strong>2. Leistungsbeschreibung</strong><br>
@@ -135,6 +135,6 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+9 -9
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aktion — DOGFATHER UNIVERSE</title>
<meta name="description" content="Detailansicht einer Aktion oder eines Projekts von Team Dogi." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,10 +20,10 @@
<meta name="twitter:description" content="Detailansicht einer Aktion oder eines Projekts von Team Dogi." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body>
<div id="site-header"></div>
@@ -52,9 +52,9 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/data-aktionen.js?v=20260828e"></script>
<script src="assets/js/i18n-aktion-detail.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-aktionen.js?v=202610012001"></script>
<script src="assets/js/i18n-aktion-detail.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(location.search);
+13 -1
View File
@@ -279,15 +279,27 @@ header.site-header {
radial-gradient(circle at 74% 72%, var(--brand-2, transparent), transparent 62%);
filter: blur(9px) saturate(1.25);
opacity: 0;
animation: nav-cta-aurora 11s ease-in-out infinite alternate;
pointer-events: none;
transition: opacity .3s ease;
}
/* DIE BEWEGUNG GEHOERT DORTHIN, WO DAS DING SICHTBAR WIRD
(30.09.2026).
Sie stand im Block darueber -- also lief sie ab dem Laden der
Seite, an JEDEM Navigationspunkt, endlos, und zu sehen war sie
erst beim Ueberfahren. Elf Sekunden Farbverlauf mal sechs
Navigationspunkte, auf jeder Seite der Website, fuer nichts.
Gefunden mit `pruef-bewegung` -- derselben Suche, die am selben
Tag den unsichtbaren Ladekreisel auf der Zugangswand gefunden hat.
Es faellt niemandem auf: Nichts stuerzt ab, nichts sieht falsch
aus. Es kostet nur Rechenzeit und Akku. */
.nav-group:hover > button::before,
.nav-group.open > button::before,
.nav-flat:hover::before,
.nav-flat.active::before {
opacity: .32;
animation: nav-cta-aurora 11s ease-in-out infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
.nav-group > button::before, .nav-flat::before { animation: none; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+2 -2
View File
@@ -40,8 +40,8 @@ const MODIS = [
photo: "assets/img/card-vanvan.jpg",
avatar: "assets/img/avatar-vanvan.jpg",
bio: {
de: `VanVan ist die rechte Hand von DogFather — und seine beste Freundin. Sie bekommt täglich die Neuigkeiten, Aufgaben und Infos direkt von ihm und verteilt bzw. delegiert sie im Team, oder organisiert gemeinsam mit Diene. Neben ihrer Rolle im Team ist sie außerdem Musik-Produzentin unter dem Namen „Van-Van”.`,
"de-CH": `VanVan isch die rechti Hand vo DogFather — und sini bescht Fründin. Sie übercho täglich die Nöiigkeite, Ufgabe und Infos direkt vo ihm und verteilt bzw. delegiert sie im Team, oder organisiert zäme mit Diene. Näbe ihrer Rolle im Team isch sie usserdem Musig-Produzentin unter em Name „Van-Van”.`,
de: `VanVan ist die rechte Hand von DogFather — und seine beste Freundin. Sie bekommt täglich die Neuigkeiten, Aufgaben und Infos direkt von ihm und verteilt bzw. delegiert sie im Team, oder organisiert gemeinsam mit Diene. Neben ihrer Rolle im Team ist sie außerdem Musik-Produzentin unter dem Namen „Van-Van“.`,
"de-CH": `VanVan isch die rechti Hand vo DogFather — und sini bescht Fründin. Sie übercho täglich die Nöiigkeite, Ufgabe und Infos direkt vo ihm und verteilt bzw. delegiert sie im Team, oder organisiert zäme mit Diene. Näbe ihrer Rolle im Team isch sie usserdem Musig-Produzentin unter em Name „Van-Van“.`,
en: `VanVan is DogFather's right hand — and his best friend. She gets all the news, tasks, and information directly from him every day and distributes or delegates them within the team, or organizes things together with Diene. Besides her role on the team, she's also a music producer under the name "Van-Van".`,
fr: `VanVan est le bras droit de DogFather — et sa meilleure amie. Elle reçoit chaque jour les nouvelles, les tâches et les informations directement de lui, et les répartit ou les délègue au sein de l'équipe, ou organise les choses avec Diene. En plus de son rôle dans l'équipe, elle est aussi productrice de musique sous le nom « Van-Van ».`,
pt: `VanVan é o braço direito do DogFather — e sua melhor amiga. Ela recebe diariamente as novidades, tarefas e informações diretamente dele e as distribui ou delega na equipe, ou organiza tudo junto com a Diene. Além de sua função na equipe, ela também é produtora musical sob o nome "Van-Van".`,
+8 -8
View File
@@ -20,18 +20,18 @@ window.I18N_PAGE = {
en: "Who is Casper?", fr: "Qui est Casper ?", pt: "Quem é o Casper?",
},
cas_wer_1: {
de: `Husky Casper ist einer von DogFathers treuen Begleitern und längst Teil der Marke geworden. Zusammen mit Kater Simba gehört er zur „Familie" hinter der Kamera.`,
"de-CH": `Husky Casper isch eine vo DogFathers treue Begleiter und scho lang Teil vo de Marke worde. Zäme mit em Kater Simba ghört er zur „Familie" hinter de Kamera.`,
en: `Husky Casper is one of DogFather's loyal companions and has long become part of the brand. Together with cat Simba, he belongs to the „family" behind the camera.`,
de: `Husky Casper ist einer von DogFathers treuen Begleitern und längst Teil der Marke geworden. Zusammen mit Kater Simba gehört er zur „Familie“ hinter der Kamera.`,
"de-CH": `Husky Casper isch eine vo DogFathers treue Begleiter und scho lang Teil vo de Marke worde. Zäme mit em Kater Simba ghört er zur „Familie“ hinter de Kamera.`,
en: `Husky Casper is one of DogFather's loyal companions and has long become part of the brand. Together with cat Simba, he belongs to the „family“ behind the camera.`,
fr: `Le husky Casper est l'un des fidèles compagnons de DogFather et fait depuis longtemps partie de la marque. Avec le chat Simba, il fait partie de la « famille » derrière la caméra.`,
pt: `O husky Casper é um dos fiéis companheiros de DogFather e há muito se tornou parte da marca. Junto com o gato Simba, ele faz parte da „família" por trás das câmeras.`,
pt: `O husky Casper é um dos fiéis companheiros de DogFather e há muito se tornou parte da marca. Junto com o gato Simba, ele faz parte da „família“ por trás das câmeras.`,
},
cas_wer_2: {
de: `Die gemeinsame Geschichte von DogFather und Casper gibt es sogar als Buch: <strong>„Hasidog &amp; Casper"</strong>.`,
"de-CH": `Die gmeinsam Gschicht vo DogFather und Casper git's sogar als Buech: <strong>„Hasidog &amp; Casper"</strong>.`,
en: `The shared story of DogFather and Casper even exists as a book: <strong>„Hasidog &amp; Casper"</strong>.`,
de: `Die gemeinsame Geschichte von DogFather und Casper gibt es sogar als Buch: <strong>„Hasidog &amp; Casper“</strong>.`,
"de-CH": `Die gmeinsam Gschicht vo DogFather und Casper git's sogar als Buech: <strong>„Hasidog &amp; Casper“</strong>.`,
en: `The shared story of DogFather and Casper even exists as a book: <strong>„Hasidog &amp; Casper“</strong>.`,
fr: `L'histoire commune de DogFather et Casper existe même sous forme de livre : <strong>« Hasidog &amp; Casper »</strong>.`,
pt: `A história compartilhada de DogFather e Casper existe até em forma de livro: <strong>„Hasidog &amp; Casper"</strong>.`,
pt: `A história compartilhada de DogFather e Casper existe até em forma de livro: <strong>„Hasidog &amp; Casper“</strong>.`,
},
cas_story_h2: {
de: "Casper – Ein Husky mit dem größten Herzen der Welt 🐾🩵",
+4 -4
View File
@@ -21,11 +21,11 @@ window.I18N_PAGE = {
en: "More Than Followers", fr: "Plus que des followers", pt: "Mais do que seguidores",
},
com_mehr_1: {
de: `Team Dogi soll niemals nur „DogFather und Follower" sein, sondern eine echte Gemeinschaft. Jeder verdient Spotlight 🩵`,
"de-CH": `Team Dogi söll niemals nur „DogFather und Follower" si, sondern e echti Gmeinschaft. Jede verdient Spotlight 🩵`,
en: `Team Dogi should never be just „DogFather and followers", but a real community. Everyone deserves the spotlight 🩵`,
de: `Team Dogi soll niemals nur „DogFather und Follower“ sein, sondern eine echte Gemeinschaft. Jeder verdient Spotlight 🩵`,
"de-CH": `Team Dogi söll niemals nur „DogFather und Follower“ si, sondern e echti Gmeinschaft. Jede verdient Spotlight 🩵`,
en: `Team Dogi should never be just „DogFather and followers“, but a real community. Everyone deserves the spotlight 🩵`,
fr: `Team Dogi ne doit jamais être seulement « DogFather et ses followers », mais une véritable communauté. Chacun mérite sa place sous les projecteurs 🩵`,
pt: `Team Dogi nunca deve ser apenas „DogFather e seguidores", mas sim uma comunidade de verdade. Todo mundo merece seu momento de destaque 🩵`,
pt: `Team Dogi nunca deve ser apenas „DogFather e seguidores“, mas sim uma comunidade de verdade. Todo mundo merece seu momento de destaque 🩵`,
},
com_mehr_2: {
de: "Discord ist das Community-Zentrum — hier trifft man sich, tauscht sich aus und ist Teil des Alltags von Team Dogi.",
+4 -4
View File
@@ -237,11 +237,11 @@ window.I18N_PAGE = {
en: "Side Projects & Passions", fr: "Projets annexes et passions", pt: "Projetos paralelos e paixões",
},
df_neben_1: {
de: `Eigene Songs und kreative Musikprojekte unter dem Namen „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>".`,
"de-CH": `Eigeni Songs und kreativi Musikprojekt under em Name „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>".`,
en: `Own songs and creative music projects under the name „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>".`,
de: `Eigene Songs und kreative Musikprojekte unter dem Namen „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>“.`,
"de-CH": `Eigeni Songs und kreativi Musikprojekt under em Name „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>“.`,
en: `Own songs and creative music projects under the name „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>“.`,
fr: `Chansons personnelles et projets musicaux créatifs sous le nom « <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a> ».`,
pt: `Músicas próprias e projetos musicais criativos sob o nome „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>".`,
pt: `Músicas próprias e projetos musicais criativos sob o nome „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>“.`,
},
df_neben_2: {
de: "Eigene Ideen für Tassen, Decken, Popsockets, Schlüsselanhänger, Sticker und weitere Produkte.",
+4 -4
View File
@@ -22,8 +22,8 @@ window.I18N_PAGE = {
en: "Who is HasiDog?", fr: "Qui est HasiDog ?", pt: "Quem é o HasiDog?",
},
hd_was_1: {
de: `HasiDog ist mehr als ein Spitzname — es ist die verspielte, familiäre Marke rund um DogFathers Community. Unter dem Namen „Hasidog" erscheint auch die eigene Musik auf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.`,
"de-CH": `HasiDog isch meh als en Spitzname — es isch die verspielt, familiäri Marke rund um DogFathers Community. Under em Name „Hasidog" chunt au eigeni Musig uf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a> use.`,
de: `HasiDog ist mehr als ein Spitzname — es ist die verspielte, familiäre Marke rund um DogFathers Community. Unter dem Namen „Hasidog“ erscheint auch die eigene Musik auf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.`,
"de-CH": `HasiDog isch meh als en Spitzname — es isch die verspielt, familiäri Marke rund um DogFathers Community. Under em Name „Hasidog“ chunt au eigeni Musig uf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a> use.`,
en: `HasiDog is more than a nickname — it's the playful, close-knit brand around DogFather's community. Original music also appears under the name "Hasidog" on <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.`,
fr: `HasiDog est bien plus qu'un surnom — c'est la marque ludique et familiale qui entoure la communauté de DogFather. Sous le nom « Hasidog » sort aussi une musique originale sur <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.`,
pt: `HasiDog é mais do que um apelido — é a marca divertida e familiar em torno da comunidade do DogFather. Sob o nome "Hasidog" também sai música original no <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.`,
@@ -394,7 +394,7 @@ window.I18N_PAGE = {
},
hd_schlaf_h2: {
de: `„Schlaft schön." 🩵`, "de-CH": `„Schlafed schön." 🩵`,
de: `„Schlaft schön.“ 🩵`, "de-CH": `„Schlafed schön.“ 🩵`,
en: `"Sleep well." 🩵`, fr: `« Faites de beaux rêves. » 🩵`, pt: `"Durmam bem." 🩵`,
},
hd_schlaf_lead: {
@@ -416,7 +416,7 @@ window.I18N_PAGE = {
en: "🐺 Casper", fr: "🐺 Casper", pt: "🐺 Casper",
},
hd_familie_h3_buch: {
de: `📖 Das Buch — „HasiDog & Casper"`, "de-CH": `📖 S Buech — „HasiDog & Casper"`,
de: `📖 Das Buch — „HasiDog & Casper“`, "de-CH": `📖 S Buech — „HasiDog & Casper“`,
en: `📖 The book — "HasiDog & Casper"`, fr: `📖 Le livre — « HasiDog & Casper »`, pt: `📖 O livro — "HasiDog & Casper"`,
},
hd_familie_h3_com: {
+1 -1
View File
@@ -230,7 +230,7 @@ window.I18N_PAGE = {
en: "November 2025", fr: "Novembre 2025", pt: "Novembro de 2025",
},
zr_ev13_h3: {
de: `Das Buch „HasiDog &amp; Casper"`, "de-CH": `Ds Buech „HasiDog &amp; Casper"`,
de: `Das Buch „HasiDog &amp; Casper“`, "de-CH": `Ds Buech „HasiDog &amp; Casper“`,
en: `The Book "HasiDog &amp; Casper"`, fr: `Le livre « HasiDog &amp; Casper »`, pt: `O livro "HasiDog &amp; Casper"`,
},
zr_ev13_p: {
+1 -1
View File
@@ -863,7 +863,7 @@
Stand haben. Die Nummer wird zusammen mit CACHE_NAME in sw.js
hochgezaehlt -- beide gehoeren zusammen. */
navigator.serviceWorker
.register("/webdesign/sw.js?v=64", { scope: "/webdesign/" })
.register("/webdesign/sw.js?v=202610012001", { scope: "/webdesign/" })
.catch(function () { /* still schlucken: ohne SW funktioniert alles weiter */ });
});
}
+11 -11
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Streamer gesucht — DOGFATHER UNIVERSE</title>
<meta name="description" content="Du bist Streamer und suchst einen Manager? Bewirb dich als Creator bei Spicy Media." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Du bist Streamer und suchst einen Manager? Bewirb dich als Creator bei Spicy Media." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-creator.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-creator-mobile.jpg');">
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-creator.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-bewerben-creator-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-bewerben-creator.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-bewerben-creator.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="bwc_eyebrow">Manager DogFather — Agentur</span>
<h1 data-i18n="bwc_h1">Streamer? Ich such dich als Creator.</h1>
@@ -71,8 +71,8 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/forms.js?v=20260828e"></script>
<script src="assets/js/i18n-bewerben-creator.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/forms.js?v=202610012001"></script>
<script src="assets/js/i18n-bewerben-creator.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+11 -11
View File
@@ -8,8 +8,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kooperation anfragen — DOGFATHER UNIVERSE</title>
<meta name="description" content="Kooperationsanfrage an Manager DogFather / Spicy Media." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -23,17 +23,17 @@
<meta name="twitter:description" content="Kooperationsanfrage an Manager DogFather / Spicy Media." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-mobile.jpg');">
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-bewerben-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-bewerben.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-bewerben.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="bwk_eyebrow">Manager DogFather — Agentur</span>
<h1 data-i18n="bwk_h1">Kooperation anfragen</h1>
@@ -69,8 +69,8 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/forms.js?v=20260828e"></script>
<script src="assets/js/i18n-bewerben-kooperation.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/forms.js?v=202610012001"></script>
<script src="assets/js/i18n-bewerben-kooperation.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+11 -11
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modi werden — DOGFATHER UNIVERSE</title>
<meta name="description" content="Werde Modi bei Team Dogi — DogFathers persönliches Community-Team." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Werde Modi bei Team Dogi — DogFathers persönliches Community-Team." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-modi.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-modi-mobile.jpg');">
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-modi.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-bewerben-modi-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-bewerben-modi.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-bewerben-modi.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="bwm_eyebrow">🩵 Team Dogi — Community</span>
<h1 data-i18n="bwm_h1">Werde Modi bei Team Dogi</h1>
@@ -80,8 +80,8 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/forms.js?v=20260828e"></script>
<script src="assets/js/i18n-bewerben-modi.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/forms.js?v=202610012001"></script>
<script src="assets/js/i18n-bewerben-modi.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+11 -11
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scout werden — DOGFATHER UNIVERSE</title>
<meta name="description" content="Werde Scout für DogFather bei der Spicy Media Agentur — finde neue Talente." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Werde Scout für DogFather bei der Spicy Media Agentur — finde neue Talente." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-scout.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-scout-mobile.jpg');">
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-scout.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-bewerben-scout-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-bewerben-scout.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-bewerben-scout.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="bws_eyebrow">Manager DogFather — Agentur</span>
<h1 data-i18n="bws_h1">Werde Scout für DogFather</h1>
@@ -72,8 +72,8 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/forms.js?v=20260828e"></script>
<script src="assets/js/i18n-bewerben-scout.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/forms.js?v=202610012001"></script>
<script src="assets/js/i18n-bewerben-scout.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+12 -12
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bewerben — DOGFATHER UNIVERSE</title>
<meta name="description" content="Werde Teil vom Team: Modi bei Team Dogi, Scout oder Creator bei Spicy Media, oder Kooperation anfragen." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Werde Teil vom Team: Modi bei Team Dogi, Scout oder Creator bei Spicy Media, oder Kooperation anfragen." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-split.jpg');--page-bg-mobile:url('/assets/img/bg-bewerben-split-mobile.jpg');">
<body class="mit-hintergrund bewerben-seite" style="--page-bg:url('/assets/img/bg-bewerben-split.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-bewerben-split-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-bewerben-split.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-bewerben-split.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="bw_eyebrow">Werde Teil vom Team</span>
<h1 data-i18n="bw_h1">Community oder Agentur — such dir deinen&nbsp;Weg</h1>
@@ -42,7 +42,7 @@
<div class="container">
<div class="bewerben-showcase">
<div class="bewerben-poster bewerben-poster-blau" aria-hidden="true">
<img src="assets/img/bewerben-poster-dogfather.jpg" alt="" loading="lazy" />
<img src="assets/img/bewerben-poster-dogfather.jpg?v=202610012001" alt="" loading="lazy" />
</div>
<div class="bewerben-split">
@@ -78,7 +78,7 @@
</div>
<div class="bewerben-poster bewerben-poster-rot" aria-hidden="true">
<img src="assets/img/bewerben-poster-spicymedia.jpg" alt="" loading="lazy" />
<img src="assets/img/bewerben-poster-spicymedia.jpg?v=202610012001" alt="" loading="lazy" />
</div>
</div>
</div>
@@ -86,7 +86,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-bewerben.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-bewerben.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+17 -17
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Casper — DOGFATHER UNIVERSE</title>
<meta name="description" content="Der Husky an DogFathers Seite — das tierische Gesicht der HasiDog-Welt." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Der Husky an DogFathers Seite — das tierische Gesicht der HasiDog-Welt." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-casper.jpg');--page-bg-mobile:url('/assets/img/bg-casper-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-casper.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-casper-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-casper.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-casper.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="cas_eyebrow">Welt 2 — HasiDog</span>
<h1>Casper 🐾</h1>
@@ -41,12 +41,12 @@
<section class="section-tight">
<div class="container grid grid-2" style="align-items:center;">
<div class="collage-photo" style="aspect-ratio:4/3;border-radius:var(--radius);">
<img src="assets/img/casper-main.jpg" alt="Casper — Husky im Sonnenuntergang" style="width:100%;height:100%;object-fit:cover;object-position:center 30%;" />
<img src="assets/img/casper-main.jpg?v=202610012001" alt="Casper — Husky im Sonnenuntergang" style="width:100%;height:100%;object-fit:cover;object-position:center 30%;" />
</div>
<div>
<h2 data-i18n="cas_wer_h2">Wer ist Casper?</h2>
<p data-i18n="cas_wer_1">Husky Casper ist einer von DogFathers treuen Begleitern und längst Teil der Marke geworden. Zusammen mit Kater Simba gehört er zur „Familie" hinter der Kamera.</p>
<p data-i18n="cas_wer_2">Die gemeinsame Geschichte von DogFather und Casper gibt es sogar als Buch: <strong>„Hasidog &amp; Casper"</strong>.</p>
<p data-i18n="cas_wer_1">Husky Casper ist einer von DogFathers treuen Begleitern und längst Teil der Marke geworden. Zusammen mit Kater Simba gehört er zur „Familie“ hinter der Kamera.</p>
<p data-i18n="cas_wer_2">Die gemeinsame Geschichte von DogFather und Casper gibt es sogar als Buch: <strong>„Hasidog &amp; Casper“</strong>.</p>
</div>
</div>
</section>
@@ -73,10 +73,10 @@
<div class="container">
<h2 class="text-center" data-i18n="cas_galerie_h2">Casper-Galerie</h2>
<div class="grid grid-4">
<div class="collage-photo frame-lila" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-3.jpg" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:center 20%;" /></div>
<div class="collage-photo frame-silver" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-4.jpg" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:42% center;" /></div>
<div class="collage-photo frame-gold" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/dogfather-casper-2.jpg" alt="DogFather und Casper" style="width:100%;height:100%;object-fit:cover;object-position:center 38%;" /></div>
<div class="collage-photo frame-tuerkis" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/hasidog-casper-book.jpg" alt="HasiDog &amp; Casper Buch-Illustration" style="width:100%;height:100%;object-fit:cover;" /></div>
<div class="collage-photo frame-lila" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-3.jpg?v=202610012001" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:center 20%;" /></div>
<div class="collage-photo frame-silver" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-4.jpg?v=202610012001" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:42% center;" /></div>
<div class="collage-photo frame-gold" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/dogfather-casper-2.jpg?v=202610012001" alt="DogFather und Casper" style="width:100%;height:100%;object-fit:cover;object-position:center 38%;" /></div>
<div class="collage-photo frame-tuerkis" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/hasidog-casper-book.jpg?v=202610012001" alt="HasiDog &amp; Casper Buch-Illustration" style="width:100%;height:100%;object-fit:cover;" /></div>
</div>
<div class="btn-row" style="justify-content:center;">
<a class="btn btn-outline" href="medien-casper.html#fotos" data-i18n="cas_galerie_btn">Zur ganzen Galerie</a>
@@ -86,7 +86,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-casper.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-casper.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+12 -12
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Die Community — DOGFATHER UNIVERSE</title>
<meta name="description" content="Team Dogi ist Familie — die Community rund um DogFather und HasiDog." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Team Dogi ist Familie — die Community rund um DogFather und HasiDog." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-community.jpg');--page-bg-mobile:url('/assets/img/bg-community-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-community.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-community-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-community.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-community.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="com_eyebrow">Welt 2 — HasiDog</span>
<h1 data-i18n="com_h1">Die Community</h1>
@@ -42,12 +42,12 @@
<div class="container grid grid-2" style="align-items:center;">
<div>
<h2 data-i18n="com_mehr_h2">Mehr als Follower</h2>
<p data-i18n="com_mehr_1">Team Dogi soll niemals nur „DogFather und Follower" sein, sondern eine echte Gemeinschaft. Jeder verdient Spotlight 🩵</p>
<p data-i18n="com_mehr_1">Team Dogi soll niemals nur „DogFather und Follower“ sein, sondern eine echte Gemeinschaft. Jeder verdient Spotlight 🩵</p>
<p data-i18n="com_mehr_2">Discord ist das Community-Zentrum — hier trifft man sich, tauscht sich aus und ist Teil des Alltags von Team Dogi.</p>
<a class="btn btn-primary" href="links.html" data-i18n="com_discord_btn">Discord beitreten</a>
</div>
<div class="collage-photo" style="aspect-ratio:3/4;border-radius:var(--radius);">
<img src="assets/img/community-team-dogi.jpg" alt="Diene, Ghost, VanVan und Dogi bei einem gemeinsamen Treffen von Team Dogi" style="width:100%;height:100%;object-fit:cover;object-position:center 25%;" />
<img src="assets/img/community-team-dogi.jpg?v=202610012001" alt="Diene, Ghost, VanVan und Dogi bei einem gemeinsamen Treffen von Team Dogi" style="width:100%;height:100%;object-fit:cover;object-position:center 25%;" />
</div>
</div>
</section>
@@ -85,7 +85,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-community.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-community.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+12 -12
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Creator im Team — DOGFATHER UNIVERSE</title>
<meta name="description" content="Content Creator, die Teil von DogFather / Spicy Media sind." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Content Creator, die Teil von DogFather / Spicy Media sind." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-creator.jpg');--page-bg-mobile:url('/assets/img/bg-creator-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-creator.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-creator-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-creator.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-creator.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="crt_eyebrow">Manager DogFather — Team</span>
<h1 data-i18n="crt_h1" data-site-text-key="seitentitel_creator">Creator im Team</h1>
@@ -61,9 +61,9 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/data-creator.js?v=20260828e"></script>
<script src="assets/js/i18n-creator.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-creator.js?v=202610012001"></script>
<script src="assets/js/i18n-creator.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
document.addEventListener("DOMContentLoaded", async () => {
const grid = document.getElementById("creator-grid");
@@ -129,7 +129,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="avatar">${(c.avatar || c.photo) ? `<img src="${c.avatar || c.photo}" alt="${c.name}" loading="lazy">` : `<span class="avatar-initial">${c.name.charAt(0).toUpperCase()}</span>`}</div>
<h3>${c.name}</h3>
<div class="role">${feld(c.role)}</div>
${quoteText ? `<p class="quote">„${quoteText}"</p>` : ""}
${quoteText ? `<p class="quote">„${quoteText}“</p>` : ""}
<span class="btn btn-outline" style="margin-top:.7rem;pointer-events:none;">${uebersetzen("crt_profil_btn")}</span>
</a>`;
}).join("");
+12 -12
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Die Person, die DogFather erschuf — DOGFATHER UNIVERSE</title>
<meta name="description" content="Wer ist DogFather wirklich? Persönlichkeit, Werte und die Geschichte hinter dem Content." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Wer ist DogFather wirklich? Persönlichkeit, Werte und die Geschichte hinter dem Content." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-dogfather-person.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-person-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-dogfather-person.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-dogfather-person-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-dogfather-person.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-dogfather-person.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="df_eyebrow">Welt 1 — DogFather</span>
<h1 data-i18n="df_h1">Die Person, die DogFather erschuf!</h1>
@@ -41,7 +41,7 @@
<section class="section-tight">
<div class="container grid grid-2" style="align-items:center;">
<div class="collage-photo" style="aspect-ratio:1/1;border-radius:var(--radius);">
<img src="assets/img/dogfather-main.jpg" alt="DogFather" style="width:100%;height:100%;object-fit:cover;object-position:center 28%;" />
<img src="assets/img/dogfather-main.jpg?v=202610012001" alt="DogFather" style="width:100%;height:100%;object-fit:cover;object-position:center 28%;" />
</div>
<div>
<h2 data-i18n="df_wer_h2">Wer ist Filipe?</h2>
@@ -137,7 +137,7 @@
<div class="container">
<h2 class="text-center" data-i18n="df_neben_h2">Nebenprojekte &amp; Leidenschaften</h2>
<div class="grid grid-3">
<div class="card"><h3 data-i18n="df_neben_h3_1">🎵 Musik-Produzent</h3><p data-i18n="df_neben_1">Eigene Songs und kreative Musikprojekte unter dem Namen „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>".</p></div>
<div class="card"><h3 data-i18n="df_neben_h3_1">🎵 Musik-Produzent</h3><p data-i18n="df_neben_1">Eigene Songs und kreative Musikprojekte unter dem Namen „<a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">HasiDog</a>“.</p></div>
<div class="card"><h3 data-i18n="df_neben_h3_2">🧸 Merchandise-Creator</h3><p data-i18n="df_neben_2">Eigene Ideen für Tassen, Decken, Popsockets, Schlüsselanhänger, Sticker und weitere Produkte.</p></div>
<div class="card"><h3 data-i18n="df_neben_h3_3">📖 Autor</h3><p data-i18n="df_neben_3">Ein eigenes Buchprojekt rund um HasiDog und Casper.</p></div>
<div class="card"><h3 data-i18n="df_neben_h3_4">🎨 Designer &amp; Ideenentwickler</h3><p data-i18n="df_neben_4">Webseiten, Grafiken, Stream-Designs und neue Markenideen entstehen aus seiner eigenen kreativen Welt.</p></div>
@@ -159,7 +159,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-dogfather.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-dogfather.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+8 -8
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zugang — DOGFATHER UNIVERSE</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<!-- BUGFIX (19.08.2026, Nutzer-Report vom Handy: "hab es installiert und klappt nicht"):
Diese Seite ist der ERSTE Bildschirm, den man ueberhaupt zu sehen bekommt (ohne gueltige
Sitzung leitet gate.js JEDEN Aufruf hierher um) -- also auch der Bildschirm, von dem aus
@@ -22,12 +22,12 @@
statt des Logos. Bewusst eine eigene Datei OHNE Transparenz: iOS ersetzt Alpha durch
Schwarz, und icon-512.png hat transparente Ecken (nachgemessen) -> haette schwarze
Ecken gegeben. -->
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DogFather" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<style>
/* =====================================================================
Zugangsseite — bewusst kein braves Standard-Login-Formular, sondern
@@ -200,8 +200,8 @@
border: 2px solid rgba(143,217,234,.6);
box-shadow: 0 0 18px rgba(143,217,234,.4), inset 0 0 10px rgba(255,255,255,.08);
}
.gate-card-icon--dogi { background-image: url('assets/img/vw-gate-dogi.jpg'); }
.gate-card-icon--vanvan { background-image: url('assets/img/vw-gate-vanvan.jpg'); }
.gate-card-icon--dogi { background-image: url('assets/img/vw-gate-dogi.jpg?v=202610012001'); }
.gate-card-icon--vanvan { background-image: url('assets/img/vw-gate-vanvan.jpg?v=202610012001'); }
.gate-card--vanvan .gate-card-icon { border-color: rgba(255,143,198,.65); }
.gate-card-name { font-size: 1.05rem; font-weight: 800; margin: 0 0 .8rem; color: var(--text); }
.gate-card--vanvan .gate-card-name { color: #ffd1e8; }
@@ -233,7 +233,7 @@
linear-gradient(110deg, #6ee7b7, #34d399, #d4af37, #6ee7b7) border-box;
box-shadow: 0 30px 70px -25px rgba(0,0,0,.75), 0 0 50px -20px rgba(52,211,153,.35);
}
.gate-card-icon--diene { background-image: url('assets/img/avatar-diene.jpg'); }
.gate-card-icon--diene { background-image: url('assets/img/avatar-diene.jpg?v=202610012001'); }
.gate-card--diene .gate-card-icon { border-color: rgba(110,231,183,.65); }
.gate-card--diene .gate-card-name { color: #c7f5e3; }
.gate-card--diene input:focus { border-color: rgba(52,211,153,.7); box-shadow: 0 0 0 4px rgba(52,211,153,.16); }
@@ -299,7 +299,7 @@
<div class="gate-stage">
<div class="gate-logo-wrap" aria-hidden="true">
<div class="gate-logo-glow"></div>
<img class="gate-logo-img" src="assets/img/logo-dogfather-transparent.png" alt="" width="472" height="476" />
<img class="gate-logo-img" src="assets/img/logo-dogfather-transparent.png?v=202610012001" alt="" width="472" height="476" />
</div>
<div class="card gate-box">
+16 -16
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HasiDog-Welt — DOGFATHER UNIVERSE</title>
<meta name="description" content="HasiDog: Maskottchen, Musikprojekt und die verspielte, familiäre Seite von Team Dogi." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="HasiDog: Maskottchen, Musikprojekt und die verspielte, familiäre Seite von Team Dogi." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-hasidog.jpg');--page-bg-mobile:url('/assets/img/bg-hasidog-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-hasidog.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-hasidog-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-hasidog.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-hasidog.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="hd_eyebrow">Welt 2 — HasiDog</span>
<h1 data-i18n="hd_h1">Willkommen in der HasiDog-Welt</h1>
@@ -41,11 +41,11 @@
<section class="section-tight">
<div class="container grid grid-2" style="align-items:center;">
<div class="collage-photo" style="aspect-ratio:1/1;border-radius:var(--radius);background:#fff;">
<img src="assets/img/sticker-hasidog-casper.png" alt="HasiDog &amp; Casper Sticker" style="width:100%;height:100%;object-fit:contain;padding:1rem;" />
<img src="assets/img/sticker-hasidog-casper.png?v=202610012001" alt="HasiDog &amp; Casper Sticker" style="width:100%;height:100%;object-fit:contain;padding:1rem;" />
</div>
<div>
<h2 data-i18n="hd_was_h2">Wer ist HasiDog?</h2>
<p data-i18n="hd_was_1">HasiDog ist mehr als ein Spitzname — es ist die verspielte, familiäre Marke rund um DogFathers Community. Unter dem Namen „Hasidog" erscheint auch die eigene Musik auf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.</p>
<p data-i18n="hd_was_1">HasiDog ist mehr als ein Spitzname — es ist die verspielte, familiäre Marke rund um DogFathers Community. Unter dem Namen „Hasidog“ erscheint auch die eigene Musik auf <a href="https://open.spotify.com/intl-de/artist/2INiE1VVJGUQdEURsltuJU" target="_blank" rel="noopener">Spotify</a>.</p>
<div class="btn-row">
<a class="btn-holo" href="streamer.html"><span class="btn-holo-spark" aria-hidden="true">💎</span><span class="btn-holo-text" data-i18n="hd_btn_erschaffer">Erschaffer</span><span class="btn-holo-spark" aria-hidden="true">💎</span></a>
</div>
@@ -67,7 +67,7 @@
<p style="text-align:right;"><strong>– HasiDog 🐰</strong></p>
</div>
<div class="collage-photo frame-lila" style="aspect-ratio:auto;border-radius:var(--radius);">
<img src="assets/img/char-hasidog.jpg" alt="HasiDog — grauer Hase mit schwarzem Hoodie und Cap" style="width:100%;height:auto;display:block;" />
<img src="assets/img/char-hasidog.jpg?v=202610012001" alt="HasiDog — grauer Hase mit schwarzem Hoodie und Cap" style="width:100%;height:auto;display:block;" />
</div>
</div>
</section>
@@ -102,7 +102,7 @@
<p class="quote" data-i18n="hd_familie_casper_quote" style="margin-top:.6rem;">Zwei blaue Augen, ein riesiges Herz und immer mitten im Abenteuer.</p>
</div>
<div class="card">
<h3 data-i18n="hd_familie_h3_buch">📖 Das Buch — „HasiDog &amp; Casper"</h3>
<h3 data-i18n="hd_familie_h3_buch">📖 Das Buch — „HasiDog &amp; Casper“</h3>
<p data-i18n="hd_familie_buch">Die Geschichte von HasiDog und Casper wird auch in Buchform erzählt. Gemeinsam erleben sie Abenteuer, überwinden Herausforderungen und zeigen, wie wichtig Freundschaft, Mut und Zusammenhalt sind. 📚✨ Das Buch verbindet Fantasie, Humor und emotionale Momente zu einer Geschichte für alle, die außergewöhnliche Figuren und echte Freundschaft lieben.</p>
</div>
<div class="card">
@@ -176,10 +176,10 @@
<section class="section-tight">
<div class="container text-center">
<h2 data-i18n="hd_schlaf_h2">„Schlaft schön." 🩵</h2>
<h2 data-i18n="hd_schlaf_h2">„Schlaft schön.“ 🩵</h2>
<p class="lead" data-i18n="hd_schlaf_lead" style="max-width:50ch;margin:0 auto 1.4rem;">Eine Szene aus der HasiDog-&amp;-Casper-Welt, wie sie im Buch lebendig wird.</p>
<div class="collage-photo frame-lila" style="max-width:640px;margin:0 auto;border-radius:var(--radius);aspect-ratio:1200/654;">
<img src="assets/img/hasidog-casper-book.jpg" alt="Schlaft schön — Team Dogi Illustration" style="width:100%;height:100%;object-fit:cover;" />
<img src="assets/img/hasidog-casper-book.jpg?v=202610012001" alt="Schlaft schön — Team Dogi Illustration" style="width:100%;height:100%;object-fit:cover;" />
</div>
</div>
</section>
@@ -187,7 +187,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-hasidog.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-hasidog.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+23 -23
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DOGFATHER UNIVERSE — Streamer. Community. HasiDog. Manager.</title>
<meta name="description" content="DOGFATHER UNIVERSE: die Heimat von DogFather als Streamer, der HasiDog-Community und Manager DogFather bei Spicy Media." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,10 +20,10 @@
<meta name="twitter:description" content="DOGFATHER UNIVERSE: die Heimat von DogFather als Streamer, der HasiDog-Community und Manager DogFather bei Spicy Media." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="start-seite">
<div id="site-header"></div>
@@ -42,7 +42,7 @@
Bildschirm ist; ein CSS-Hintergrund kann das nicht, weil die Kante
dort je nach Fensterformat woanders liegt. -->
<div class="hero-artwork" aria-hidden="true">
<img class="hero-artwork-scharf" src="assets/img/universe-trio.jpg?v=20260828e"
<img class="hero-artwork-scharf" src="assets/img/universe-trio.jpg?v=202610012001"
width="1600" height="900" alt="" fetchpriority="high" decoding="async" />
</div>
@@ -170,7 +170,7 @@
<div class="char-showcase">
<a class="char-card dogfather" href="streamer.html">
<img src="assets/img/char-dogfather.jpg" width="599" height="1212"
<img src="assets/img/char-dogfather.jpg?v=202610012001" width="599" height="1212"
alt="DogFather — Husky mit blauem Hoodie und Cap" />
<span class="char-meta">
<span class="char-name">DogFather</span>
@@ -179,7 +179,7 @@
</span>
</a>
<a class="char-card hasidog" href="hasidog.html">
<img src="assets/img/char-hasidog.jpg" width="599" height="1212"
<img src="assets/img/char-hasidog.jpg?v=202610012001" width="599" height="1212"
alt="HasiDog — grauer Hase mit schwarzem Hoodie und Cap" />
<span class="char-meta">
<span class="char-name">HasiDog</span>
@@ -198,23 +198,23 @@
<h2>DogFather &amp; Casper</h2>
<div class="collage-wrap" id="collage-wrap">
<div class="collage-item">
<div class="collage-photo"><img class="collage-img" data-fallback="DogFather" src="assets/img/dogfather-portrait.jpg" alt="DogFather" style="object-position:center top;"></div>
<div class="collage-photo"><img class="collage-img" data-fallback="DogFather" src="assets/img/dogfather-portrait.jpg?v=202610012001" alt="DogFather" style="object-position:center top;"></div>
<p class="collage-caption">DogFather 🩵</p>
</div>
<div class="collage-item">
<div class="collage-photo frame-lila"><img class="collage-img" data-fallback="Casper" src="assets/img/casper-1.jpg" alt="Casper" style="object-position:center top;"></div>
<div class="collage-photo frame-lila"><img class="collage-img" data-fallback="Casper" src="assets/img/casper-1.jpg?v=202610012001" alt="Casper" style="object-position:center top;"></div>
<p class="collage-caption">Casper 🐾</p>
</div>
<div class="collage-item">
<div class="collage-photo frame-silver"><img class="collage-img" data-fallback="DogFather &amp; Casper" src="assets/img/dogfather-casper-1.jpg" alt="DogFather und Casper zusammen" style="object-position:center 18%;"></div>
<div class="collage-photo frame-silver"><img class="collage-img" data-fallback="DogFather &amp; Casper" src="assets/img/dogfather-casper-1.jpg?v=202610012001" alt="DogFather und Casper zusammen" style="object-position:center 18%;"></div>
<p class="collage-caption" data-i18n="idx_collage_cap_team">Team 🩵🐾</p>
</div>
<div class="collage-item">
<div class="collage-photo frame-gold"><img class="collage-img" data-fallback="Casper" src="assets/img/casper-2.jpg" alt="Casper"></div>
<div class="collage-photo frame-gold"><img class="collage-img" data-fallback="Casper" src="assets/img/casper-2.jpg?v=202610012001" alt="Casper"></div>
<p class="collage-caption" data-i18n="idx_collage_cap_huskylife">Husky-Life</p>
</div>
<div class="collage-item">
<div class="collage-photo frame-tuerkis"><img class="collage-img" data-fallback="DogFather &amp; Casper" src="assets/img/dogfather-casper-2.jpg" alt="DogFather und Casper Nase an Nase"></div>
<div class="collage-photo frame-tuerkis"><img class="collage-img" data-fallback="DogFather &amp; Casper" src="assets/img/dogfather-casper-2.jpg?v=202610012001" alt="DogFather und Casper Nase an Nase"></div>
<p class="collage-caption" data-i18n="idx_collage_cap_nose">Nase an Nase 🩵</p>
</div>
</div>
@@ -277,10 +277,10 @@
<div class="container">
<h2 class="text-center" data-i18n="idx_galerie_h2">Impressionen</h2>
<div class="grid grid-4">
<div class="collage-photo" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-3.jpg" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:center 20%;" /></div>
<div class="collage-photo frame-lila" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/card-vanvan.jpg" alt="VanVan" style="width:100%;height:100%;object-fit:cover;" /></div>
<div class="collage-photo frame-silver" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-4.jpg" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:42% center;" /></div>
<div class="collage-photo frame-gold" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/card-dogfather.jpg" alt="DogFather" style="width:100%;height:100%;object-fit:cover;" /></div>
<div class="collage-photo" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-3.jpg?v=202610012001" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:center 20%;" /></div>
<div class="collage-photo frame-lila" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/card-vanvan.jpg?v=202610012001" alt="VanVan" style="width:100%;height:100%;object-fit:cover;" /></div>
<div class="collage-photo frame-silver" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/casper-4.jpg?v=202610012001" alt="Casper" style="width:100%;height:100%;object-fit:cover;object-position:42% center;" /></div>
<div class="collage-photo frame-gold" style="aspect-ratio:1;border-radius:var(--radius);"><img src="assets/img/card-dogfather.jpg?v=202610012001" alt="DogFather" style="width:100%;height:100%;object-fit:cover;" /></div>
</div>
<div class="btn-row" style="justify-content:center;">
<a class="btn btn-outline" href="medien-dogfather.html" data-i18n="idx_galerie_btn">Zur ganzen Galerie</a>
@@ -293,7 +293,7 @@
<div class="container text-center">
<span class="eyebrow" data-i18n="idx_testimonial_eyebrow">Was andere sagen</span>
<div class="card" style="max-width:640px;margin:1rem auto 0;">
<p class="quote" data-i18n="idx_testimonial_quote" style="font-size:1.15rem;">„Hier stehen bald echte Stimmen aus der Community. 🩵"</p>
<p class="quote" data-i18n="idx_testimonial_quote" style="font-size:1.15rem;">„Hier stehen bald echte Stimmen aus der Community. 🩵“</p>
<p class="small muted" data-i18n="idx_testimonial_attrib">— Team Dogi</p>
</div>
<div class="btn-row" style="justify-content:center;">
@@ -319,10 +319,10 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/data-modis.js?v=20260828e"></script>
<script src="assets/js/data-scouts.js?v=20260828e"></script>
<script src="assets/js/i18n-index.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-modis.js?v=202610012001"></script>
<script src="assets/js/data-scouts.js?v=202610012001"></script>
<script src="assets/js/i18n-index.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
// Gleiche URL wie in assets/js/forms.js / postfach.html — läuft seit
// 05.08.2026 auf dem eigenen Server statt auf Cloudflare Workers.
+10 -10
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kontakt &amp; Rechtliches — DOGFATHER UNIVERSE</title>
<meta name="description" content="Kontakt, Impressum und Datenschutz von DOGFATHER UNIVERSE." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,10 +20,10 @@
<meta name="twitter:description" content="Kontakt, Impressum und Datenschutz von DOGFATHER UNIVERSE." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body>
<div id="site-header"></div>
@@ -64,7 +64,7 @@
E-Mail: <a href="mailto:[email protected]">[email protected]</a><br><br>
<strong>EU-Streitschlichtung:</strong> Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a>. Unsere E-Mail-Adresse finden Sie oben im Impressum.<br><br>
<strong>Verbraucherstreitbeilegung:</strong> Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.<br><br>
DOGFATHER UNIVERSE ist ein persönliches Content-/Community-Projekt von Filipe Pereira Queiroz (Künstlername „Dogfather"). Es besteht keine Verbindung dieser Angaben zu Namen weiterer auf der Seite gezeigter Personen (z. B. Team-/Community-Mitglieder), die ausschließlich mit ihren Spitz-/Künstlernamen auftreten.
DOGFATHER UNIVERSE ist ein persönliches Content-/Community-Projekt von Filipe Pereira Queiroz (Künstlername „Dogfather“). Es besteht keine Verbindung dieser Angaben zu Namen weiterer auf der Seite gezeigter Personen (z. B. Team-/Community-Mitglieder), die ausschließlich mit ihren Spitz-/Künstlernamen auftreten.
</p>
</div>
</section>
@@ -90,7 +90,7 @@
<li>Lokaler Browser-Speicher (localStorage/sessionStorage) für Login-Sitzungen im Supporter-/DogiCrew-Bereich sowie im internen Verwaltungsbereich — verbleibt ausschließlich auf deinem Gerät und wird nicht an Dritte weitergegeben.</li>
</ul>
<p><strong>4. „Weiter mit Google" (Google Sign-In)</strong><br>
<p><strong>4. „Weiter mit Google“ (Google Sign-In)</strong><br>
Im DogiCrew-/Supporter-Bereich kann optional die Anmeldung über Google genutzt werden. Dabei findet eine Datenübertragung an Google Ireland Limited (Gordon House, Barrow Street, Dublin 4, Irland) statt. Es werden nur Name, E-Mail-Adresse und Profilbild übermittelt, um ein Konto zu erstellen bzw. zuzuordnen. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) durch aktive Nutzung des Buttons. Näheres: <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">policies.google.com/privacy</a>.</p>
<p><strong>5. Zahlungsabwicklung (PayPal)</strong><br>
@@ -127,7 +127,7 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-kontakt.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-kontakt.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+13 -13
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Alle Links — DOGFATHER UNIVERSE</title>
<meta name="description" content="Alle Social-Media- und Musik-Links von DogFather an einem Ort — plus QR-Codes." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,10 +20,10 @@
<meta name="twitter:description" content="Alle Social-Media- und Musik-Links von DogFather an einem Ort — plus QR-Codes." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
<style>
/* Rabattcodes: an ein KONTO gekoppelt, seit 08.09.2026.
@@ -68,12 +68,12 @@
.rabatt-gesperrt-knoepfe { display: flex; gap: .6rem; flex-wrap: wrap; }
</style>
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-links-seite.jpg');--page-bg-mobile:url('/assets/img/bg-links-seite-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-links-seite.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-links-seite-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-links-seite.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-links-seite.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="lk_eyebrow">🩵 Alle Links</span>
<h1 data-i18n="lk_h1">Alles an einem Ort</h1>
@@ -165,15 +165,15 @@
<h2 class="text-center" data-i18n="lk_qr_h2">QR-Codes</h2>
<div class="grid grid-3" style="max-width:800px;margin:0 auto;">
<div class="card qr-card">
<img src="assets/qr/qr-instagram.png" alt="QR-Code Instagram @sf0118" style="width:160px;height:160px;object-fit:contain;margin:0 auto 1rem;border-radius:10px;display:block;" />
<img src="assets/qr/qr-instagram.png?v=202610012001" alt="QR-Code Instagram @sf0118" style="width:160px;height:160px;object-fit:contain;margin:0 auto 1rem;border-radius:10px;display:block;" />
<p class="small">Instagram</p>
</div>
<div class="card qr-card">
<img src="assets/qr/qr-snapchat.png" alt="QR-Code Snapchat dogfather0804" style="width:160px;height:160px;object-fit:contain;margin:0 auto 1rem;border-radius:10px;display:block;" />
<img src="assets/qr/qr-snapchat.png?v=202610012001" alt="QR-Code Snapchat dogfather0804" style="width:160px;height:160px;object-fit:contain;margin:0 auto 1rem;border-radius:10px;display:block;" />
<p class="small">Snapchat</p>
</div>
<div class="card qr-card">
<img src="assets/qr/qr-teilen-baustein.png" alt="QR-Code Dogis persönlicher Teilen-Baustein" style="width:160px;height:160px;object-fit:contain;margin:0 auto 1rem;border-radius:10px;display:block;" />
<img src="assets/qr/qr-teilen-baustein.png?v=202610012001" alt="QR-Code Dogis persönlicher Teilen-Baustein" style="width:160px;height:160px;object-fit:contain;margin:0 auto 1rem;border-radius:10px;display:block;" />
<p class="small" data-i18n="lk_qr_teilen">Dogis persönlicher Teilen-Baustein</p>
</div>
</div>
@@ -183,8 +183,8 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-links.js?v=20260908a"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-links.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
(function () {
document.addEventListener("DOMContentLoaded", function () {
+14 -14
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Manager DogFather — Spicy Media — DOGFATHER UNIVERSE</title>
<meta name="description" content="DogFather als Manager bei der Spicy Media Agency: Scouts, Creator und faire Zusammenarbeit." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="DogFather als Manager bei der Spicy Media Agency: Scouts, Creator und faire Zusammenarbeit." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-manager.jpg');--page-bg-mobile:url('/assets/img/bg-manager-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-manager.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-manager-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-manager.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-manager.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="mgr_eyebrow">Welt 3 — Manager DogFather</span>
<h1 data-i18n="mgr_h1">Manager DogFather bei Spicy Media</h1>
@@ -45,7 +45,7 @@
<section class="section-tight">
<div class="container grid grid-2" style="align-items:center;gap:2.6rem;">
<div class="collage-photo frame-rot" style="aspect-ratio:1122/1402;border-radius:var(--radius);">
<img src="assets/img/manager-dogfather-portrait.jpg" alt="DogFather — Manager bei Spicy Media"
<img src="assets/img/manager-dogfather-portrait.jpg?v=202610012001" alt="DogFather — Manager bei Spicy Media"
style="width:100%;height:100%;object-fit:cover;" />
</div>
<div>
@@ -91,7 +91,7 @@
<a class="btn-tiktok" href="https://www.tiktok.com/@spicymediaagentur" target="_blank" rel="noopener" style="margin-top:1rem;"><span class="btn-tiktok-note" aria-hidden="true">♪</span><span class="btn-tiktok-text">TikTok</span></a>
</div>
<div class="collage-photo frame-rot" style="aspect-ratio:1/1;border-radius:var(--radius);background:#000;">
<img src="assets/img/spicy-media-logo.jpg" alt="Spicy Media Agency Logo"
<img src="assets/img/spicy-media-logo.jpg?v=202610012001" alt="Spicy Media Agency Logo"
style="width:100%;height:100%;object-fit:contain;padding:1.6rem;" />
</div>
</div>
@@ -140,9 +140,9 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/data-manager.js?v=20260828e"></script>
<script src="assets/js/i18n-manager.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-manager.js?v=202610012001"></script>
<script src="assets/js/i18n-manager.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
// "Weitere Manager" (21.08.2026) -- siehe Kommentar bei #weitere-manager-root
// oben. Aktuell IMMER window.MANAGERS === [] (siehe data-manager.js), Inhalt
@@ -169,7 +169,7 @@ document.addEventListener("DOMContentLoaded", () => {
<div class="avatar">${(m.avatar || m.photo) ? `<img src="${m.avatar || m.photo}" alt="${m.name}" loading="lazy">` : `<span class="avatar-initial">${m.name.charAt(0).toUpperCase()}</span>`}</div>
<h3>${m.name}</h3>
<div class="role">${feld(m.role)}</div>
${quoteText ? `<p class="quote">„${quoteText}"</p>` : ""}
${quoteText ? `<p class="quote">„${quoteText}“</p>` : ""}
<span class="btn btn-outline" style="margin-top:.7rem;pointer-events:none;">${t("mgr_weitere_profil_btn")}</span>
</a>`;
}).join("");
+13 -13
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Medien — Casper — DOGFATHER UNIVERSE</title>
<meta name="description" content="Fotos und Videos rund um Husky Casper an einem Ort." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Fotos und Videos rund um Husky Casper an einem Ort." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" data-welt="casper" style="--page-bg:url('/assets/img/bg-medien-casper.jpg');--page-bg-mobile:url('/assets/img/bg-medien-casper-mobile.jpg');">
<body class="mit-hintergrund" data-welt="casper" style="--page-bg:url('/assets/img/bg-medien-casper.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-medien-casper-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-medien-casper.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-medien-casper.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="mc_eyebrow">Welt 2 — HasiDog</span>
<h1 data-i18n="mc_h1">Medien — Casper</h1>
@@ -60,10 +60,10 @@
</div>
<div id="site-footer"></div>
<script src="assets/js/data-media.js?v=20260828e"></script>
<script src="assets/js/data-videos.js?v=20260828e"></script>
<script src="assets/js/medien-welt.js?v=20260828e"></script>
<script src="assets/js/i18n-medien-casper.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-media.js?v=202610012001"></script>
<script src="assets/js/data-videos.js?v=202610012001"></script>
<script src="assets/js/medien-welt.js?v=202610012001"></script>
<script src="assets/js/i18n-medien-casper.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+14 -14
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Medien — DogFather — DOGFATHER UNIVERSE</title>
<meta name="description" content="Fotos, Videos und Events aus DogFathers Welt an einem Ort." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Fotos, Videos und Events aus DogFathers Welt an einem Ort." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" data-welt="dogfather" style="--page-bg:url('/assets/img/bg-dogfather.jpg');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg');">
<body class="mit-hintergrund" data-welt="dogfather" style="--page-bg:url('/assets/img/bg-dogfather.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-dogfather-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-dogfather.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-dogfather.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="md_eyebrow">Welt 1 — DogFather</span>
<h1 data-i18n="md_h1">Medien — DogFather</h1>
@@ -66,11 +66,11 @@
</div>
<div id="site-footer"></div>
<script src="assets/js/data-media.js?v=20260828e"></script>
<script src="assets/js/data-videos.js?v=20260828e"></script>
<script src="assets/js/data-aktionen.js?v=20260828e"></script>
<script src="assets/js/medien-welt.js?v=20260828e"></script>
<script src="assets/js/i18n-medien-dogfather.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-media.js?v=202610012001"></script>
<script src="assets/js/data-videos.js?v=202610012001"></script>
<script src="assets/js/data-aktionen.js?v=202610012001"></script>
<script src="assets/js/medien-welt.js?v=202610012001"></script>
<script src="assets/js/i18n-medien-dogfather.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+14 -14
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Medien — HasiDog — DOGFATHER UNIVERSE</title>
<meta name="description" content="Fotos, Videos und Events aus der HasiDog-Welt an einem Ort." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,17 +20,17 @@
<meta name="twitter:description" content="Fotos, Videos und Events aus der HasiDog-Welt an einem Ort." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
</head>
<body class="mit-hintergrund" data-welt="hasidog" style="--page-bg:url('/assets/img/bg-medien-hasidog.jpg');--page-bg-mobile:url('/assets/img/bg-medien-hasidog-mobile.jpg');">
<body class="mit-hintergrund" data-welt="hasidog" style="--page-bg:url('/assets/img/bg-medien-hasidog.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-medien-hasidog-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-medien-hasidog.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-medien-hasidog.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="mh_eyebrow">Welt 2 — HasiDog</span>
<h1 data-i18n="mh_h1">Medien — HasiDog</h1>
@@ -66,11 +66,11 @@
</div>
<div id="site-footer"></div>
<script src="assets/js/data-media.js?v=20260828e"></script>
<script src="assets/js/data-videos.js?v=20260828e"></script>
<script src="assets/js/data-aktionen.js?v=20260828e"></script>
<script src="assets/js/medien-welt.js?v=20260828e"></script>
<script src="assets/js/i18n-medien-hasidog.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-media.js?v=202610012001"></script>
<script src="assets/js/data-videos.js?v=202610012001"></script>
<script src="assets/js/data-aktionen.js?v=202610012001"></script>
<script src="assets/js/medien-welt.js?v=202610012001"></script>
<script src="assets/js/i18n-medien-hasidog.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
</body>
</html>
+14 -14
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profil — DOGFATHER UNIVERSE</title>
<meta name="description" content="Team Dogi — persönliche Vorstellung." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -17,10 +17,10 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="robots" content="noindex" /> <!-- generische URL, echte Team-Seiten verlinken hierher -->
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
<style>
.profil-photo { aspect-ratio: 4/5; max-width: 420px; margin: 0 auto; }
.profil-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -131,12 +131,12 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/data-modis.js?v=20260828e"></script>
<script src="assets/js/data-scouts.js?v=20260828e"></script>
<script src="assets/js/data-creator.js?v=20260828e"></script>
<script src="assets/js/data-manager.js?v=20260828e"></script>
<script src="assets/js/i18n-profil.js?v=20260828e"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/data-modis.js?v=202610012001"></script>
<script src="assets/js/data-scouts.js?v=202610012001"></script>
<script src="assets/js/data-creator.js?v=202610012001"></script>
<script src="assets/js/data-manager.js?v=202610012001"></script>
<script src="assets/js/i18n-profil.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
document.addEventListener("DOMContentLoaded", async () => {
const slug = new URLSearchParams(location.search).get("p") || "";
@@ -194,8 +194,8 @@ document.addEventListener("DOMContentLoaded", async () => {
// mehr oben im Kopfbereich, damit das Profil der Person (Foto/Name/Bio)
// sofort beim Öffnen der Seite im Fokus steht, ohne Banner davor.
const bgBasis = istScout || istCreator || istManager ? "bg-scouts" : "bg-dogfather";
document.body.style.setProperty("--page-bg", `url('/assets/img/${bgBasis}.jpg')`);
document.body.style.setProperty("--page-bg-mobile", `url('/assets/img/${bgBasis}-mobile.jpg')`);
document.body.style.setProperty("--page-bg", `url('/assets/img/${bgBasis}.jpg?v=202610012001')`);
document.body.style.setProperty("--page-bg-mobile", `url('/assets/img/${bgBasis}-mobile.jpg?v=202610012001')`);
document.title = `${person.name} — DOGFATHER UNIVERSE`;
+11 -11
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rabattcodes — DOGFATHER UNIVERSE</title>
<meta name="description" content="Rabattcodes von Dogis Partnern für Team Dogi — spar bares Geld bei ausgewählten Marken." />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/universe-32.png?v=202610012001" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/universe-180.png?v=202610012001" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#065f76" />
<meta property="og:type" content="website" />
@@ -20,10 +20,10 @@
<meta name="twitter:description" content="Rabattcodes von Dogis Partnern für Team Dogi — spar bares Geld bei ausgewählten Marken." />
<meta name="twitter:image" content="https://dogfather-universe.com/assets/img/og-cover.jpg" />
<link rel="stylesheet" href="assets/css/main.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260828e" />
<link rel="stylesheet" href="assets/css/main.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=202610012001" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=202610012001" />
<style>
.code-platzhalter {
max-width: 620px;
@@ -397,12 +397,12 @@
}
</style>
</head>
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-links.jpg');--page-bg-mobile:url('/assets/img/bg-links-mobile.jpg');">
<body class="mit-hintergrund" style="--page-bg:url('/assets/img/bg-links.jpg?v=202610012001');--page-bg-mobile:url('/assets/img/bg-links-mobile.jpg?v=202610012001');">
<div id="site-header"></div>
<main>
<section class="hero page-bg">
<div class="hero-banner"><img src="/assets/img/bg-links.jpg" alt="" loading="eager" /></div>
<div class="hero-banner"><img src="/assets/img/bg-links.jpg?v=202610012001" alt="" loading="eager" /></div>
<div class="container">
<span class="eyebrow" data-i18n="rc_eyebrow">🔒 Nur mit Konto</span>
<h1 data-i18n="rc_h1">Rabattcodes</h1>
@@ -420,7 +420,7 @@
<div class="rc-medaillon">
<span class="rc-raendel" aria-hidden="true"></span>
<span class="rc-glanz" aria-hidden="true"></span>
<img src="/assets/img/partner-vandiy.webp" width="384" height="384"
<img src="/assets/img/partner-vandiy.webp?v=202610012001" width="384" height="384"
alt="Logo von Van’s DIY &amp; Bastelbedarf"
data-i18n-attr="alt:rc_logo_alt" loading="lazy" decoding="async" />
<span class="rc-schliff" aria-hidden="true"></span>
@@ -475,8 +475,8 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/i18n-rabattcodes.js?v=20260908a"></script>
<script src="assets/js/main.js?v=20260828e"></script>
<script src="assets/js/i18n-rabattcodes.js?v=202610012001"></script>
<script src="assets/js/main.js?v=202610012001"></script>
<script>
(function () {
/* Ablaufdatum von DOGI10 — dieselbe Angabe wie "bis" in
+10 -3
View File
@@ -9,8 +9,15 @@ import { heuteLokal } from "./helfer-zeit.mjs";
const ordner = mkdtempSync(join(tmpdir(), "ws-callbild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4319, "das Bildwerkzeug");
process.env.PORT = "4319";
/* DIE ANTWORT DES WAECHTERS WIRD BENUTZT (01.10.2026). Hier
stand die Zahl dreimal, und der Rueckgabewert wurde
weggeworfen. Gibt das System den Port dauerhaft nicht her,
weicht `portMussFreiSein` auf Port + 4000 aus und meldet die
neue Nummer -- dieses Werkzeug lauschte danach trotzdem auf
der alten und stuerzte mit `listen EACCES` ab. Genau der
Fall ist am 23.09. auf Port 5040 eingetreten. */
const PORT = await portMussFreiSein(4319, "das Bildwerkzeug");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
@@ -19,7 +26,7 @@ const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4319";
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
+10 -3
View File
@@ -7,8 +7,15 @@ import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-chatbild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4315, "das Bildwerkzeug");
process.env.PORT = "4315";
/* DIE ANTWORT DES WAECHTERS WIRD BENUTZT (01.10.2026). Hier
stand die Zahl dreimal, und der Rueckgabewert wurde
weggeworfen. Gibt das System den Port dauerhaft nicht her,
weicht `portMussFreiSein` auf Port + 4000 aus und meldet die
neue Nummer -- dieses Werkzeug lauschte danach trotzdem auf
der alten und stuerzte mit `listen EACCES` ab. Genau der
Fall ist am 23.09. auf Port 5040 eingetreten. */
const PORT = await portMussFreiSein(4315, "das Bildwerkzeug");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
@@ -17,7 +24,7 @@ const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4315";
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
+149
View File
@@ -0,0 +1,149 @@
/* Bild der vier Urteilsstufen (24.09.2026)
Gebaut wurde heute die zweite Skala fuer das Agenturhaus: Passt,
Verbesserung moeglich, Passt nicht, Trifft nicht zu. Die Zahlen
stimmen (pruef-checkliste-stufen: 48 Pruefungen), aber eine Zahl
sagt nicht, ob vier Knoepfe auf einem Handy noch nebeneinander
passen und ob das neue Rosé neben dem Gelb noch ruhig wirkt.
Zwei Breiten, zwei Rollen: die Betreuung (Scout, mit Knoepfen) und
der Creator (ohne, dafuer mit Notiz).
Aufrufen mit: node server/bild-checkliste-stufen.mjs
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-bild-st-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
/* DIE ANTWORT DES WAECHTERS WIRD BENUTZT (01.10.2026). Hier
stand die Zahl dreimal, und der Rueckgabewert wurde
weggeworfen. Gibt das System den Port dauerhaft nicht her,
weicht `portMussFreiSein` auf Port + 4000 aus und meldet die
neue Nummer -- dieses Werkzeug lauschte danach trotzdem auf
der alten und stuerzte mit `listen EACCES` ab. Genau der
Fall ist am 23.09. auf Port 5040 eingetreten. */
const PORT = await portMussFreiSein(4333, "das Bildwerkzeug fuer die Stufen");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1200));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const j = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, j);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idCrea = anlegen("Luna", "creator", "CODE-CREA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idCrea, idScout, j);
d.close();
const anmelden = async (rolle, code) => {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
};
const kScout = await anmelden("scout", "CODE-SCOU-0001");
/* Jede der vier Stufen einmal setzen -- sonst zeigt das Bild vier
graue Knoepfe und beantwortet keine der beiden Fragen. */
for (const [schluessel, stufe, grund] of [
["ton-geprueft", "gut", ""],
["licht-sitzt", "verbessern", "Das Gesicht liegt im Schatten, der Rest ist hell. Lampe eine Handbreit höher."],
["titel-und-thema-stehen", "passt_nicht", "Der Titel sagt nichts über das Thema. Bitte vor dem nächsten Mal ändern."],
["moderation-geklaert", "entfaellt", "Du sendest bisher ohne Gäste – der Punkt kommt bei dir nicht vor."],
]) {
const a = await fetch(`${BASIS}/workspace/api/checkliste/live/${schluessel}`, {
method: "PUT", headers: { "Content-Type": "application/json", Cookie: kScout },
body: JSON.stringify({ stufe, begruendung: grund, creator_id: idCrea }) });
if (!a.ok) console.log(" konnte nicht setzen:", schluessel, a.status, await a.text());
}
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
for (const [wer, rolle, code, breite, datei] of [
["Scout", "scout", "CODE-SCOU-0001", 1280, "bild-stufen-betreuung.png"],
["Creator", "creator", "CODE-CREA-0001", 1280, "bild-stufen-creator.png"],
["Scout", "scout", "CODE-SCOU-0001", 390, "bild-stufen-handy.png"],
]) {
const ctx = await browser.newContext({
viewport: { width: breite, height: breite < 500 ? 900 : 1000 }, deviceScaleFactor: 2 });
const seite = await ctx.newPage();
const laut = [];
seite.on("console", (m) => { if (m.type() === "error") laut.push(m.text()); });
seite.on("pageerror", (f) => laut.push(String(f)));
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.goto(BASIS + "/workspace/bereich.html?b=live", { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
/* Die erste Gruppe aufklappen -- zugeklappt sieht man nichts von dem,
was hier zu beurteilen ist. */
await seite.evaluate(() => {
const k = document.querySelector(".fest-gruppe__kopf");
if (k && k.getAttribute("aria-expanded") !== "true") k.click();
});
await seite.waitForTimeout(500);
/* NUR DIE ERSTEN VIER KARTEN. Eine Aufnahme ueber 200 Punkte wird
so lang, dass man beim Ansehen jedes Detail wegskaliert -- und
genau die Details sind die Frage: Passen vier Knoepfe nebeneinander,
ist das Rosé ruhig genug, steht die Notiz lesbar da. */
await seite.evaluate(() => {
const k = [...document.querySelectorAll(".fest-punkt")];
for (let i = 4; i < k.length; i++) k[i].remove();
for (const g of [...document.querySelectorAll(".fest-gruppe")].slice(1)) g.remove();
});
await seite.waitForTimeout(200);
const block = await seite.$(".liste-fest");
await (block || seite).screenshot({ path: datei });
/* Und das Nachfragefenster einmal offen -- dort steht die Frage,
die der Katalog zu dieser Stufe mitbringt. */
if (datei === "bild-stufen-betreuung.png") {
await seite.evaluate(() => {
const b = [...document.querySelectorAll('.stufe-knopf[data-stufe="passt_nicht"]')][1];
if (b) b.click();
});
await seite.waitForTimeout(700);
await seite.screenshot({ path: "bild-stufen-nachfrage.png" });
}
const zahlen = await seite.evaluate(() => ({
knoepfe: document.querySelectorAll(".fest-punkt:not([hidden]) .stufe-knopf").length,
bilanz: [...document.querySelectorAll(".bilanz-zahl")]
.map((z) => `${z.querySelector(".bilanz-zahl__n")?.textContent} ${z.querySelector(".bilanz-zahl__text")?.textContent}`),
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
console.log(` ${datei} (${wer}, ${breite}px): ${zahlen.knoepfe} Knöpfe, `
+ `Bilanz [${zahlen.bilanz.join(" | ")}], ${zahlen.ueber}px über`
+ (laut.length ? ` KONSOLE: ${laut.join(" | ")}` : ""));
await ctx.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+10 -3
View File
@@ -9,8 +9,15 @@ import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-sicht-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4329, "das Bildwerkzeug");
process.env.PORT = "4329";
/* DIE ANTWORT DES WAECHTERS WIRD BENUTZT (01.10.2026). Hier
stand die Zahl dreimal, und der Rueckgabewert wurde
weggeworfen. Gibt das System den Port dauerhaft nicht her,
weicht `portMussFreiSein` auf Port + 4000 aus und meldet die
neue Nummer -- dieses Werkzeug lauschte danach trotzdem auf
der alten und stuerzte mit `listen EACCES` ab. Genau der
Fall ist am 23.09. auf Port 5040 eingetreten. */
const PORT = await portMussFreiSein(4329, "das Bildwerkzeug");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
@@ -19,7 +26,7 @@ const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4329";
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
+10 -3
View File
@@ -15,8 +15,15 @@ import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-bild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4331, "das Bildwerkzeug");
process.env.PORT = "4331";
/* DIE ANTWORT DES WAECHTERS WIRD BENUTZT (01.10.2026). Hier
stand die Zahl dreimal, und der Rueckgabewert wurde
weggeworfen. Gibt das System den Port dauerhaft nicht her,
weicht `portMussFreiSein` auf Port + 4000 aus und meldet die
neue Nummer -- dieses Werkzeug lauschte danach trotzdem auf
der alten und stuerzte mit `listen EACCES` ab. Genau der
Fall ist am 23.09. auf Port 5040 eingetreten. */
const PORT = await portMussFreiSein(4331, "das Bildwerkzeug");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
@@ -25,7 +32,7 @@ const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1200));
const BASIS = "http://127.0.0.1:4331";
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
+10 -3
View File
@@ -7,8 +7,15 @@ import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-bild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4307, "das Bildwerkzeug");
process.env.PORT = "4307";
/* DIE ANTWORT DES WAECHTERS WIRD BENUTZT (01.10.2026). Hier
stand die Zahl dreimal, und der Rueckgabewert wurde
weggeworfen. Gibt das System den Port dauerhaft nicht her,
weicht `portMussFreiSein` auf Port + 4000 aus und meldet die
neue Nummer -- dieses Werkzeug lauschte danach trotzdem auf
der alten und stuerzte mit `listen EACCES` ab. Genau der
Fall ist am 23.09. auf Port 5040 eingetreten. */
const PORT = await portMussFreiSein(4307, "das Bildwerkzeug");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
@@ -17,7 +24,7 @@ const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 900));
const BASIS = "http://127.0.0.1:4307";
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
+141
View File
@@ -0,0 +1,141 @@
/* =====================================================================
HELLER UND DUNKLER (25.09.2026)
=====================================================================
Filipe: „dieser kreis muss viel perfekter sein. ich will dass die
leute auch heller und dunkler aussuchen koennen. perfektionier das
bitte viel krasser. viel profissioneller. so dass die leute viel
krassere moeglichkeiten haben."
---------------------------------------------------------------------
WARUM DAS BIS HEUTE NICHT GING
Die 360 Toene in chat-kreis.js liegen ALLE auf derselben
Leuchtdichte. Das war kein Zufall und keine Bequemlichkeit: Solange
die Blase in der gewaehlten Farbe stand, musste jede dieser Flaechen
dieselbe Schrift tragen -- „gleiche Leuchtdichte heisst gleicher
Kontrast fuer alle". Eine hellere Farbe zuzulassen hiesse damals:
irgendwo im Haus wird eine Nachricht unlesbar.
SEIT DEM 25.09.2026 IST DAS ANDERS. Die Blase ist fuer alle dunkles
Glas (`--blasengrund`); der gewaehlte Ton ist KANTE und NAME. Damit
faellt die alte Bedingung weg -- und eine neue tritt an ihre Stelle:
Der Ton muss als NAME auf dem Blasengrund lesbar bleiben.
Die Oberflaeche hellt ihn dafuer mit Weiss auf (`--namen-anteil`,
50 %). Genau diese Rechnung prueft pruef-chat-neu fuer alle Toene.
---------------------------------------------------------------------
DIE ZAHLEN SIND GEMESSEN, NICHT GEWAEHLT
Nachgerechnet ueber alle 360 Winkel, jeweils der schlechteste:
40 % schwarz 4,468 -- unter der Schwelle, faellt raus
34 % schwarz 4,555 -- ginge, aber ohne Reserve
30 % schwarz 4,619 <- die dunkelste Stufe
20 % schwarz 4,7
10 % schwarz 4,9
0 % 5,12 <- die Mitte, der alte Kreis
18 % weiss 6,4
36 % weiss 7,9
55 % weiss 9,6 <- die hellste Stufe
Noetig sind 4,5. Die dunkelste Stufe hat also 0,12 Reserve, die
hellste mehr als das Doppelte. Wer hier eine Stufe hinzufuegt, misst
nach -- pruef-chat-neu rechnet jede Stufe an jedem der 360 Winkel
nach und wird rot, bevor es jemand auf dem Bildschirm sieht.
---------------------------------------------------------------------
WARUM SIEBEN UND NICHT DREI
Drei (dunkel / normal / hell) waere die bequeme Zahl. Sieben sind
der Punkt, an dem zwei benachbarte Stufen noch unterscheidbar sind
und man trotzdem das Gefuehl hat, etwas einstellen zu koennen --
Filipes „viel krassere moeglichkeiten" heisst nicht „zwei mehr".
Zusammen mit 360 Winkeln sind es 2520 Farben statt 360.
---------------------------------------------------------------------
WARUM EINE EIGENE DATEI
chat-kreis.js wird von tools/chat-kacheln-rechnen.mjs ERZEUGT und
traegt oben „NICHT VON HAND AENDERN". Etwas hineinzuschreiben waere
beim naechsten Lauf des Werkzeugs weg. Die Stufen sind ausserdem
keine Tabelle, sondern eine Rechnung -- sie gehoeren nicht in eine
erzeugte Liste.
===================================================================== */
import { KREIS } from "./chat-kreis.js";
/** Die sieben Stufen. Minus heisst dunkler, Plus heller.
*
* `wert` ist der Teil, der im Schluessel steht ("ton-214-s2").
* `misch` ist der Anteil, mit dem der Grundton nach Schwarz (negativ)
* oder nach Weiss (positiv) gezogen wird.
* `name` ist das, was ein Vorleseprogramm sagt -- eine Zahl von 1 bis
* 7 saehe aus wie eine Note.
*/
export const KREIS_STUFEN = [
{ wert: "s1", misch: -0.30, name: "sehr dunkel" },
{ wert: "s2", misch: -0.20, name: "dunkler" },
{ wert: "s3", misch: -0.10, name: "leicht dunkel" },
{ wert: "s4", misch: 0, name: "normal" },
{ wert: "s5", misch: 0.18, name: "leicht hell" },
{ wert: "s6", misch: 0.36, name: "heller" },
{ wert: "s7", misch: 0.55, name: "sehr hell" },
];
/** Welche Stufe ist die Mitte -- und damit das, was "ton-214" ohne
* Zusatz bedeutet. Abgeleitet aus `misch === 0` und nicht als Index
* hingeschrieben: Wer eine Stufe vorn einfuegt, verschiebt sonst
* stillschweigend die Bedeutung aller alten Schluessel. */
export const KREIS_MITTE = KREIS_STUFEN.find((s) => s.misch === 0)?.wert || "s4";
/** Zerlegt einen Schluessel in Winkel und Stufe.
*
* "ton-214" -> { winkel: 214, stufe: "s4" } (die Mitte)
* "ton-214-s6" -> { winkel: 214, stufe: "s6" }
* alles andere -> null
*
* DIE ALTEN SCHLUESSEL BLEIBEN GUELTIG, und zwar ohne Umstellung in
* der Datenbank. Wer seit dem 23.09. "ton-214" traegt, traegt danach
* genau dieselbe Farbe -- eine Umstellung, die stillschweigend die
* Wahl von fuenfzehn Leuten verschiebt, waere kein Fortschritt.
*/
export function tonAus(wert) {
const t = /^ton-(\d{1,3})(?:-(s[1-7]))?$/.exec(String(wert ?? ""));
if (!t) return null;
const winkel = Number(t[1]);
if (!(winkel >= 0 && winkel < KREIS.length)) return null;
const stufe = t[2] || KREIS_MITTE;
if (!KREIS_STUFEN.some((s) => s.wert === stufe)) return null;
return { winkel, stufe };
}
/** Zwei Farben mischen, wie `color-mix(in srgb, a p%, b)` es tut. */
function mischen(a, b, anteilB) {
const z = (h) => {
const s = String(h).replace("#", "");
return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
};
const [ar, ag, ab] = z(a), [br, bg, bb] = z(b);
const m = (x, y) => Math.round(x * (1 - anteilB) + y * anteilB);
return "#" + [m(ar, br), m(ag, bg), m(ab, bb)]
.map((v) => v.toString(16).padStart(2, "0")).join("");
}
/** Die fertige Farbe zu Winkel und Stufe. */
export function tonFarbe(winkel, stufe) {
const grund = KREIS[winkel];
if (!grund) return null;
const s = KREIS_STUFEN.find((x) => x.wert === stufe) || { misch: 0 };
if (!s.misch) return grund;
return s.misch < 0
? mischen(grund, "#000000", -s.misch)
: mischen(grund, "#ffffff", s.misch);
}
/** Die Farbe zu einem ganzen Schluessel, oder null. */
export function farbeZuSchluessel(wert) {
const t = tonAus(wert);
return t ? tonFarbe(t.winkel, t.stufe) : null;
}
+115 -1
View File
@@ -78,6 +78,20 @@ import { fileURLToPath } from "node:url";
export const CREW_ADRESSE = "https://crew.dogfather-universe.com";
/** Und die Adresse des Agenturhauses.
*
* Sie stand bisher nur als Text in `anmeldeAdresseFuer` ganz unten.
* Seit die Wechselkachel in BEIDE Richtungen geht (24.09.2026), wird
* sie an zwei Stellen gebraucht -- und zwei gleiche Zeichenketten sind
* eine Umzugsadresse, die beim naechsten Umzug halb mitwandert. Das
* ist in diesem Haus schon passiert: Der Workspace ist am 06.09.2026
* umgezogen, und die Rueckmeldung an den Server lief danach ins Leere.
*
* KEINE ABLEITUNG AUS `OHNE_MODI_HOSTS`: Dort stehen drei Adressen
* (mit und ohne www, plus die Hauptseite). Welche davon die Tuer ist,
* waere geraten. */
export const WORKSPACE_ADRESSE = "https://workspace.dogfather-universe.com";
/* WELCHE SYMBOLGROESSEN HAT DIE CREW-APP EIGENS?
Abgeleitet aus dem Ordner, nicht aufgezaehlt: Fuer jede Datei
crew-<X>.png, zu der es auch workspace-<X>.png gibt, kann umgebogen
@@ -157,6 +171,90 @@ export const AUSSEN_ROLLEN = new Set(["gast"]);
weniger prueft, sondern mehr: Was vorher die Adresse erledigt hat,
muss jetzt jede einzelne Regel halten. */
/* =====================================================================
DIE ZWEI HAEUSER -- WELCHE ROLLE WO WOHNT (24.09.2026)
=====================================================================
Filipe: „ich will dass du zuerst die komplette site vn der workspace
seite trennst. da soll nichts verknuepft sein. wenn ich bei der einen
was mache soll nichts bei der anderen passieren."
DAS KEHRT DIE ENTSCHEIDUNG VOM 10.09.2026 UM. Damals hiess es
ausdruecklich: „es soll trotzdem so bleiben dass ich die daten hier
und da sehe", und weiter unten in dieser Datei steht bis heute
„Getrennt wird also das AUSSEHEN, nicht der Bestand." Das gilt nicht
mehr. Wer den alten Satz liest, liest einen ueberholten Stand --
deshalb steht die Umkehr hier oben und nicht als Fussnote.
WARUM DIE MENGEN HIER STEHEN: Diese Datei hat keine eigenen Importe
und ist damit der einzige Ort, an dem eine Rollenmenge stehen kann,
ohne einen Kreis zu bauen. TEAM_DOGI_ROLLEN und AUSSEN_ROLLEN wohnen
aus demselben Grund hier.
DREI MENGEN UND NICHT ZWEI. „Alles ausser Team Dogi" waere die
naheliegende Formulierung fuer die Agentur -- und sie waere falsch,
denn DogFather gehoert in BEIDE Haeuser. Eine Bedingung durch
Ausschluss haette ihn stillschweigend der Agentur zugeschlagen. Genau
diese Bauart ist in dieser Datei schon dreimal schiefgegangen (siehe
Falle 2 im Kopf). Deshalb wird jede Rolle einzeln genannt.
UND EINE ROLLE MUSS IRGENDWO STEHEN: `hausVonRolle` gibt fuer eine
unbekannte Rolle `null` zurueck, nicht „beide" und nicht „agentur".
Ein `null` faellt auf -- `pruef-haustrennung` wird rot, sobald eine
Rolle in ROLLEN steht, die hier fehlt. Eine stille Voreinstellung
waere eine offene Tuer, die niemand bemerkt. */
/** Die Rollen, die ausschliesslich im Agenturhaus leben. */
export const AGENTUR_ROLLEN = new Set(["spicy", "manager", "scout", "creator"]);
/** Die Rollen, die in BEIDEN Haeusern arbeiten. Genau eine: DogFather.
* Als Menge und nicht als `rolle === "admin"`, damit die drei Mengen
* zusammen gegen ROLLEN geprueft werden koennen. */
export const BEIDE_HAEUSER_ROLLEN = new Set(["admin"]);
/**
* In welchem Haus wohnt diese Rolle?
*
* @returns {"crew"|"agentur"|"beide"|null} `null` heisst: unbekannte
* Rolle. Das ist ein Fehler, kein Standardfall.
*/
export function hausVonRolle(rolle) {
if (TEAM_DOGI_ROLLEN.has(rolle) || AUSSEN_ROLLEN.has(rolle)) return "crew";
if (AGENTUR_ROLLEN.has(rolle)) return "agentur";
if (BEIDE_HAEUSER_ROLLEN.has(rolle)) return "beide";
return null;
}
/**
* Welche Rollen kommen in diesem Haus vor -- DogFather eingerechnet?
*
* ABGELEITET AUS DENSELBEN MENGEN, nicht abgeschrieben. Wer eine Rolle
* verschiebt, verschiebt damit jede SQL-Abfrage mit, die diese Funktion
* benutzt. Genau das Abschreiben hat am 24.09.2026 dazu gefuehrt, dass
* die „An wen"-Reihe sechs Leute zeigte und der Server vier belieferte.
*/
export function rollenImHaus(haus) {
const eigen = haus === "crew"
? [...TEAM_DOGI_ROLLEN, ...AUSSEN_ROLLEN]
: [...AGENTUR_ROLLEN];
return new Set([...eigen, ...BEIDE_HAEUSER_ROLLEN]);
}
/**
* Und welche Rollen gehoeren NUR dem anderen Haus?
*
* Das ist die Menge, nach der gefiltert wird -- nicht „welche gehoeren
* hierher". Der Unterschied ist DogFather: Er steht in beiden, also
* darf er in keiner der beiden Ausschlussmengen auftauchen. Eine Zeile,
* die „gehoert hierher" fragt, wuerde eine Aufgabe, die DogFather fuer
* einen Creator angelegt hat, ueber `erstellt_von` ins Team-Haus
* ziehen. Derselbe Gedanke steht bei ohneTeamDogi() in workspace.js.
*/
export function nurAnderesHaus(haus) {
return haus === "crew" ? new Set(AGENTUR_ROLLEN)
: new Set([...TEAM_DOGI_ROLLEN, ...AUSSEN_ROLLEN]);
}
/** Die Adresse von Team Dogi -- und seit 11.09.2026 auch die des
* Treffs. Feste Menge, kein Muster -- Falle 1. */
const CREW_HOSTS = new Set(["crew.dogfather-universe.com"]);
@@ -278,6 +376,22 @@ export function istWorkspacePfad(pfad) {
export const GEHOERT_ZU_ADRESSE = [
{ datei: "/workspace/crew-index.html", zuhause: istCrewAdresse },
{ datei: "/workspace/assets/css/crew-haus.css", zuhause: istCrewAdresse },
/* DER NOTIZBLOCK (26.09.2026). Er gehoert zum Haus von Team Dogi,
und zwar vollstaendig: Auf der Agenturadresse gibt es die Seite
nicht -- auch nicht fuer DogFather, der in beiden Haeusern
arbeitet. Das ist dieselbe Trennung wie bei Chat, Kalender und
Aufgaben; ohne diesen Eintrag waere die Seite dort einfach
abrufbar, weil express.static jede Datei ausliefert, die man
beim Namen nennt. Die Schnittstelle prueft es ein zweites Mal
(nurTeamDogi in workspace-notizen.js) -- die Seite ist nur HTML,
die Daten sind die Sache. */
{ datei: "/workspace/notizen.html", zuhause: istCrewAdresse },
{ datei: "/workspace/reaktion.html", zuhause: istCrewAdresse },
/* DIE ZWEI BUEHNENQUELLEN (28.09.2026). Sie kommen ohne
Anmeldung aus -- geprueft wird ein Schluessel in der Adresse
-- und gehoeren trotzdem in genau ein Haus. */
{ datei: "/workspace/buehne.html", zuhause: istCrewAdresse },
{ datei: "/workspace/tafel.html", zuhause: istCrewAdresse },
];
/** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */
@@ -569,5 +683,5 @@ export function crewWeiche(req, res, next) {
export function anmeldeAdresseFuer(rolle) {
return (TEAM_DOGI_ROLLEN.has(rolle) || AUSSEN_ROLLEN.has(rolle))
? CREW_ADRESSE
: "https://workspace.dogfather-universe.com";
: WORKSPACE_ADRESSE;
}
+162
View File
@@ -0,0 +1,162 @@
/* EINE DATEI AUSLIEFERN — MIT TEILANFRAGEN (02.10.2026)
=====================================================================
WARUM ES DIESE DATEI GIBT
Miss, eine Modi, konnte keine Sprachnachrichten hoeren. Alle
anderen schon. Gemessen hat es sich so aufgeklaert:
· An ihrer Rolle lag es nicht — der Anhang-Weg fragt nur
„gibt es die Nachricht", „ist sie im Raum", „hat sie das
Gespraech weggeraeumt". Eine Modi bekommt HTTP 200.
· Sie ist die EINZIGE im Haus mit Safari (iPhone, iOS 18.7).
Alle anderen: Android-Chrome, Windows-Chrome/Edge, Firefox.
· Und der Anhang-Weg beantwortete eine Teilanfrage
(`Range: bytes=0-1`) mit einer vollen 200, ohne
`Accept-Ranges`, ohne `Content-Length`, als `chunked`.
Safari verlangt fuer <audio> und <video> zwingend Teilanfragen und
verweigert die Wiedergabe bei einer 200. Chrome und Firefox nehmen
die ganze Datei klaglos — deshalb fiel es sieben Tage lang
niemandem auf ausser ihr. Bilder brauchen das nicht: Deshalb sah
sie Fotos und hoerte nichts.
WARUM EIN GEMEINSAMER HELFER UND NICHT EINE REPARATUR
Im Haus gab es ZEHN Stellen, die eine Datei mit
`createReadStream(pfad).pipe(res)` hinausgeben: Chat-Anhang,
Chat-GIF, Dateiablage (ansehen und laden), Material (ansehen und
laden), Steckbriefbild, Buehnenbild, Supportbild, Wissens-PDF.
Nur eine davon zu reparieren hiesse, eine Liste zu fuehren, welche
Stelle schon richtig ist — und die naechste neue Stelle macht es
wieder falsch. Hier ist es EIN Weg, den alle zehn benutzen.
DAMIT ES SO BLEIBT, zaehlt `pruef-struktur` nach: Wer kuenftig
wieder `createReadStream(...).pipe(res)` schreibt, bekommt einen
Befund. Ein Kommentar, der vor einem Fehler warnt, verhindert ihn
nicht — das hat der Spaltenverlust vom 11.09. gezeigt.
WAS ES AUSSER SAFARI NOCH BRINGT (gemessen, nicht vermutet)
· Eine MP4-Sprachnachricht meldete in Chromium ohne Teilanfragen
eine Laenge von 0,23 s statt 1,96 s — der Kopf einer MP4 steht
am ENDE der Datei, und ohne Springen findet der Browser ihn
nicht.
· Eine Ogg-Datei meldete „Infinity" statt 2,02 s.
· In einer PDF springt der Betrachter zu einer Seite, ohne die
ganze Datei zu holen.
WAS HIER ABSICHTLICH NICHT PASSIERT
Mehrere Bereiche in einer Anfrage („bytes=0-9,20-29") werden NICHT
bedient. Das darf ein Server (RFC 9110: er MAY den Kopf
uebergehen), und die Antwort ist dann die vollstaendige Datei —
nie eine falsche Teilmenge. Kein Browser im Haus fragt so.
===================================================================== */
import { createReadStream, statSync } from "node:fs";
/** Einen `Range`-Kopf lesen.
*
* Drei Formen sind erlaubt und werden unterschieden:
* bytes=100-199 ab 100 bis einschliesslich 199
* bytes=100- ab 100 bis zum Ende
* bytes=-500 die letzten 500 Bytes
*
* Rueckgabe: null = kein (oder kein verstandener) Bereich, volle Datei
* false = der Bereich liegt ausserhalb der Datei -> 416
* {von, bis} = genau dieser Abschnitt
*
* Ausgelagert und ausgefuehrt, damit die Pruefung sie einzeln
* befragen kann, ohne einen Server zu starten. */
export function bereichLesen(roh, groesse) {
if (typeof roh !== "string" || !roh) return null;
const t = /^bytes=(\d*)-(\d*)$/.exec(roh.trim());
if (!t) return null; /* auch „bytes=0-9,20-29" landet hier */
const [, a, b] = t;
if (a === "" && b === "") return null;
if (groesse <= 0) return false;
let von; let bis;
if (a === "") {
/* Die letzten n Bytes. „bytes=-0" ist nach RFC unerfuellbar. */
const n = Number(b);
if (n <= 0) return false;
von = Math.max(0, groesse - n);
bis = groesse - 1;
} else {
von = Number(a);
bis = b === "" ? groesse - 1 : Number(b);
if (von >= groesse) return false;
if (bis >= groesse) bis = groesse - 1; /* zu viel verlangt ist erlaubt */
if (bis < von) return false;
}
return { von, bis };
}
/** Eine Datei von der Platte in die Antwort geben.
*
* Die Kopfzeilen, die zur SACHE gehoeren (Content-Type, Cache,
* Content-Disposition, Sicherheitsregeln), setzt der Aufrufer VORHER
* — die bleiben unberuehrt. Hier kommt nur der Transport dazu.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
* @param {string} pfad Datei, die es schon gibt (der Aufrufer hat
* vorher nachgesehen und sonst 410 gemeldet)
*/
export function liefereDatei(req, res, pfad) {
/* ==== WARUM `res.statusCode = n` UND NICHT `res.status(n)` =========
`res.status()` gibt es nur an einer EXPRESS-Antwort. Alle zehn
Aufrufer sind Express-Handler, es lief also -- aber der Helfer
hing damit an Express, ohne dass irgendwo stand warum.
Aufgefallen ist es nicht beim Lesen, sondern beim MESSEN: Die
Probe auf dem Server reichte eine nackte `http.ServerResponse`
herein, und die Zeile warf „res.status is not a function". Ein
Helfer, den man nur innerhalb der ganzen Anwendung pruefen kann,
ist schwerer zu beweisen als einer, dem eine Antwort genuegt.
`statusCode` kennen beide, und Express aendert daran nichts. */
let groesse;
try { groesse = statSync(pfad).size; } catch {
/* Zwischen dem Nachsehen des Aufrufers und hier ist die Datei
verschwunden. Nichts gesendet, also darf noch ein Fehler kommen. */
if (!res.headersSent) { res.statusCode = 410; res.end(); }
return;
}
res.setHeader("Accept-Ranges", "bytes");
const bereich = bereichLesen(req.headers?.range, groesse);
if (bereich === false) {
/* UNERFUELLBAR. Die Groesse mitzugeben ist Pflicht, sonst weiss
der Browser nicht, was er stattdessen fragen soll. */
res.statusCode = 416;
res.setHeader("Content-Range", `bytes */${groesse}`);
res.end();
return;
}
const strom = bereich
? createReadStream(pfad, { start: bereich.von, end: bereich.bis })
: createReadStream(pfad);
if (bereich) {
res.statusCode = 206;
res.setHeader("Content-Range", `bytes ${bereich.von}-${bereich.bis}/${groesse}`);
res.setHeader("Content-Length", String(bereich.bis - bereich.von + 1));
} else {
res.setHeader("Content-Length", String(groesse));
}
/* Bricht der Empfaenger ab (Finger weg vom Bildschirm, Seite zu),
wird nicht weiter von der Platte gelesen. Ohne das haengt bei
jedem Abbruch ein offener Lesestrom. */
res.on("close", () => { if (!strom.destroyed) strom.destroy(); });
strom.on("error", () => {
if (!res.headersSent) { res.statusCode = 500; res.end(); }
else res.destroy();
});
strom.pipe(res);
}
+319
View File
@@ -0,0 +1,319 @@
/* =====================================================================
helfer-ffmpeg.mjs — eine Sprachnachricht, die JEDES Gerät abspielt
=====================================================================
WARUM ES DIESE DATEI GIBT (03.10.2026)
Miss im Support, Meldung #12 — fünf Runden lang: „Bei mir lässt
sich die Nachricht nicht abspielen." Zuletzt: „Jetzt lädt es die
ganze Zeit." Bei allen anderen ging es.
GEMESSEN, BEVOR GEBAUT WURDE:
· Alle sechs Sprachnachrichten im Haus sind `audio/webm`.
· Miss' Gerät: iPhone, iOS 18.7, WebKit.
· Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
wurde KEINE EINZIGE neue aufgenommen — ihr Problem betrifft
also ausschließlich die sechs alten Dateien.
WAS ICH NICHT MESSEN KONNTE, und das gehört hierher: Ob WebKit
WebM/Opus abspielen kann, lässt sich auf diesem Rechner nicht
nachsehen — Playwrights WebKit startet hier nicht (`libegl.dll`
fehlt). Die Vermutung „iPhones können kein WebM" ist deshalb
begründet, aber nicht von mir gemessen.
GENAU DESHALB IST DIE LÖSUNG HIER EINE, DIE DIE FRAGE NICHT
STELLT. Statt zu raten, welches Gerät welchen Behälter kann, legt
der Server neben jede Sprachnachricht eine zweite Fassung in dem
Format, das seit zwanzig Jahren überall läuft: AAC in MP4. Wer sie
hat, bekommt sie. Kein Gerätename im Code, keine Liste, die
altert.
---------------------------------------------------------------------
WARUM EIN FREMDES PROGRAMM UND NICHT NODE
Node kann kein Audio umwandeln, und ein Paket dafür wäre die
einzige nennenswerte Abhängigkeit in einem Haus, das ohne Baulauf
auskommt. ffmpeg ist auf dem Server installiert (7.1.5, Debian) —
abgesprochen, nicht nebenbei eingerichtet.
DREI AUSGÄNGE, wie überall: umgewandelt / nicht umwandelbar /
ffmpeg nicht auffindbar. Der dritte ist KEIN Fehler: Fehlt ffmpeg,
wird die Sprachnachricht wie bisher im Originalformat
ausgeliefert. Niemand verliert etwas, was er vorher hatte — es
kommt nur niemand dazu.
===================================================================== */
import { existsSync, renameSync, unlinkSync } from "node:fs";
import { execFile, execFileSync } from "node:child_process";
import { delimiter, join, extname, basename, dirname } from "node:path";
/* Die Endung der Zweitfassung. AAC in MP4 — das Format, bei dem sich
iPhone, Android, Windows, Mac und jeder Browser einig sind. */
export const ZWEITFASSUNG_ENDUNG = ".m4a";
export const ZWEITFASSUNG_TYP = "audio/mp4";
/* 96 kbit/s für Sprache. Opus holt bei 32 kbit/s dasselbe heraus,
AAC braucht mehr — und eine Sprachnachricht von einer Minute wiegt
damit 700 KB statt 240. Das ist der Preis dafür, dass sie überall
läuft, und er ist es wert. Niedriger wäre hörbar dumpf; höher
brächte bei einem Handymikrofon nichts. */
const RATE = "96k";
/* Nach dieser Zeit wird abgebrochen. Eine Sprachnachricht von einer
Minute braucht auf dem Server unter einer Sekunde; wer hier in die
Frist läuft, hat ein anderes Problem, und ein hängender Prozess
wäre das schlimmere. */
const FRIST_MS = 60_000;
const BEKANNTE_ORTE = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"C:/Program Files/ffmpeg/bin/ffmpeg.exe",
];
let gemerkt = null;
let schonGesagt = false;
/* IM PATH SUCHEN, OHNE `where` ODER `which` ZU BRAUCHEN — dieselbe
Überlegung wie in helfer-openssl.mjs: Beide sind selbst Programme
und können genauso fehlen. */
function imPfad() {
const pfade = (process.env.PATH || "").split(delimiter).filter(Boolean);
const namen = process.platform === "win32" ? ["ffmpeg.exe", "ffmpeg"] : ["ffmpeg"];
for (const p of pfade) {
for (const n of namen) {
const voll = join(p, n);
if (existsSync(voll)) return voll;
}
}
return null;
}
/** Wo liegt ffmpeg — oder `null`.
*
* GIBT `null` ZURUECK UND WIRFT NICHT. Anders als bei openssl ist
* das Fehlen hier kein Abbruchgrund: Die Sprachnachricht
* funktioniert auch ohne Zweitfassung, nur eben nicht überall. Ein
* geworfener Fehler würde das Hochladen scheitern lassen — und
* damit aus einer fehlenden Verbesserung einen Ausfall machen. */
export function ffmpegPfad() {
if (gemerkt !== null) return gemerkt || null;
gemerkt = imPfad() || BEKANNTE_ORTE.find((o) => existsSync(o)) || "";
if (!gemerkt && !schonGesagt) {
schonGesagt = true;
/* EINMAL SAGEN, NICHT BEI JEDER NACHRICHT. Eine Zeile, die bei
jedem Hochladen kommt, liest nach dem dritten Mal niemand
mehr — und deckt dabei die echten zu. */
console.log("[ton] ffmpeg nicht gefunden – Sprachnachrichten bekommen keine "
+ "zweite Fassung. Auf Debian: sudo apt-get install -y ffmpeg");
}
return gemerkt || null;
}
/** Wo liegt ffprobe — oder `null`.
*
* ES LIEGT IMMER NEBEN ffmpeg (dasselbe Paket, derselbe Ordner),
* deshalb wird es dort gesucht und nicht noch einmal im PATH.
* Gebraucht wird es nur von den Prüfungen: Nur ffprobe kann sagen,
* ob in der erzeugten Datei wirklich AAC steckt und ob sie so lang
* ist wie das Original. Ohne diese Frage wäre „die Datei ist da"
* ein grüner Haken über einer Datei, die niemand abspielen kann. */
export function ffprobePfad() {
const ff = ffmpegPfad();
if (!ff) return null;
const nachbar = ff.replace(/ffmpeg(\.exe)?$/i, (m) => m.replace(/ffmpeg/i, "ffprobe"));
return existsSync(nachbar) ? nachbar : null;
}
/** Der Pfad, unter dem die Zweitfassung einer Datei liegt (oder läge). */
export function zweitfassungPfad(pfad) {
return join(dirname(pfad), basename(pfad, extname(pfad)) + ZWEITFASSUNG_ENDUNG);
}
/** Braucht diese Datei überhaupt eine Zweitfassung?
*
* NEIN, WENN SIE SCHON DAS ZIELFORMAT IST. Eine m4a nach m4a zu
* wandeln kostet Rechenzeit und Qualität für genau nichts — und
* seit dem 02.10. nehmen die meisten Geräte ohnehin MP4 auf. */
export function brauchtZweitfassung(pfad) {
return extname(pfad).toLowerCase() !== ZWEITFASSUNG_ENDUNG;
}
/** Taugt eine vorhandene Zweitfassung noch?
*
* ==== WARUM DIESE FRAGE UEBERHAUPT GESTELLT WIRD (03.10.2026) ====
*
* Die ersten sechs Zweitfassungen sind entstanden, BEVOR das Loch
* am Anfang bekannt war (siehe `asetpts` weiter unten). Vier davon
* tragen es: 387 s, 164 s, 134 s und 63,8 s Zeitachse fuer jeweils
* wenige Sekunden Ton. Sie liegen auf dem Server und gehoeren dem
* Dienstbenutzer — von aussen sind sie nicht wegzuraeumen.
*
* EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG
* und die falsche: Eine Zahl, die jemand hochzaehlen muss, wird
* beim naechsten Mal vergessen. Gefragt wird deshalb die DATEI
* SELBST — und zwar nach dem, was schiefgehen kann: Passt ihre
* Zeitachse zu der Tonmenge, die sie traegt?
*
* GERECHNET WIRD AUS DEN TONRAHMEN. AAC packt 1024 Abtastwerte in
* einen Rahmen; Rahmenzahl mal 1024 geteilt durch die Abtastrate
* ist die echte Laenge. Weicht die angegebene Dauer um mehr als
* anderthalb Sekunden nach OBEN ab, steckt ein Loch darin.
*
* DRITTER AUSGANG: Sagt ffprobe nichts (fehlt, oder die Datei gibt
* die Rahmenzahl nicht her), gilt die Fassung als tauglich. Eine
* Datei wegzuwerfen, weil man sie nicht beurteilen kann, waere die
* schlechtere Antwort — und ein Lauf, der bei jedem Durchgang alles
* neu wandelt, waere eine Warnung, die immer kommt. */
export function zweitfassungTaugt(ziel) {
const probe = ffprobePfad();
if (!probe) return true;
try {
/* ==== MIT NAMEN LESEN, NICHT NACH REIHENFOLGE =================
Hier stand `-of default=…:nokey=1` und danach
`const [rahmen, rate, dauer] = …`. ffprobe gibt die Werte aber
in SEINER Reihenfolge aus, nicht in meiner: erst
`sample_rate`, dann `nb_frames`. Die Rechnung lief damit mit
48000 Rahmen und 95 Hz — und kam auf eine „echte Laenge" von
einer halben Million Sekunden. Die Funktion haette JEDE Datei
fuer tauglich erklaert, immer, und nie etwas gemeldet.
Das ist genau die Sorte, vor der die Hausregel warnt: Sie war
gruen und wertlos. Aufgefallen ist es nicht beim Lesen,
sondern beim Bauen der Gegenprobe — die Frage „kann sie
ueberhaupt NEIN sagen?" hat sie gestellt. */
const roh = execFileSync(probe, ["-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=nb_frames,sample_rate", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1", ziel], { encoding: "utf8", timeout: 20_000 });
const wert = (name) => {
const t = roh.match(new RegExp(`^${name}=(.+)$`, "m"));
return t ? Number(t[1]) : NaN;
};
const rahmen = wert("nb_frames");
const rate = wert("sample_rate");
const dauer = wert("duration");
/* DRITTER AUSGANG: Fehlt eine der drei Zahlen, ist die Frage
nicht zu beantworten -- und „nicht beurteilbar" heisst hier
„in Ordnung lassen". Eine Datei wegzuwerfen, weil man sie
nicht messen kann, waere die schlechtere Antwort. */
if (!Number.isFinite(rahmen) || !rahmen) return true;
if (!Number.isFinite(rate) || !rate) return true;
if (!Number.isFinite(dauer)) return true;
/* AAC packt 1024 Abtastwerte in einen Rahmen. */
const echt = (rahmen * 1024) / rate;
return dauer <= echt + 1.5;
} catch { return true; }
}
/** Eine Tondatei in AAC/MP4 danebenlegen.
*
* Gibt ein Versprechen auf `true` (liegt jetzt da), `false` (ging
* nicht) oder `null` (ffmpeg fehlt — konnte nicht nachsehen).
*
* SIE WIRFT NIE. Der Aufrufer hängt an einem Hochladen, und ein
* Fehler beim Umwandeln darf die Nachricht nicht verhindern.
*
* `-movflags +faststart` IST NICHT KOSMETIK: Ohne es steht die
* Inhaltsübersicht einer MP4 am ENDE der Datei. Ein Abspielgerät
* muss dann erst bis ans Ende lesen, bevor es anfangen kann — bei
* einer Teilanfrage über eine mobile Verbindung ist das genau das
* „lädt die ganze Zeit", über das sich Miss beklagt hat. */
export function zweitfassungBauen(pfad) {
return new Promise((fertig) => {
const ff = ffmpegPfad();
if (!ff) return fertig(null);
const ziel = zweitfassungPfad(pfad);
/* SCHON DA HEISST FERTIG. Der Nachrüstlauf geht mehrmals über
denselben Ordner; ohne diese Zeile würde er jedes Mal alles
neu wandeln. */
if (existsSync(ziel)) return fertig(true);
/* ==== ERST FERTIG, DANN SICHTBAR (03.10.2026) ================
GEFUNDEN VON DER PRÜFUNG, nicht beim Nachdenken: Sie holte die
zweite Fassung ab und bekam 44 Bytes.
ffmpeg LEGT DIE ZIELDATEI SOFORT AN und füllt sie danach — bei
`+faststart` schreibt es sie am Ende sogar noch einmal um. Die
Stelle, die fragt „gibt es die zweite Fassung?", sieht in
dieser Zeit eine Datei, die es zwar gibt, die aber noch nichts
enthält. Wer dann zuhört, bekommt ein Bruchstück.
Deshalb entsteht sie unter einem ANDEREN Namen und wird erst
am Ende umbenannt. Umbenennen im selben Ordner ist EIN Schritt:
Entweder die Datei ist vollständig da oder gar nicht.
DER BRUCHSTÜCKNAME ENDET NICHT AUF `.m4a` — sonst hielte
`zweitfassungDa()` ihn für das Ergebnis, und die Lücke wäre
nur umbenannt. Dafür muss das Format ausdrücklich dastehen
(`-f mp4`): ffmpeg wählt es sonst an der Endung, und `.teil`
kennt es nicht. (Auch das hat die Prüfung gefunden — der erste
Versuch mit Umbenennen erzeugte gar keine Datei mehr.) */
const bruchstueck = ziel + ".teil";
try { if (existsSync(bruchstueck)) unlinkSync(bruchstueck); } catch { /* egal */ }
execFile(ff, [
"-nostdin", "-hide_banner", "-loglevel", "error",
"-i", pfad,
/* KEIN BILD. Eine WebM kann ein Titelbild enthalten; in einer
Audiodatei hätte es nichts verloren und bringt ffmpeg bei
manchen Dateien zum Stolpern. */
"-vn",
/* ==== DAS LOCH AM ANFANG (03.10.2026, gemessen) =============
An einer echten Sprachnachricht aus dem Haus nachgesehen
(`mumqbw6v…webm`, 29.09.): Sie enthält 46 Pakete mit
zusammen 2,76 Sekunden Ton — verteilt über eine Zeitachse
von 63,8 Sekunden. Das erste Paket steht bei 0,0 s, das
zweite bei 61,1 s. Dazwischen: nichts.
WAS DAS FÜR DEN HÖRER HEISST: Das Abspielgerät zeigt 1:03,
springt beim Antippen in eine Minute Stille und fängt erst
danach an. Auf einem Handy über Mobilfunk sieht genau das
aus wie „lädt die ganze Zeit" — Miss' Satz aus Runde 5.
DAS IST NICHT UNSER FEHLER UND TROTZDEM UNSER PROBLEM:
`MediaRecorder` setzt diesen Versatz selbst. Reparieren
lässt er sich nur hier, beim Umwandeln.
`asetpts=N/SR/TB` vergibt die Zeitstempel neu, fortlaufend
ab null. Nachgemessen bleibt der Toninhalt dabei
unverändert: 260 KiB dekodiert vorher wie nachher, nur die
Länge geht von 63,8 s auf 2,76 s. Es wird also nichts
abgeschnitten, sondern ein Loch geschlossen.
WAS ES KOSTET: Eine Aufnahme mit einer echten Pause in der
Mitte rückt zusammen. Bei einer Sprachnachricht ist das
richtig so — `MediaRecorder` nimmt während einer Pause
ohnehin nichts auf, es entstünde also auch dort nur ein
Loch aus Stille. */
"-af", "asetpts=N/SR/TB",
"-c:a", "aac", "-b:a", RATE,
"-movflags", "+faststart",
/* NICHT ÜBERSCHREIBEN. Eine halbe Datei von einem
abgebrochenen Lauf soll auffallen, nicht stillschweigend
ersetzt werden -- und `existsSync` oben hat ohnehin schon
gefragt. */
"-f", "mp4", "-n", bruchstueck,
], { timeout: FRIST_MS }, (fehler) => {
const weg = () => {
try { if (existsSync(bruchstueck)) unlinkSync(bruchstueck); } catch { /* egal */ }
};
if (fehler) {
console.error(`[ton] Zweitfassung fuer ${basename(pfad)} ging nicht:`,
String(fehler.message || fehler).split("\n")[0]);
weg();
return fertig(false);
}
/* DER EINE SCHRITT, DER SIE SICHTBAR MACHT. Siehe oben: Erst
fertig, dann unter dem Namen, nach dem gefragt wird. */
try { renameSync(bruchstueck, ziel); } catch (f) {
console.error(`[ton] Zweitfassung fuer ${basename(pfad)} nicht umbenannt:`,
f?.message);
weg();
return fertig(false);
}
fertig(true);
});
});
}
+19 -6
View File
@@ -37,6 +37,7 @@ import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { createServer } from "node:https";
import { request as httpAnfrage } from "node:http";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const CREW = "crew.dogfather-universe.com";
@@ -127,12 +128,21 @@ export async function pruefeEchteFarben({ port = 4369, breite = 1500 } = {}) {
/* Chromium zwingt crew.… per eingebauter HSTS-Liste auf https --
deshalb derselbe selbst unterschriebene Vorbau wie überall. */
const zKey = join(ordner, "k.pem"), zCrt = join(ordner, "z.pem");
/* HIER BLEIBT DAS try/catch -- anders als an den 28 anderen
Stellen. Diese Datei hat einen dritten Ausgang: Sie sagt
„moeglich: false" samt Grund, statt den ganzen Lauf
abzubrechen. Ein fehlender https-Vorbau ist hier kein Befund
ueber die Farben, sondern „konnte nicht nachsehen".
Seit dem 29.09.2026 sucht `zertifikatBauen` openssl auch
ausserhalb des PATH -- der Fall sollte damit nicht mehr
eintreten. Der Ausgang bleibt trotzdem stehen: Eine Sicherung
abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags. */
try {
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", zKey, "-out", zCrt, "-days", "2", "-subj", `/CN=${CREW}`,
"-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
} catch {
return { moeglich: false, grund: "openssl ist nicht da (fuer den https-Vorbau)" };
zertifikatBauen(zKey, zCrt, CREW);
} catch (f) {
return { moeglich: false, grund: `openssl: ${f.message.split("\n")[0]}` };
}
const HTTPS_PORT = port + 1;
@@ -140,7 +150,10 @@ export async function pruefeEchteFarben({ port = 4369, breite = 1500 } = {}) {
(an, ab) => {
const weiter = httpAnfrage({
host: "127.0.0.1", port, path: an.url, method: an.method,
headers: { ...an.headers, host: CREW },
/* MIT PORT (01.10.2026) -- siehe pruef-werdegang: Ohne
ihn vergleicht `gleicheHerkunft` Herkunft und Host und
antwortet 403. */
headers: { ...an.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => { ab.writeHead(antwort.statusCode, antwort.headers); antwort.pipe(ab); });
weiter.on("error", () => { try { ab.destroy(); } catch { /* Zugabe */ } });
an.on("error", () => {});
+25
View File
@@ -43,7 +43,32 @@
/** @param {number} fristMs Nach dieser Zeit gilt der Lauf als haengend.
* @param {string} wer Name für die Meldung. */
import { standWache } from "./helfer-standwache.mjs";
export function notbremse(fristMs = 300_000, wer = "diese Pruefung") {
/* ==== UND HAT SICH DER STAND WAEHREND DES LAUFS GEAENDERT? =======
(01.10.2026, angestossen von der zweiten Claude-Sitzung.)
Am 06./07.09.2026 wurden fuenf Gesamtlaeufe gestartet und
dreimal davon WAEHREND des Laufs weitergebaut. Jeder dieser
Laeufe hat einen Stand gemessen, den es schon nicht mehr gab --
rund zwei Stunden Wartezeit ohne einen einzigen Befund.
Am 01.10. dieselbe Lage, nur anders: Zwei Sitzungen im
Verzeichnis, eine misst, die andere speichert. Aufgefallen ist
es nur, weil die andere es von sich aus gesagt hat.
HIER UND NICHT IN 179 DATEIEN: Wer eine Notbremse hat, bekommt
die Standwache mit. Das ist dieselbe Ueberlegung wie bei der
Notbremse selbst -- einzelne Dateien nachzuruesten hilft nur bis
zur naechsten ohne.
SIE HAELT NICHTS AN. Ein Lauf, dessen Grundlage sich verschoben
hat, ist nicht „nicht in Ordnung" -- er ist NICHT NACHSEHBAR.
Deshalb Rueckgabewert 3 und ein ausdruecklicher Satz, dass das
kein Befund am Code ist. */
standWache(wer);
let schonAus = false;
const raus = (art, was) => {
if (schonAus) return;
+112
View File
@@ -0,0 +1,112 @@
/* =====================================================================
helfer-openssl.mjs — wo liegt openssl?
WAS HIER SCHIEFGING (29.09.2026 gemessen):
Im nächtlichen Lauf standen dreizehn Prüfungen mit derselben Zeile
rot:
[uncaughtException] Error: spawnSync openssl ENOENT
Kein Programmfehler. Die Prüfungen brauchen einen https-Vorbau
(der Sitzungskeks des Hauses ist `secure`, über http kommt er nicht
an), und dafür erzeugen sie ein Wegwerf-Zertifikat mit `openssl`.
AUF DIESEM RECHNER LIEGT ES IN Git Bash:
C:\Program Files\Git\mingw64\bin\openssl.exe
C:\Program Files\Git\usr\bin\openssl.exe
Im PATH des Systems steht aber nur `C:\Program Files\Git\cmd` —
dort liegt `git.exe` und sonst nichts. Wer aus Git Bash heraus
startet, erbt die mingw-Pfade und merkt nie etwas. Die
Aufgabenplanung startet `node.exe` direkt, ohne Shell — und
bekommt sie nicht.
Das ist die unangenehmste Sorte Fehler: Bei mir grün, nachts rot,
und der Grund steht nicht im Code. Dreizehn dauerhaft rote Zeilen
in einer Notiz, die Filipe morgens liest, gewöhnen einem das
Hinsehen ab — und decken dabei die echten Befunde zu.
WAS DIESE DATEI TUT: Sie sucht `openssl` zuerst dort, wo es
ohnehin gefunden würde (PATH), und danach an den Orten, an denen
es auf einem Windows-Rechner mit Git tatsächlich liegt. Damit
läuft dieselbe Prüfung in beiden Umgebungen.
WARUM KEIN EINTRAG IN DEN SYSTEM-PATH: `mingw64\bin` enthält rund
hundert Programme mit Unix-Namen (`find`, `sort`, `link` …), die
gleichnamige Windows-Befehle verdecken. Das für eine einzige
Prüfung zu ändern, würde an ganz anderen Stellen Fehler
verursachen, die niemand mehr hierher zurückverfolgt.
===================================================================== */
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { delimiter, join } from "node:path";
/* Die Orte, an denen Git für Windows sein openssl ablegt. Beide
kommen vor: `mingw64` bei der 64-Bit-Fassung, `usr` bei der
mitgelieferten Unix-Umgebung. */
const BEKANNTE_ORTE = [
"C:/Program Files/Git/mingw64/bin/openssl.exe",
"C:/Program Files/Git/usr/bin/openssl.exe",
"C:/Program Files (x86)/Git/mingw64/bin/openssl.exe",
"C:/Program Files (x86)/Git/usr/bin/openssl.exe",
"C:/Program Files/OpenSSL-Win64/bin/openssl.exe",
];
let gemerkt = null;
/* IM PATH SUCHEN, OHNE `where` ODER `which` ZU BRAUCHEN. Beide sind
selbst Programme und können genauso fehlen; und `which` gibt es
unter Windows außerhalb von Git Bash gar nicht. */
const imPfad = () => {
const pfade = (process.env.PATH || "").split(delimiter).filter(Boolean);
const namen = process.platform === "win32"
? ["openssl.exe", "openssl.com", "openssl"]
: ["openssl"];
for (const p of pfade) {
for (const n of namen) {
const voll = join(p, n);
if (existsSync(voll)) return voll;
}
}
return null;
};
/* Der Pfad zu openssl — oder ein Abbruch mit Anleitung.
*
* DRITTER AUSGANG: Wird nichts gefunden, ist das weder „in Ordnung"
* noch „das Programm ist kaputt", sondern „konnte nicht nachsehen".
* Der Fehler sagt deshalb, WAS fehlt und WO man es herbekommt —
* `spawnSync openssl ENOENT` sagt nur, dass etwas nicht da war. */
export function opensslPfad() {
if (gemerkt) return gemerkt;
const gefunden = imPfad() || BEKANNTE_ORTE.find((o) => existsSync(o));
if (!gefunden) {
throw new Error(
"openssl ist nicht auffindbar. Es wird fuer den https-Vorbau gebraucht "
+ "(der Sitzungskeks ist `secure` und kommt ueber http nicht an).\n"
+ " Gesucht im PATH und an diesen Orten:\n"
+ BEKANNTE_ORTE.map((o) => " " + o).join("\n")
+ "\n Unter Windows bringt Git fuer Windows es mit; sonst "
+ "https://slproweb.com/products/Win32OpenSSL.html");
}
gemerkt = gefunden;
return gefunden;
}
/* Ein Wegwerf-Zertifikat für einen Hostnamen.
*
* WARUM HIER UND NICHT IN JEDER DATEI: Die Zeile stand
* neunundzwanzigmal fast gleich im Haus. Zwei Tage bevor ich das
* hier schrieb, war eine davon um ein `-addext` ärmer als die
* anderen — gemerkt hat es niemand, weil der Browser den fehlenden
* Alternativnamen erst bei einer Weiterleitung anmahnt. Eine Stelle
* kann nicht von sich selbst abweichen. */
export function zertifikatBauen(schluessel, zertifikat, host, tage = 2) {
execFileSync(opensslPfad(), [
"req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", schluessel, "-out", zertifikat,
"-days", String(tage), "-subj", `/CN=${host}`,
"-addext", `subjectAltName=DNS:${host}`,
], { stdio: "ignore" });
}
+157
View File
@@ -0,0 +1,157 @@
/* =====================================================================
helfer-png-alpha.mjs — wie viel von einem PNG ist überhaupt da?
---------------------------------------------------------------------
WOFÜR
Das Abzeichen einer Benachrichtigung (`badge`) wird von Android
AUSSCHLIESSLICH über seinen Alphakanal gelesen und einfarbig weiß
gemalt. Alles Bunte daran geht verloren.
Bis zum 29.09.2026 stand dort ein vollflächiges App-Symbol ohne
Transparenz. Dessen Alphakanal ist überall voll — in der
Statusleiste erschien deshalb ein ausgefüllter Klotz. Es stürzt
nichts ab, es sieht nur billig aus, und genau deshalb fällt so
etwas niemandem als Fehler auf.
Eine Prüfung, die nur nachsieht, ob die DATEI existiert, hätte den
Klotz nie gefunden — sie existierte ja. Gefragt ist der Inhalt:
Deckt das Bild 12 Prozent seiner Fläche (ein Zeichen) oder 100
(ein Klotz)?
---------------------------------------------------------------------
WARUM SELBST GERECHNET
Im Haus gibt es keine Bildbibliothek für Node, und eine dafür zu
installieren wäre eine Abhängigkeit für vierzig Zeilen. PNG ist an
dieser Stelle einfach: Kopf lesen, die Datenblöcke
zusammenhängen, auspacken, die Zeilenfilter rückgängig machen.
`zlib` liegt in Node bei.
---------------------------------------------------------------------
DREI AUSGÄNGE, NICHT ZWEI
Diese Datei kann ein PNG auch NICHT lesen können — ein anderes
Farbformat, 16 Bit je Kanal, ein verschachteltes Bild. Dann sagt
sie das (`lesbar: false, grund: "…"`) und behauptet nicht, die
Deckung sei null. Eine Null sähe aus wie ein Befund und wäre eine
Ausrede.
===================================================================== */
import { readFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
const MAGIE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** Wie viel eines PNG ist sichtbar?
*
* @returns {{lesbar: boolean, grund?: string, breite?: number,
* hoch?: number, deckung?: number, hatAlpha?: boolean}}
* `deckung` ist der Anteil der Bildpunkte mit Alpha über 127.
*/
export function pngDeckung(pfad) {
let roh;
try {
roh = readFileSync(pfad);
} catch (f) {
return { lesbar: false, grund: `nicht zu öffnen: ${f?.code || f?.message}` };
}
if (roh.length < 8 || !roh.subarray(0, 8).equals(MAGIE)) {
return { lesbar: false, grund: "kein PNG" };
}
/* ---- Die Blöcke durchgehen ---- */
let stelle = 8;
let kopf = null;
const daten = [];
while (stelle + 8 <= roh.length) {
const laenge = roh.readUInt32BE(stelle);
const art = roh.toString("ascii", stelle + 4, stelle + 8);
const inhalt = roh.subarray(stelle + 8, stelle + 8 + laenge);
if (art === "IHDR") {
kopf = {
breite: inhalt.readUInt32BE(0),
hoch: inhalt.readUInt32BE(4),
tiefe: inhalt[8],
farbe: inhalt[9],
verschachtelt: inhalt[12],
};
} else if (art === "IDAT") {
daten.push(inhalt);
} else if (art === "IEND") {
break;
}
stelle += 12 + laenge; // Länge + Art + Inhalt + Prüfsumme
}
if (!kopf) return { lesbar: false, grund: "kein Kopf (IHDR)" };
/* ---- Was diese Datei kann, und was nicht ---- */
const hatAlpha = kopf.farbe === 6 || kopf.farbe === 4;
if (!hatAlpha) {
/* KEIN ALPHAKANAL heißt: das Bild ist überall voll. Das ist eine
Antwort und kein Unvermögen -- genau dieser Fall war der Klotz
in der Statusleiste. */
return { lesbar: true, hatAlpha: false, deckung: 1,
breite: kopf.breite, hoch: kopf.hoch };
}
if (kopf.tiefe !== 8) {
return { lesbar: false, grund: `${kopf.tiefe} Bit je Kanal, ich kann nur 8` };
}
if (kopf.verschachtelt) {
return { lesbar: false, grund: "verschachtelt (Adam7)" };
}
if (!daten.length) return { lesbar: false, grund: "keine Bilddaten" };
let aus;
try {
aus = inflateSync(Buffer.concat(daten));
} catch (f) {
return { lesbar: false, grund: `nicht auszupacken: ${f?.message}` };
}
/* ---- Die Zeilenfilter rückgängig machen ----
PNG speichert jede Zeile mit einem Filterbyte davor. Ohne das
Rückrechnen stünden dort Differenzen und keine Farbwerte -- und
eine Deckung, die aus Differenzen gerechnet ist, wäre eine Zahl
ohne Bedeutung, die trotzdem plausibel aussieht. */
const kanaele = kopf.farbe === 6 ? 4 : 2; // RGBA oder Grau+Alpha
const zeileBytes = kopf.breite * kanaele;
const noetig = (zeileBytes + 1) * kopf.hoch;
if (aus.length < noetig) {
return { lesbar: false, grund: `zu wenig Daten (${aus.length} statt ${noetig})` };
}
let voll = 0;
let vorige = Buffer.alloc(zeileBytes);
let p = 0;
for (let y = 0; y < kopf.hoch; y++) {
const filter = aus[p++];
const zeile = Buffer.from(aus.subarray(p, p + zeileBytes));
p += zeileBytes;
for (let i = 0; i < zeileBytes; i++) {
const a = i >= kanaele ? zeile[i - kanaele] : 0; // links
const b = vorige[i]; // oben
const c = i >= kanaele ? vorige[i - kanaele] : 0; // oben links
let w = zeile[i];
if (filter === 1) w += a;
else if (filter === 2) w += b;
else if (filter === 3) w += (a + b) >> 1;
else if (filter === 4) {
const s = a + b - c;
const da = Math.abs(s - a), dbb = Math.abs(s - b), dc = Math.abs(s - c);
w += (da <= dbb && da <= dc) ? a : (dbb <= dc ? b : c);
}
zeile[i] = w & 0xff;
}
for (let x = kanaele - 1; x < zeileBytes; x += kanaele) {
if (zeile[x] > 127) voll++;
}
vorige = zeile;
}
return {
lesbar: true, hatAlpha: true,
breite: kopf.breite, hoch: kopf.hoch,
deckung: voll / (kopf.breite * kopf.hoch),
};
}
+61
View File
@@ -0,0 +1,61 @@
/* =====================================================================
EIN PNG SCHREIBEN (03.10.2026)
Das Gegenstueck zu helfer-png.mjs, das eines LIEST. Gebraucht fuer
die Eventkarten-Pruefung: Ob der Titel auf einer Buehne lesbar
bleibt, haengt davon ab, was fuer ein Banner darunter liegt -- und
der interessante Fall ist nicht das dunkle, sondern das HELLE, das
jemand spaeter hochlaedt. Ein solches Bild muss die Pruefung also
selbst herstellen koennen.
WARUM SELBST GESCHRIEBEN: Dieselbe Begruendung wie bei helfer-png.mjs
-- das Projekt hat kein Bildmodul, und fuer ein Testbild eines
nachzuinstallieren waere eine Abhaengigkeit, die jemand pflegen
muss. Node bringt zlib mit; ein unkomprimiertes RGB-PNG ist danach
nur noch Pruefsumme und Zeilenfilter.
===================================================================== */
import { deflateSync } from "node:zlib";
const tabelle = (() => {
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = tabelle[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function teil(name, inhalt) {
const kopf = Buffer.alloc(8);
kopf.writeUInt32BE(inhalt.length, 0);
kopf.write(name, 4, "ascii");
const pruef = Buffer.alloc(4);
pruef.writeUInt32BE(crc32(Buffer.concat([Buffer.from(name, "ascii"), inhalt])), 0);
return Buffer.concat([kopf, inhalt, pruef]);
}
/** male(x, y, b, h) -> [r,g,b] */
export function pngBauen(b, h, male) {
const roh = Buffer.alloc((b * 3 + 1) * h);
let p = 0;
for (let y = 0; y < h; y++) {
roh[p++] = 0;
for (let x = 0; x < b; x++) {
const f = male(x, y, b, h);
roh[p++] = f[0]; roh[p++] = f[1]; roh[p++] = f[2];
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(b, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
teil("IHDR", ihdr),
teil("IDAT", deflateSync(roh, { level: 9 })),
teil("IEND", Buffer.alloc(0)),
]);
}
+63 -7
View File
@@ -180,6 +180,40 @@ import { basename, dirname } from "node:path";
* werden: "irgendwo ueber 4400" haette plausibel geklungen. */
const BASIS = 5000;
const PRO_DATEI = 2;
/** Ab hier die MESSDATEIEN (01.10.2026).
*
* Sie hatten ihre Nummern von Hand. Nachgemessen lagen ACHT davon
* IM abgeleiteten Bereich der Pruefungen: Der ist bei 204 Dateien
* dicht von 5000 bis 5409 belegt, und 5387/5397/5399/5401/5403/5405
* liegen mittendrin. Zwei Laeufe konnten also denselben Port
* bekommen, ohne dass es jemand merkt. Untereinander war fuenfmal
* dieselbe Nummer doppelt vergeben (5397, 5399, 5461, 5483, 5491).
*
* Aufgefallen ist es, weil mess-reaktion und mess-buehne beide auf
* 5483 lagen und ein haengengebliebener Lauf einen ganzen Messlauf
* gekostet hat.
*
* 5900 UND NICHT 5500: Zwischen dem Ende der Pruefungen (5409) und
* hier bleibt Platz fuer 245 weitere Pruefdateien -- bei 5500 waeren
* es nur 45, und die waeren in einem Jahr aufgebraucht. Nach oben:
* 5900 + 24 Dateien * 2 = 5948; plus AUSWEICHEN (4000) sind das
* 9948, also unter 10080, der naechsten gesperrten Nummer. Beides
* nachgerechnet, nicht geschaetzt -- dieselbe Ueberlegung wie bei
* BASIS, wo eine geschaetzte 4500 danebengelegen haette.
*
* DIE ZAHLEN HIER SIND EIN STAND, KEINE WAHRHEIT. Noch am selben
* Tag kamen zwei Pruefdateien aus einer anderen Sitzung dazu, und
* aus 5409/245 wurde 5413/243. Genau dafuer ist die Ableitung da --
* es musste niemand etwas nachtragen. Wer die heutige Zahl braucht,
* liest sie nicht hier, sondern laesst pruef-portnummern laufen:
* „die Pruefungen enden bei X, die Messungen fangen bei Y an".
*
* AUSGELEITET aus demselben Grund wie AUSWEICHEN und GESPERRT:
* pruef-portnummern.mjs weist nach, dass die beiden Bereiche sich
* nicht beruehren -- und muss dafuer DIESE Zahl ansehen, nicht eine
* Abschrift, die beim naechsten Verschieben stehen bleibt. */
export const MESS_BASIS = 5900;
/** Um wie viel ausgewichen wird, wenn das System einen Port nicht
* hergibt. 4000 liegt sicher oberhalb des ganzen abgeleiteten
* Bereichs (BASIS bis BASIS+5000 in Schritten von 2, bei derzeit
@@ -244,8 +278,8 @@ export const GESPERRT = new Set([
* Ausnahmen musste dafuer niemand pflegen: Uebersprungen wird nach
* der Regel, nicht nach einer Tabelle von Sonderfaellen.
*/
export function portNummer(nte) {
let p = BASIS;
export function portNummer(nte, basis = BASIS) {
let p = basis;
let uebrig = nte;
while (true) {
if (!GESPERRT.has(p)) {
@@ -253,8 +287,8 @@ export function portNummer(nte) {
uebrig -= 1;
}
p += 1;
if (p > BASIS + 5000) {
throw new Error("[Port] Kein freier Nummernbereich mehr oberhalb von " + BASIS + ".");
if (p > basis + 5000) {
throw new Error("[Port] Kein freier Nummernbereich mehr oberhalb von " + basis + ".");
}
}
}
@@ -263,18 +297,40 @@ export async function eigenerPort(meta, wer = null, nr = 0) {
const pfad = fileURLToPath(meta.url);
const mein = basename(pfad);
const ordner = dirname(pfad);
/* ZWEI SORTEN, ZWEI BEREICHE (01.10.2026). Eine Messdatei zaehlt
unter ihresgleichen, eine Pruefdatei unter ihresgleichen -- sonst
verschoebe eine neue Pruefung die Nummern aller Messungen und
umgekehrt. Welche Sorte es ist, sagt der Dateiname. */
const istMess = mein.startsWith("mess-");
const vorsilbe = istMess ? "mess-" : "pruef-";
const alle = readdirSync(ordner)
.filter((f) => f.startsWith("pruef-") && f.endsWith(".mjs"))
.filter((f) => f.startsWith(vorsilbe) && f.endsWith(".mjs"))
.sort();
const stelle = alle.indexOf(mein);
if (stelle < 0) {
throw new Error(
`[Port] ${mein} steht nicht in der Liste der Pruefdateien in ${ordner}.\n`
`[Port] ${mein} steht nicht in der Liste der ${vorsilbe}Dateien in ${ordner}.\n`
+ " Die Nummer wird aus der Stelle im Alphabet abgeleitet -- ohne\n"
+ " Stelle gibt es keine Nummer, und geraten wird nicht.");
}
if (nr < 0 || nr >= PRO_DATEI) {
throw new Error(`[Port] Es gibt ${PRO_DATEI} Nummern je Datei (0 und 1), nicht ${nr}.`);
}
return portMussFreiSein(portNummer(stelle * PRO_DATEI + nr), wer || mein.replace(/\.mjs$/, ""));
const port = istMess
? portNummer(stelle * PRO_DATEI + nr, MESS_BASIS)
: portNummer(stelle * PRO_DATEI + nr);
/* DIE WACHE GEGEN DAS ZUSAMMENWACHSEN. Kommen so viele
Pruefdateien dazu, dass ihr Bereich den der Messungen erreicht,
bekaemen zwei Laeufe still dieselbe Nummer -- genau der Fehler,
den diese Ableitung beseitigt. Dann lieber ein lauter Abbruch
mit Anleitung. */
if (!istMess && port >= MESS_BASIS) {
throw new Error(
`[Port] Der Bereich der Pruefungen ist bei ${port} angekommen und\n`
+ ` stoesst an den der Messdateien (ab ${MESS_BASIS}).\n`
+ " MESS_BASIS in helfer-port.mjs hochsetzen -- und dabei nachsehen,\n"
+ " dass MESS_BASIS + 4000 (AUSWEICHEN) unter 10080 bleibt.");
}
return portMussFreiSein(port, wer || mein.replace(/\.mjs$/, ""));
}
+61
View File
@@ -0,0 +1,61 @@
/* =====================================================================
EINE BENACHRICHTIGUNG AUFMACHEN — so, wie der Browser es tut.
RFC 8291, in die andere Richtung gelesen: aus dem verschlüsselten
Paket wieder Klartext machen, mit dem privaten Schlüssel des
Geräts.
WARUM ALS EIGENE DATEI (30.09.2026)
Das stand als lokale Funktion in `pruef-push-weg.mjs`. Seit heute
braucht `pruef-abzeichen.mjs` dieselbe Rechnung — sie muss in den
Inhalt der Nachricht hineinsehen, um zu prüfen, dass die Zahl fürs
App-Symbol wirklich mitkommt.
Die Rechnung ein zweites Mal abzuschreiben wäre genau die Sorte
Doppelung, die sich später auseinanderentwickelt: Ändert sich am
Verfahren etwas, wird eine der beiden Abschriften nachgezogen, die
andere nicht — und dann meldet eine Prüfung einen Fehler, den es
nicht gibt, oder schlimmer: keinen, den es gibt.
NUR FÜR PRÜFUNGEN. Im Betrieb macht der Browser das selbst; der
Server hat den privaten Schlüssel des Geräts nie gesehen und soll
ihn auch nie sehen.
===================================================================== */
import { hkdfSync, createDecipheriv } from "node:crypto";
/** Macht ein aes128gcm-Paket wieder auf.
*
* @param {Buffer} paket was auf der Leitung lag
* @param {object} wer der ECDH des Geräts (mit privatem Teil)
* @param {Buffer} geheim16 die 16 Bytes `auth` der Anmeldung
* @returns {string} der Klartext
*
* Wirft, wenn Schlüssel oder Geheimnis nicht passen — und genau das
* ist die Gegenprobe in `pruef-push-weg`: Mit dem falschen Schlüssel
* MUSS es scheitern.
*/
export function pushAufmachen(paket, wer, geheim16) {
const salz = paket.subarray(0, 16);
const len = paket.readUInt8(20);
const senderOeff = paket.subarray(21, 21 + len);
const rest = paket.subarray(21 + len);
const gemeinsam = wer.computeSecret(senderOeff);
const prkInfo = Buffer.concat([
Buffer.from("WebPush: info\0"), wer.getPublicKey(), senderOeff,
]);
const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, geheim16, prkInfo, 32));
const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
Buffer.from("Content-Encoding: nonce\0"), 12));
const tag = rest.subarray(rest.length - 16);
const daten = rest.subarray(0, rest.length - 16);
const dec = createDecipheriv("aes-128-gcm", cek, nonce);
dec.setAuthTag(tag);
const klar = Buffer.concat([dec.update(daten), dec.final()]);
return klar.subarray(0, klar.length - 1).toString("utf8"); // Polsterbyte weg
}
+164
View File
@@ -0,0 +1,164 @@
/* =====================================================================
helfer-standwache.mjs — hat sich der Stand WÄHREND des Laufs
geändert?
---------------------------------------------------------------------
DER FEHLER, DEN DAS SICHTBAR MACHT
Am 06./07.09.2026 wurden fünf Gesamtläufe gestartet und dreimal
davon WÄHREND des Laufs weitergebaut. Jeder dieser Läufe hat damit
einen Stand gemessen, den es schon nicht mehr gab, und war wertlos
— rund zwei Stunden Wartezeit ohne einen einzigen zusätzlichen
Befund.
Am 01.10.2026 ist dieselbe Lage zum zweiten Mal entstanden, nur
anders: Zwei Claude-Sitzungen arbeiteten gleichzeitig im
Verzeichnis. Während die eine Prüfläufe fuhr, hat die andere
Dateien gespeichert. Aufgefallen ist es nur, weil die zweite
Sitzung es von sich aus gesagt hat.
---------------------------------------------------------------------
WARUM NICHT DAS ARBEITSSCHLOSS FRAGEN
Das Schloss beantwortet „arbeitet hier jemand". Das ist die falsche
Frage, und zwar in beide Richtungen:
* Es blockiert zu viel: Wer zwei Stunden lang am Schloss sitzt,
hätte in dieser Zeit keinen einzigen Prüflauf mehr. Eine
Sicherung, die das eigene Arbeiten anhält, wird abgeschafft.
* Es blockiert zu wenig: Filipe, der in VS Code speichert, hat
kein Schloss. Mein eigenes Werkzeug, das nebenbei stempelt,
auch nicht.
Die Frage, auf die es ankommt, ist eine MESSUNG und keine
Zuständigkeit: Ist der Stand am Ende noch derselbe wie am Anfang?
Das trifft jede Quelle — die andere Sitzung, den Menschen am
Editor, ein Werkzeug von mir.
---------------------------------------------------------------------
WAS GEZÄHLT WIRD, UND WAS NICHT
Gezählt wird, was eine Messung verfälschen kann: der Quelltext der
Seiten und des Servers. Nicht gezählt werden Bilder — die
Messdateien legen selbst welche ab (`server/mess-*.png`), und eine
Wache, die bei jedem Bildschirmfoto anschlägt, ist eine Warnung,
die immer kommt.
---------------------------------------------------------------------
SIE HÄLT NICHTS AN
Sie meldet. Ein Lauf, dessen Grundlage sich verschoben hat, ist
nicht „nicht in Ordnung" — er ist NICHT NACHSEHBAR, der dritte
Ausgang. Deshalb setzt sie den Rückgabewert auf 3 und sagt
ausdrücklich, dass das kein Befund am Code ist. Ein grüner Haken
über einen Stand, den es nicht mehr gibt, wäre das Schlimmere.
===================================================================== */
import { readdirSync, statSync } from "node:fs";
import { join, dirname, extname } from "node:path";
import { fileURLToPath } from "node:url";
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
/* Wo Quelltext liegt, der eine Messung verändern kann. */
const ORTE = ["workspace", "assets", "server", "webdesign"];
const ZAEHLT = new Set([".js", ".mjs", ".css", ".html", ".webmanifest"]);
/** Ein Fingerabdruck des Standes: Anzahl, Gesamtgröße, jüngste Änderung.
*
* KEIN HASH ÜBER DEN INHALT. Der wäre genauer und kostet bei rund
* tausend Dateien ein Mehrfaches — und genauer muss es nicht sein:
* Gesucht ist „hat jemand gespeichert", und das ändert immer
* mindestens die Änderungszeit.
*/
export function standNehmen() {
let anzahl = 0, bytes = 0, jüngste = 0;
const gehen = (ordner, tiefe) => {
let eintraege;
try { eintraege = readdirSync(ordner, { withFileTypes: true }); }
catch { return; }
for (const e of eintraege) {
if (e.name.startsWith(".") || e.name === "node_modules") continue;
const voll = join(ordner, e.name);
if (e.isDirectory()) { if (tiefe < 5) gehen(voll, tiefe + 1); continue; }
if (!ZAEHLT.has(extname(e.name))) continue;
try {
const s = statSync(voll);
anzahl += 1;
bytes += s.size;
if (s.mtimeMs > jüngste) jüngste = s.mtimeMs;
} catch { /* im Flug verschwunden -- der Vergleich merkt es */ }
}
};
for (const ort of ORTE) gehen(join(WURZEL, ort), 0);
/* Die Seiten im Wurzelverzeichnis gehören dazu, aber nicht alles
darunter -- deshalb hier flach. */
try {
for (const name of readdirSync(WURZEL)) {
if (!ZAEHLT.has(extname(name))) continue;
const s = statSync(join(WURZEL, name));
anzahl += 1; bytes += s.size;
if (s.mtimeMs > jüngste) jüngste = s.mtimeMs;
}
} catch { /* egal */ }
return { anzahl, bytes, jüngste, genommen: Date.now() };
}
/** Vergleicht und meldet. Gibt `true` zurück, wenn der Stand hielt. */
export function standPruefen(vorher, wer = "dieser Lauf") {
if (!vorher) return true;
const jetzt = standNehmen();
const gleich = jetzt.anzahl === vorher.anzahl
&& jetzt.bytes === vorher.bytes
&& jetzt.jüngste === vorher.jüngste;
if (gleich) return true;
const sek = Math.round((jetzt.genommen - vorher.genommen) / 1000);
console.error("");
console.error("\x1b[33m -- NICHT NACHSEHBAR: Der Stand hat sich WÄHREND des Laufs"
+ " geändert.\x1b[0m");
console.error(` ${wer} lief ${sek} Sekunden. In dieser Zeit:`);
if (jetzt.anzahl !== vorher.anzahl) {
console.error(` Dateien: ${vorher.anzahl} -> ${jetzt.anzahl}`);
}
if (jetzt.bytes !== vorher.bytes) {
console.error(` Groesse: ${vorher.bytes} -> ${jetzt.bytes} Bytes`);
}
if (jetzt.jüngste !== vorher.jüngste) {
console.error(" jüngste Änderung: "
+ `${new Date(vorher.jüngste).toLocaleTimeString("de-DE")}`
+ ` -> ${new Date(jetzt.jüngste).toLocaleTimeString("de-DE")}`);
}
console.error("");
console.error(" DAS IST KEIN BEFUND AM CODE. Es heisst, dass dieser Lauf");
console.error(" einen Stand gemessen hat, den es am Ende nicht mehr gab —");
console.error(" genau das hat am 06./07.09.2026 zwei Stunden Wartezeit");
console.error(" ohne einen einzigen Befund gekostet.");
console.error("");
console.error(" Wer hat gespeichert? Eine zweite Sitzung, ein Editor, ein");
console.error(" Werkzeug? Nachsehen: node tools/arbeitsschloss.mjs ansehen");
console.error(" Dann noch einmal laufen lassen.");
console.error("");
return false;
}
/** Nimmt den Stand und vergleicht ihn beim Programmende von selbst.
*
* SETZT DEN RÜCKGABEWERT AUF 3 — den dritten Ausgang. Nicht auf 1:
* „nicht in Ordnung" wäre eine Aussage über den Code, und die hat
* dieser Lauf nicht gemacht. Und nicht auf 0: Ein grüner Haken über
* einen Stand, den es nicht mehr gibt, ist das Schlimmere.
*
* Nur wenn der Lauf sonst erfolgreich war: Ein echter Befund
* (Rückgabe 1) ist die wichtigere Nachricht und bleibt stehen.
*/
export function standWache(wer) {
const vorher = standNehmen();
process.on("exit", () => {
const hielt = standPruefen(vorher, wer);
if (!hielt && (process.exitCode === 0 || process.exitCode === undefined)) {
process.exitCode = 3;
}
});
return vorher;
}
+65 -3
View File
@@ -37,6 +37,12 @@ import { auskunftRouter } from "./workspace-auskunft.js";
import { uebernahmeRouter } from "./workspace-uebernahme.js";
import { befindenRouter } from "./workspace-befinden.js";
import { hilfeRouter } from "./workspace-hilfe.js";
import { supportRouter } from "./workspace-support.js";
import { notizenRouter } from "./workspace-notizen.js";
import { reaktionRouter } from "./workspace-reaktion.js";
import { spendenRouter } from "./workspace-spenden.js";
import { buehneRouter } from "./workspace-buehne.js";
import { unterstuetzungRouter } from "./workspace-unterstuetzung.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
@@ -60,11 +66,12 @@ import { ampelRouter } from "./workspace-ampel.js";
import { checklisteRouter } from "./workspace-checkliste.js";
import { teamlageRouter } from "./workspace-teamlage.js";
import { pushRouter, pushStarten } from "./workspace-push.js";
import { chatRouter } from "./workspace-chat.js";
import { chatRouter, toeneNachruestenStarten } from "./workspace-chat.js";
import { anrufRouter } from "./workspace-anruf.js";
import { videoRouter, videoWacheStarten } from "./workspace-video.js";
import { bewerbungRouter } from "./workspace-bewerbung.js";
import { leistungRouter } from "./workspace-leistung.js";
import { managerZieleRouter } from "./workspace-manager-ziele.js";
import { fruehwarnungRouter } from "./workspace-fruehwarnung.js";
import { icsRouter } from "./workspace-ics.js";
@@ -100,9 +107,24 @@ app.use((req, res, next) => {
`(self)` und nicht `*`: Nur diese Seite selbst, keine
eingebetteten Fremdinhalte. */
const chatSeite = /^\/workspace\/chat\.html/.test(req.path);
/* DIE ZWEITE STELLE KAM AM 28.09.2026 DAZU: die Reaction. Sie
zeigt die Kamera des Hosts und bis zu zwei Gaeste, ueber
denselben Weg wie der Anruf im Chat.
Und sie kam als MENGE dazu, nicht als zweiter Sonderfall: Beim
Chat stand die Bedingung als Einzelfall im Code, und ein zweiter
Einzelfall daneben ist die Bauweise, bei der der dritte
vergessen wird. Vergessen heisst hier: Die Sendung startet, alle
sind da, und die Kamera geht nicht an -- mit einer Meldung, die
auf die falsche Faehrte fuehrt.
pruef-kamera-richtlinie.mjs haelt die Menge gegen den Quelltext:
Jede Seite, deren Skripte `getUserMedia` aufrufen, muss hier
stehen -- und jede andere darf es nicht. */
const MIT_KAMERA = [/^\/workspace\/chat\.html/, /^\/workspace\/reaktion\.html/];
const darfKamera = MIT_KAMERA.some((m) => m.test(req.path));
res.setHeader("Permissions-Policy",
chatSeite
darfKamera
? "geolocation=(), microphone=(self), camera=(self), payment=()"
: "geolocation=(), microphone=(), camera=(), payment=()");
@@ -214,6 +236,12 @@ app.use("/workspace/api", sichtSetzen);
Pfade unter /workspace/api. Die Rueckmeldung der Sicherungskopie kommt
aber von einem Skript ohne Sitzung und wuerde dort abgewiesen, bevor
ihr Schluessel ueberhaupt geprueft wird. */
/* EBENFALLS VOR aufgabenRouter, aus demselben Grund: Die zwei
Buehnenquellen fuer OBS kommen OHNE Sitzung -- geprueft wird ein
Schluessel in der Adresse. Weiter unten eingehaengt wuerden sie mit
401 abgewiesen, bevor der Schluessel ueberhaupt angesehen wird.
Gemessen: genau das ist beim ersten Versuch passiert. */
app.use(buehneRouter);
app.use(sicherungRouter);
/* Ebenfalls VOR aufgabenRouter: Das Profilbild wird von <img src="..."/>
geholt. Der Browser schickt dabei den Sitzungs-Cookie zwar mit, aber
@@ -309,9 +337,25 @@ app.use(auskunftRouter);
app.use(uebernahmeRouter);
app.use(befindenRouter);
app.use(hilfeRouter);
app.use(supportRouter);
/* Der Notizblock (26.09.2026): vier Rollen des Teamhauses, und jeder
sieht nur seinen eigenen -- auch DogFather. Begruendung im Kopf von
notiz-tabellen.js. */
app.use(notizenRouter);
app.use(reaktionRouter);
app.use(spendenRouter);
/* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas
beitragen kann – lesen alle, ändern nur DogFather. */
app.use(unterstuetzungRouter);
app.use(bewerbungRouter);
app.use(reportRouter);
app.use(scoutRouter);
/* Manager-Ziele (02.10.2026): die vier festen Monatsaufgaben. Steht
hinter dem Scout-CRM, weil es dessen Pipeline mitbenutzt -- die
Reihenfolge der Router entscheidet hier zwar nichts (die Wege
ueberschneiden sich nicht), aber wer suchen muss, sucht sie
nebeneinander. */
app.use(managerZieleRouter);
app.use(callRouter);
app.use(hinweisRouter);
app.use(zentraleRouter);
@@ -326,6 +370,10 @@ app.use(contentRouter);
Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */
sicherungStarten();
aufbewahrungStarten();
/* Sprachnachrichten in eine Fassung bringen, die jedes Geraet
abspielt (Miss im Support, Meldung #12). Der Lauf meldet sich
nur, wenn er etwas getan hat. */
toeneNachruestenStarten();
/* Sieht taeglich nach, ob die verlinkten Videos bei TikTok noch da
sind. Ein Knopf, der ins Leere fuehrt, ist schlimmer als kein
Knopf -- man klickt ihn ja. */
@@ -563,3 +611,17 @@ process.on("uncaughtException", (fehler) => console.error("[uncaughtException]",
app.listen(PORT, "127.0.0.1", () => {
console.log(`DogFather Universe – Server läuft auf http://127.0.0.1:${PORT}`);
});
/* =====================================================================
DIE ANWENDUNG NACH AUSSEN (26.09.2026)
Nur fuer Pruefungen. `pruef-notizen` geht damit den Routenbaum von
express durch und sucht Wege, die zweimal vergeben sind -- genau so
ein Fall hat beim Bau des Notizblocks eine Stunde gekostet
(DELETE /workspace/api/notizen/:id gab es schon in
workspace-aufgaben.js, und express nimmt schweigend die erste).
Der Export aendert am Betrieb nichts: Wer diese Datei importiert,
startet den Server ohnehin -- das tut sie beim Laden. Er macht nur
sichtbar, was vorher nur im Speicher stand. */
export default app;
+22 -3
View File
@@ -76,6 +76,25 @@ import { join, normalize } from "node:path";
/* Spotify: die zwei Player im Musik-Feld (main.js, jede Seite). */
const SPOTIFY = "https://open.spotify.com";
/* ==== YOUTUBE (28.09.2026, fuer die Reaction) ======================
Die Reaction zeigt ein YouTube-Video, und jeder Zuschauer laedt es
SELBST -- weitergesendet wird nur der Spielstand (siehe
reaktion-tabellen.js). Dafuer braucht das Dokument drei Dinge:
RAHMEN der eingebettete Player. `youtube-nocookie.com` setzt
keine Werbekennungen, solange niemand abspielt; das ist
der Grund, warum diese Adresse zuerst steht.
SKRIPT die Einbett-API (`/iframe_api`) liegt auf www.youtube.com
und laedt von dort ihren eigentlichen Teil nach. Ohne
sie liesse sich der Player nicht fernsteuern -- und genau
das ist der Kern: Alle sehen dieselbe Sekunde.
BILDER die Vorschaubilder (i.ytimg.com), die im Wartebereich
stehen, wenn der Host kein eigenes Bild hinterlegt hat. */
const YOUTUBE_RAHMEN = "https://www.youtube-nocookie.com https://www.youtube.com";
const YOUTUBE_SKRIPT = "https://www.youtube.com";
const YOUTUBE_BILDER = "https://i.ytimg.com https://i9.ytimg.com";
/* PayPal: das SDK selbst liegt auf www.paypal.com, Bilder/Schriften der
Knöpfe auf www.paypalobjects.com. Der Bezahlvorgang läuft anschließend
in einem Rahmen von www.paypal.com.
@@ -241,7 +260,7 @@ export function inhaltsrichtlinie(wurzel) {
wenn ohnehin etwas grundlegend nicht stimmt. */
if (!summen) return next();
const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT].join(" ");
const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT, YOUTUBE_SKRIPT].join(" ");
res.setHeader("Content-Security-Policy", [
"default-src 'self'",
@@ -259,7 +278,7 @@ export function inhaltsrichtlinie(wurzel) {
ihn: Der Deploy hätte Erfolg gemeldet, der Server wäre
gestartet, und der Schaden wäre erst jemandem aufgefallen, der
die Seite ansieht. */
`img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER}`,
`img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER} ${YOUTUBE_BILDER}`,
"font-src 'self'",
/* Die Seite spricht mit dem eigenen Server und mit dem internen
Bereich unter postfach. -- mehr nicht. Ein eingeschleustes
@@ -302,7 +321,7 @@ export function inhaltsrichtlinie(wurzel) {
pruef-musik.mjs und pruef-kasse.mjs prüfen das im echten Browser
mit -- inklusive der Kasse, die vorher genau hier gescheitert
wäre. */
`frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`,
`frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN} ${YOUTUBE_RAHMEN}`,
"worker-src 'self'",
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren.
+501
View File
@@ -0,0 +1,501 @@
/* =====================================================================
MANAGER-ZIELE — die Tabellen (02.10.2026)
=====================================================================
Vier feste Monatsaufgaben für Scouts, Manager, DogFather und Spicy
Media: Creator rekrutieren, Manager-Meeting, Schulung oder Community
Talk, Werbung auf TikTok. Jede mit einer Zielzahl, einer Ampel und
einem Monatsschnitt.
WARUM EINE EIGENE DATEI: Die Tabellen hängen an `personen` (CASCADE)
und an `leads`. Beide entstehen im Bauplan von workspace.js, und
workspace-manager-ziele.js importiert AUS workspace.js — stünden die
Tabellen dort, wäre das ein Importkreis. Über einen Kreis kommen
Konstanten als `undefined` an, ohne dass irgendwo ein Fehler
erscheint; hilfe-tabellen.js, support-tabellen.js und
notiz-tabellen.js stehen aus demselben Grund getrennt.
---------------------------------------------------------------------
DREI ENTSCHEIDUNGEN, DIE MAN SPÄTER NICHT MEHR BILLIG ÄNDERN KANN
1. DAS ZIEL WIRD PRO MONAT EINGEFROREN, nicht als eine laufende
Einstellung geführt.
Filipe wollte die Zielzahlen änderbar haben, wirksam ab dem
nächsten Monat. Läge nur EIN aktueller Wert in `einstellungen`,
dann schriebe jede spätere Änderung rückwirkend den gesamten
Verlauf um: Ein Monat, der mit 2/2 als „erreicht" abgeschlossen
war, stünde nach einer Erhöhung auf 4 plötzlich als „nicht
erreicht" da — ohne dass jemand etwas getan hätte. Ein Verlauf,
der sich rückwirkend ändert, ist kein Verlauf.
Deshalb: `mz_ziel` hat den Monat im Schlüssel. Beim ersten
Zugriff auf einen Monat wird er festgeschrieben (siehe
`monatSichern` im Fachmodul), und ab da steht er fest.
2. DIE SPERRE VERGANGENER MONATE SITZT IN DER DATENBANK, nicht nur
im Code.
Dieselbe Überlegung wie bei workspace-schulung.js: „Ein Schutz,
der nur im Code steht, ist beim nächsten neuen Weg zur Datenbank
wieder weg." Drei Trigger lehnen jede Änderung an einem
abgeschlossenen Monat ab.
DIE AUSNAHME FÜR DOGFATHER LÄSST SICH IN SQLITE NICHT ÜBER DIE
SITZUNG ABFRAGEN — es gibt keine Sitzungsvariablen. Deshalb
`mz_freigabe`: eine einzige Zeile, die seine Route INNERHALB
derselben Transaktion auf 1 setzt und danach wieder auf 0. Das
ist ehrlicher als ein Trigger, den man mit einem Kommentar
„gilt nicht für den Chef" einfach weglässt — die Ausnahme ist
damit ein sichtbarer, zählbarer Vorgang und kein Loch.
WARUM DIE FREIGABE NICHT VERGESSEN WERDEN KANN: Sie wird im
`finally` einer synchronen Transaktion zurückgesetzt, und
`mz_freigabe.bis` trägt zusätzlich einen Zeitpunkt. Was älter
als zwei Minuten ist, gilt als nicht freigegeben — ein
vergessenes Schloss wäre sonst dasselbe wie gar keines
(dieselbe Erfahrung wie beim Arbeitsschloss in der Buchhaltung).
3. DER AKTUELLE MONAT STEHT IN EINER TABELLE, nicht in `strftime`.
Ein Trigger müsste sonst `strftime('%Y-%m','now','localtime')`
rechnen — und `localtime` ist die Zeitzone des SERVERS. Steht der
auf UTC, wäre am 1. eines Monats zwischen 00:00 und 02:00 Uhr
deutscher Zeit noch der Vormonat „aktuell", und die Sperre
griffe eine Stunde zu früh oder zu spät. `mz_lage` hält den
Monat, den die Anwendung selbst in Europa/Berlin ausrechnet.
===================================================================== */
/** Die vier Aufgaben. Reihenfolge = Anzeigereihenfolge.
*
* `feld` sagt, welche Zusatzangabe die Aufgabe braucht — daraus
* entstehen Formular, Prüfung und Dublettenschutz, damit die Liste
* nicht an vier Stellen getrennt gepflegt werden muss. Eine Liste,
* die niemand pflegt, kann nicht veralten. */
export const AUFGABEN = [
{
schluessel: "creator",
name: "Creator rekrutieren",
kurz: "Creator",
einheit: ["Creator", "Creator"],
vorgabe: 3,
feld: "name",
hinweis: "TikTok-Name des Creators",
},
{
schluessel: "meeting",
name: "Manager Meeting über Discord",
kurz: "Meeting",
einheit: ["Manager Meeting", "Manager Meetings"],
vorgabe: 2,
feld: null,
hinweis: "Nur Datum und auf Wunsch eine Notiz",
},
{
schluessel: "schulung",
name: "Schulung oder Community Talk",
kurz: "Schulung",
einheit: ["Schulung oder Talk", "Schulungen oder Talks"],
vorgabe: 2,
feld: "art",
arten: [
{ wert: "schulung", name: "Schulung" },
{ wert: "talk", name: "Community Talk" },
],
hinweis: "Über Discord",
},
{
schluessel: "werbung",
name: "Werbung auf TikTok hochladen",
kurz: "Werbung",
einheit: ["Werbe-Video", "Werbe-Videos"],
vorgabe: 1,
feld: "link",
arten: [
{ wert: "agentur", name: "Für die Agentur" },
{ wert: "eigen", name: "Eigenwerbung" },
{ wert: "beides", name: "Beides" },
],
hinweis: "Link zum Video",
},
];
export const AUFGABEN_SCHLUESSEL = AUFGABEN.map((a) => a.schluessel);
/** Wer die vier Aufgaben erfüllen muss.
*
* Filipe am 02.10.2026 auf die Frage, wer eigene Zähler hat: „Alle
* vier Rollen." Creator stehen bewusst NICHT dabei — sie bekommen
* weder die Kachel noch eine Erinnerung. Modis und die Community
* kommen hier gar nicht vor: Sie gehören zum Haus Team Dogi, und die
* beiden Häuser sind seit dem 24.09.2026 getrennt. */
export const PFLICHT_ROLLEN = ["spicy", "admin", "manager", "scout"];
/** Wer die Zielzahlen ändern darf.
*
* Filipe: „dogfather und spicy rolle sollen aber gleichzeitig es
* immer ändern können für alle." Das ist dieselbe Menge wie
* `siehtAlles()` in workspace.js — sie wird dort abgefragt und nicht
* hier abgeschrieben, damit es nicht zwei Mengen gibt, die
* auseinanderlaufen. Diese Zeile steht nur als Erinnerung daran, dass
* es eine bewusste Entscheidung war und keine Nebenwirkung. */
/** Der erste Monat, den es überhaupt gibt.
*
* Aus der Vorlage: „Der erste gültige Monat ist Oktober 2026; davor
* gibt es keine Daten und keine Warnungen." Steht hier und nicht
* dreimal im Fachmodul. */
export const ERSTER_MONAT = "2026-10";
/** Wie lange eine Freigabe für einen abgeschlossenen Monat gilt.
*
* Zwei Minuten. Lang genug für jede Korrektur, kurz genug, dass ein
* vergessenes Zurücksetzen keine dauerhaft offene Tür ist. */
export const FREIGABE_SEKUNDEN = 120;
/* Die Tabellen als TEXT und nicht direkt ausgefuehrt: Der Umbau
weiter unten muss sie nach einem DROP TABLE ein zweites Mal
anlegen koennen -- samt der Indizes, die mit der Tabelle
verschwinden. Zweimal hingeschrieben waeren es zwei Fassungen,
von denen eine veraltet. */
const TABELLEN = `
/* ---------- Ein Eintrag --------------------------------------- */
CREATE TABLE IF NOT EXISTS mz_eintrag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
/* SET NULL UND NICHT CASCADE -- berichtigt am 02.10.2026, am
selben Tag, an dem die Tabelle entstand.
Zuerst stand hier CASCADE mit der Begruendung, ein Eintrag
ohne Person sei sinnlos. Die Vorlage sagt aber ausdruecklich:
„Wer die Rolle verliert, sieht die Kachel nicht mehr; die
Daten bleiben fuer den DogFather erhalten." Mit CASCADE waere
genau das nicht wahr gewesen -- DELETE FROM personen in
workspace-personen.js haette den ganzen Monatsverlauf eines
Menschen lautlos mitgenommen, mitten in einem laufenden Jahr.
Das ist dieselbe Entscheidung wie bei support_meldungen: Wird
ein Zugang geloescht, verliert der Eintrag seinen Absender,
nicht seinen Inhalt. Deshalb stehen Name und Rolle zusaetzlich
als Text daneben -- eine Verknuepfung allein ueberlebt das
Loeschen nicht. */
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
/* WER ES WAR, als er es eintrug. Nicht nur verknuepft, sondern
mitgeschrieben: Nach einem Rollenwechsel oder einer Loeschung
stuende sonst „—" neben einer Zahl, die in den Monatsverlauf
eingeht. Die Rolle entscheidet ausserdem, welches Ziel fuer
diesen Monat galt -- eine heute andere Rolle wuerde den
abgeschlossenen Monat rueckwirkend an anderen Zahlen messen. */
von_name TEXT,
von_rolle TEXT,
aufgabe TEXT NOT NULL
CHECK (aufgabe IN ('creator','meeting','schulung','werbung')),
/* 'JJJJ-MM'. Der Monatsschnitt entsteht über diese Spalte und
NICHT über das Löschen alter Zeilen -- aus der Vorlage:
„Löse das über die Zuordnung der Einträge zu Monat/Jahr,
nicht über das Löschen von Daten." */
monat TEXT NOT NULL CHECK (length(monat) = 7),
datum TEXT NOT NULL CHECK (length(datum) = 10),
/* Der TikTok-Name bei 'creator'. Zusätzlich normalisiert in
name_klein, damit der Dublettenschutz nicht an einem
Großbuchstaben oder einem führenden @ scheitert. Zwei Spalten
statt einer Rechnung beim Suchen: So kann ein Index darauf
liegen. */
name TEXT,
name_klein TEXT,
/* Der Link bei 'werbung', ebenfalls mit normalisierter Fassung.
Filipe am 02.10.2026: jede gültige Internetadresse, nicht nur
TikTok. */
link TEXT,
link_klein TEXT,
/* schulung|talk bzw. agentur|eigen|beides */
art TEXT,
notiz TEXT,
/* DIE BRÜCKE ZUR SCOUT-PIPELINE. Darf leer sein: Nicht jede
Rekrutierung läuft über einen Lead, und ein Pflichtfeld hier
würde den Weg von Hand unmöglich machen. SET NULL, weil ein
gelöschter Lead den Monatseintrag nicht entwerten darf — die
Rekrutierung hat ja stattgefunden. */
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
/* Ob jemand anders denselben Creator bzw. denselben Link in
diesem Monat schon eingetragen hatte. Kein Grund abzulehnen
(zwei Menschen können denselben Creator ansprechen), aber
DogFather soll es in der Übersicht sehen können. */
doppelt_bei INTEGER REFERENCES personen(id) ON DELETE SET NULL,
erstellt TEXT NOT NULL,
geaendert TEXT,
geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_mz_person_monat
ON mz_eintrag (person_id, monat, aufgabe);
CREATE INDEX IF NOT EXISTS idx_mz_monat ON mz_eintrag (monat);
CREATE INDEX IF NOT EXISTS idx_mz_name ON mz_eintrag (monat, name_klein);
CREATE INDEX IF NOT EXISTS idx_mz_link ON mz_eintrag (monat, link_klein);
CREATE INDEX IF NOT EXISTS idx_mz_lead ON mz_eintrag (lead_id);
/* ---------- Das Ziel, eingefroren je Monat und Rolle ----------- */
CREATE TABLE IF NOT EXISTS mz_ziel (
monat TEXT NOT NULL CHECK (length(monat) = 7),
rolle TEXT NOT NULL,
aufgabe TEXT NOT NULL
CHECK (aufgabe IN ('creator','meeting','schulung','werbung')),
/* 0 heißt ausdrücklich „für diese Rolle nicht gefordert" und
nicht „vergessen". Eine Zeile mit 0 erzeugt keine Warnung und
zählt nicht in den Gesamtfortschritt. */
ziel INTEGER NOT NULL CHECK (ziel >= 0 AND ziel <= 99),
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
gesetzt_am TEXT NOT NULL,
PRIMARY KEY (monat, rolle, aufgabe)
);
/* ---------- Ab wann jemand pflichtig ist ----------------------- */
/* Aus der Vorlage: „Wer erst im Laufe des Monats eine der Rollen
bekommt, erhält in diesem Monat keine Warnungen; die Pflicht
beginnt im Folgemonat."
EINMAL GEMESSEN UND GESPEICHERT statt bei jeder Abfrage aus dem
Protokoll gerechnet. Das Protokoll ist ein Log: Es wird
irgendwann aufgeräumt, und dann änderte sich rückwirkend, ab
wann jemand pflichtig war. Ein Fundament darf nicht aus etwas
bestehen, das ein Aufräumlauf kürzen darf. */
CREATE TABLE IF NOT EXISTS mz_start (
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
erster_monat TEXT NOT NULL CHECK (length(erster_monat) = 7),
gesetzt_am TEXT NOT NULL,
/* Woraus die Zahl entstanden ist — damit man es später
nachvollziehen kann, ohne das Protokoll zu durchsuchen. */
grund TEXT
);
/* ---------- Welcher Monat gerade läuft ------------------------- */
CREATE TABLE IF NOT EXISTS mz_lage (
eins INTEGER PRIMARY KEY CHECK (eins = 1),
monat TEXT NOT NULL
);
INSERT OR IGNORE INTO mz_lage (eins, monat) VALUES (1, '0000-00');
/* ---------- Die Freigabe für abgeschlossene Monate ------------- */
CREATE TABLE IF NOT EXISTS mz_freigabe (
eins INTEGER PRIMARY KEY CHECK (eins = 1),
offen INTEGER NOT NULL DEFAULT 0 CHECK (offen IN (0, 1)),
bis TEXT
);
INSERT OR IGNORE INTO mz_freigabe (eins, offen, bis) VALUES (1, 0, NULL);
`;
/* =====================================================================
DER UMBAU: aus CASCADE wird SET NULL (02.10.2026)
=====================================================================
Noetig, weil die Tabelle am selben Tag schon mit CASCADE ausgeliefert
wurde. Ein ALTER TABLE kann in SQLite weder einen Fremdschluessel
noch ein NOT NULL aendern -- dafuer gibt es nur den Weg ueber eine
neue Tabelle.
DREI DINGE, DIE HIER ANDERS GEMACHT SIND ALS BEIM LETZTEN MAL
1. DIE SPALTENLISTE WIRD ABGELEITET, NICHT GEPFLEGT. Am 11.09.2026
hat im Creator Workspace genau so ein Umbau DREI Spalten mit
Inhalt verloren -- die Liste war von Hand abgeschrieben, zweimal
(einmal fuer CREATE, einmal fuer INSERT), und beim Hinzufuegen
der naechsten Spalte hat sie niemand nachgezogen. Hier fragt
PRAGMA, was in BEIDEN Tabellen steht; eine Liste, die niemand
pflegt, kann nicht veralten.
2. GEZAEHLT WERDEN ZEILEN *UND* SPALTEN. Die Zeilenzaehlung war
damals als Sicherung gedacht und konnte den Spaltenverlust gar
nicht sehen: 24 Spalten hinein, 21 heraus, bei unveraenderter
Zeilenzahl, ohne Fehlermeldung. Stimmt hier etwas nicht, wird
geworfen -- und die Umstellung laeuft in einer Transaktion,
die dann zurueckrollt.
3. ER LAEUFT NUR, WENN ER MUSS. Gefragt wird die Datenbank selbst
(PRAGMA foreign_key_list), nicht eine Fassungsnummer, die jemand
hochzaehlen muesste.
===================================================================== */
function umbauWennNoetig(d) {
let brauchtEs = false;
try {
const verweise = d.prepare("PRAGMA foreign_key_list(mz_eintrag)").all();
brauchtEs = verweise.some((v) => v.table === "personen"
&& v.from === "person_id" && String(v.on_delete).toUpperCase() === "CASCADE");
} catch {
return; // Tabelle gibt es (noch) nicht
}
if (!brauchtEs) return;
const spaltenVon = (t) => d.prepare(`PRAGMA table_info(${t})`).all().map((s) => s.name);
const vorherZeilen = d.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
const vorherSpalten = spaltenVon("mz_eintrag");
/* Die Fremdschluessel muessen WAEHREND des Umbaus aus sein, sonst
laufen die Verweise waehrend der Umbenennung ins Leere. Danach
wieder an -- und ueberprueft. */
const warenAn = d.prepare("PRAGMA foreign_keys").get()?.foreign_keys;
d.exec("PRAGMA foreign_keys = OFF");
/* EIGENE TRANSAKTION NUR, WENN NOCH KEINE LAEUFT. Diese Funktion
wird aus dem Bauplan in workspace.js gerufen, und der oeffnet an
zwei Stellen selbst eine. Ein BEGIN darin wirft („cannot start a
transaction within a transaction") -- und das waere ein Abbruch
beim Serverstart, also die schlimmste Stelle fuer einen Fehler,
der sich vermeiden laesst. */
let meine = false;
try {
try { d.exec("BEGIN"); meine = true; } catch { meine = false; }
/* Die neue Tabelle entsteht aus derselben Vorlage wie die echte --
nur unter anderem Namen. Ein zweiter, von Hand geschriebener
CREATE waere die Fassung, die auseinanderlaeuft. */
d.exec(TABELLEN.replace(/mz_eintrag/g, "mz_eintrag_neu"));
const nachher = spaltenVon("mz_eintrag_neu");
const gemeinsam = vorherSpalten.filter((s) => nachher.includes(s));
if (!gemeinsam.includes("id") || !gemeinsam.includes("person_id")) {
throw new Error("Umbau: die Schluesselspalten fehlen in der neuen Tabelle");
}
const liste = gemeinsam.join(", ");
d.exec(`INSERT INTO mz_eintrag_neu (${liste}) SELECT ${liste} FROM mz_eintrag`);
const kopiert = d.prepare("SELECT COUNT(*) n FROM mz_eintrag_neu").get().n;
if (kopiert !== vorherZeilen) {
throw new Error(`Umbau: ${kopiert} statt ${vorherZeilen} Zeilen uebernommen`);
}
/* DIE SPALTENPROBE. Jede alte Spalte muss es weiterhin geben --
sonst ist still Inhalt verschwunden. */
const verloren = vorherSpalten.filter((s) => !nachher.includes(s));
if (verloren.length) {
throw new Error(`Umbau: Spalten verloren: ${verloren.join(", ")}`);
}
d.exec("DROP TABLE mz_eintrag");
d.exec("ALTER TABLE mz_eintrag_neu RENAME TO mz_eintrag");
if (meine) d.exec("COMMIT");
console.log(`[manager-ziele] person_id auf SET NULL umgestellt --`
+ ` ${kopiert} Zeilen, ${nachher.length} Spalten (vorher ${vorherSpalten.length}).`);
} catch (fehler) {
if (meine) { try { d.exec("ROLLBACK"); } catch { /* schon zurueck */ } }
try { d.exec("DROP TABLE IF EXISTS mz_eintrag_neu"); } catch { /* egal */ }
console.error("[manager-ziele] Umbau abgebrochen:", fehler?.message);
throw fehler;
} finally {
if (warenAn) d.exec("PRAGMA foreign_keys = ON");
}
/* NACH dem Umbau nachsehen, ob die Verweise noch stimmen. Ein
Umbau mit abgeschalteten Fremdschluesseln kann sie hinterlassen,
ohne dass irgendetwas meckert. */
const kaputt = d.prepare("PRAGMA foreign_key_check(mz_eintrag)").all();
if (kaputt.length) {
console.error(`[manager-ziele] ACHTUNG: ${kaputt.length} Verweise zeigen ins Leere.`);
}
}
export function managerZieleTabellen(d) {
d.exec(TABELLEN);
umbauWennNoetig(d);
d.exec(TABELLEN); // nach einem Umbau fehlen die Indizes
/* EIN GEAENDERTER TRIGGER WIRD NICHT VON SELBST NEU (02.10.2026).
`CREATE TRIGGER IF NOT EXISTS` sieht den Namen, findet ihn und
tut nichts -- die alte Fassung bliebe stehen, und zwar fuer
immer. Deshalb wird nachgesehen, ob die geltende Fassung die
Ausnahme fuer ON DELETE SET NULL schon kennt; wenn nicht, kommt
sie weg und wird unten neu gebaut.
Erkannt am INHALT und nicht an einer Fassungsnummer: Eine Nummer
muesste jemand hochzaehlen, und genau das wird vergessen. */
try {
const alt = d.prepare(
"SELECT sql FROM sqlite_master WHERE type='trigger' AND name='mz_kein_alter_update'")
.get()?.sql || "";
if (alt && !alt.includes("NEW.person_id IS NULL")) {
d.exec("DROP TRIGGER mz_kein_alter_update");
console.log("[manager-ziele] Sperr-Trigger erneuert (laesst jetzt ON DELETE SET NULL durch).");
}
} catch (fehler) {
console.error("[manager-ziele] Trigger pruefen:", fehler?.message);
}
/* ---------- Die Sperre ------------------------------------------
Drei Trigger, einer je Richtung. Sie stehen hier und nicht in
einer Umstellung: Sie gehören zur Tabelle wie ihre Spalten.
DIE BEDINGUNG IST DREITEILIG und jeder Teil hat einen Grund:
* der Monat des Eintrags liegt vor dem laufenden
* die Freigabe steht nicht auf 1
* ODER die Freigabe ist abgelaufen (`bis` liegt in der
Vergangenheit) -- eine vergessene Freigabe ist damit nach
zwei Minuten von selbst wieder zu.
`datetime('now')` ist hier unbedenklich, obwohl die Zeitzone des
Servers unbekannt ist: Verglichen wird ein UTC-Zeitstempel mit
einem UTC-Zeitstempel (die Anwendung schreibt `toISOString()`),
und beide wandern gemeinsam. Beim MONAT wäre genau das nicht so --
deshalb steht er in `mz_lage`.
--------------------------------------------------------------- */
d.exec(`
CREATE TRIGGER IF NOT EXISTS mz_kein_alter_insert
BEFORE INSERT ON mz_eintrag
WHEN NEW.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
AND NOT (SELECT offen FROM mz_freigabe WHERE eins = 1)
BEGIN
SELECT RAISE(ABORT, 'monat_gesperrt');
END;
/* DIE AUSNAHME IN ZEILE DREI IST KEINE BEQUEMLICHKEIT, sondern die
Reparatur eines Fehlers, den die Pruefung gefunden hat
(02.10.2026).
ON DELETE SET NULL ist kein Loeschen -- es ist ein UPDATE auf
mz_eintrag.person_id. Dieser Trigger hat es als Aenderung an
einem abgeschlossenen Monat gesehen und abgebrochen. Folge:
Wer eine Person loeschen wollte, die irgendwann einmal etwas
eingetragen hatte, bekam 'monat_gesperrt' -- und die ganze
Personenverwaltung in workspace-personen.js waere daran
gescheitert, an einer Stelle, die mit Monatszielen nichts zu
tun hat.
Erlaubt ist deshalb genau EINE Aenderung an einem alten Monat:
dem Eintrag seinen Besitzer zu nehmen. Alles andere -- Datum,
Name, Link, Notiz -- bleibt gesperrt.
ALS BEDINGUNG UND NICHT ALS SPALTENLISTE (UPDATE OF ...):
Eine Liste muesste jemand pflegen, und beim Hinzufuegen der
naechsten Spalte wuerde sie vergessen -- dann waere die Sperre
dort still unwirksam. Diese Bedingung beschreibt, WAS erlaubt
ist, und gilt damit auch fuer jede Spalte, die es noch nicht
gibt. */
CREATE TRIGGER IF NOT EXISTS mz_kein_alter_update
BEFORE UPDATE ON mz_eintrag
WHEN OLD.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
AND NOT (NEW.person_id IS NULL AND OLD.person_id IS NOT NULL)
AND NOT ((SELECT offen FROM mz_freigabe WHERE eins = 1)
AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now'))
BEGIN
SELECT RAISE(ABORT, 'monat_gesperrt');
END;
CREATE TRIGGER IF NOT EXISTS mz_kein_alter_delete
BEFORE DELETE ON mz_eintrag
WHEN OLD.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
AND NOT ((SELECT offen FROM mz_freigabe WHERE eins = 1)
AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now'))
BEGIN
SELECT RAISE(ABORT, 'monat_gesperrt');
END;
`);
/* ---------- Nachrüstung für Bestände ohne die neuen Spalten -------
ABGELEITET, NICHT ABGESCHRIEBEN. Am 11.09.2026 hat im Creator
Workspace eine von Hand gepflegte Spaltenliste beim Tabellenumbau
drei Spalten mit Inhalt verloren -- ohne Fehlermeldung, bei
unveränderter Zeilenzahl. Deshalb wird hier gefragt, was DA ist,
statt eine Liste zu pflegen. */
const da = new Set(d.prepare("PRAGMA table_info(mz_eintrag)").all().map((s) => s.name));
const fehlt = [
["name_klein", "TEXT"], ["link_klein", "TEXT"],
["doppelt_bei", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
["von_name", "TEXT"], ["von_rolle", "TEXT"],
].filter(([spalte]) => !da.has(spalte));
for (const [spalte, typ] of fehlt) {
d.exec(`ALTER TABLE mz_eintrag ADD COLUMN ${spalte} ${typ}`);
}
}
+267
View File
@@ -0,0 +1,267 @@
/* =====================================================================
WER HAT SIE, WER NICHT — die Einzelansicht bei „An alle" (24.09.2026)
---------------------------------------------------------------------
WARUM ES DIESE DATEI GIBT
Filipe: „wenn ich eine aufgabe an alle verteile will ich dass
dogfather und die rechte hand individuel von jedem sehen wer es
gemacht hat oder nicht. aufgabe an alle verteilen aber individuel
sehen wir das."
Gebaut ist das in `vorlagenbrett.js`. Ob es AUSSIEHT wie gedacht,
beantwortet kein Quelltext: Die Marken stehen in einer Reihe, die
umbricht, in einer Karte, die schmaler wird — und auf einem Handy
ist die Karte 380 px breit, nicht 400.
SIE PRUEFT NICHTS und meldet keinen Fehlschlag. Sie macht ein Bild,
auf dem man nachsehen kann. Sie heisst `mess-` und nicht `pruef-`: Beide Sorten leiten ihre
Portnummer aus der Stelle im Alphabet ab, aber jede aus IHRER
eigenen Liste und in ihrem eigenen Bereich (Pruefungen ab 5000,
Messungen ab 5900). So verschiebt eine neue Messdatei die Nummern
der Pruefungen nicht -- und umgekehrt.
---------------------------------------------------------------------
EIGENE WEGWERF-DATENBANK, NIE DIE ECHTE
Es entstehen echte Aufgaben — vier Stueck, damit alle vier Zustaende
im Bild sind. In einem laufenden System waeren das Testdaten, und die
sind verboten. Hier liegt die Datenbank in einem `mktemp`-Ordner und
wird am Ende geloescht.
Aufrufen mit: node server/mess-alle-einzelsicht.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-alle-einzelsicht (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-alle-sicht-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-alle-einzelsicht");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
/* Vier Empfaenger und eine rechte Hand — genau die Besetzung, um die es
geht: Die Haende sind getrennt (eine verteilt, eine bekommt). */
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("VanVan", "hand", "CODE-HAND-0001");
const idLinke = anlegen("Kessi", "linke", "CODE-LINKE-0001");
const idM1 = anlegen("Diene", "modi", "CODE-MODI-0001");
const idM2 = anlegen("Marina", "modi", "CODE-MODI-0002");
const idM3 = anlegen("Ghost", "modi", "CODE-MODI-0003");
void idDogi; void idHand;
d.close();
const anfrage = (pfad, art, koerper, keks) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: {
Host: CREW, "Content-Type": "application/json",
...(keks ? { Cookie: keks } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "admin", code: "CODE-DOGI-0001" });
if (an.code !== 200) {
console.log(`ABBRUCH: DogFather ließ sich nicht anmelden (HTTP ${an.code}).`);
process.exit(2);
}
const keks = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
/* ---- Ein Stand, auf dem man ALLE VIER Zustaende sieht ----------------
Sonst zeigt das Bild nur „hat sie nicht", und man weiss nachher
nicht, ob die Farben stimmen. */
/* Der Katalog steckt UNTER `katalog` in der Antwort -- die Route
liefert daneben noch Bewerbungen und Rechte. */
const kat = JSON.parse((await anfrage("/workspace/api/vorlagen", "GET", null, keks)).text);
const liste = kat?.katalog?.aufgaben;
if (!Array.isArray(liste) || !liste.length) {
console.log("ABBRUCH: kein Katalog in der Antwort --",
JSON.stringify(kat).slice(0, 300));
process.exit(3);
}
const v0 = liste.map((v, i) => ({ ...v, nr: i }))[0];
const verteilt = await anfrage("/workspace/api/vorlagen/uebernehmen", "POST",
{ art: "katalog", nr: v0.nr, gruppe: v0.gruppe, person_id: "alle" }, keks);
console.log(`Verteilt an alle: HTTP ${verteilt.code} ${verteilt.text.slice(0, 80)}`);
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
/* Einer hat sie erledigt, einer ist ueberfaellig, einer hat sie gar
nicht — die Linke Hand behaelt ihr schlichtes „Offen". */
d2.prepare("UPDATE aufgaben SET status = 'erledigt' WHERE vorlage = ? AND verantwortlich_id = ?")
.run(v0.schluessel, idM1);
d2.prepare("UPDATE aufgaben SET frist = '2026-09-01' WHERE vorlage = ? AND verantwortlich_id = ?")
.run(v0.schluessel, idM2);
d2.prepare("DELETE FROM aufgaben WHERE vorlage = ? AND verantwortlich_id = ?")
.run(v0.schluessel, idM3);
const stand = d2.prepare(
"SELECT verantwortlich_id AS w, status, frist FROM aufgaben WHERE vorlage = ?")
.all(v0.schluessel);
d2.close();
console.log("Stand:", JSON.stringify(stand),
`\n (${idLinke}=Kessi/linke, ${idM1}=Diene, ${idM2}=Marina, ${idM3}=Ghost)`);
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
try {
/* ZWEI BREITEN. 412 px ist die haeufigste Android-Breite; an ihr ist
am 06.09. die Kopfleiste gescheitert. 1280 px ist der kleinste
Rechner, an dem noch jemand arbeitet. */
for (const [wie, breite, hoehe] of [["handy", 412, 1400], ["computer", 1280, 1600]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/entwicklung.html`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
/* Das Brett startet zugeklappt — aufklappen, sonst ist das Bild leer. */
await seite.evaluate(() => {
const kopf = document.querySelector('.vorlagenbrett__kopf[aria-expanded="false"]');
if (kopf) kopf.click();
});
await seite.waitForTimeout(700);
/* Den Alle-Knopf waehlen -- das ist der Fall, um den es geht. */
const traf = await seite.evaluate(() => {
const k = document.querySelector('.stufenleiter--wer [data-alle="ja"]');
if (!k) return false;
k.click(); return true;
});
await seite.waitForTimeout(1400);
const gesehen = await seite.evaluate(() => {
const karte = document.querySelector('.vorlagenbrett__karte .vorlagenbrett__wer')
?.closest('.vorlagenbrett__karte');
const reihe = [...(karte?.querySelectorAll('.vorlagenbrett__wer-p') || [])]
.map((m) => ({
wer: m.querySelector('.vorlagenbrett__wer-name')?.textContent,
stand: m.querySelector('.vorlagenbrett__wer-stand')?.textContent,
art: m.dataset.stand,
}));
return {
wen: [...document.querySelectorAll('.stufenleiter--wer .stufenleiter__knopf')]
.map((k) => k.textContent.trim().replace(/\s+/g, ' ')),
titel: karte?.querySelector('.vorlagenbrett__titel')?.textContent,
fuss: karte?.querySelector('.vorlagenbrett__schon')?.textContent,
knopf: karte?.querySelector('.knopf')?.textContent,
hinweis: karte?.querySelector('.knopf')?.title,
reihe,
mitReihe: document.querySelectorAll('.vorlagenbrett__wer').length,
};
});
console.log(`\n=== ${wie} (${breite} px) ===`);
console.log(" Alle-Knopf getroffen:", traf);
console.log(" An wen:", JSON.stringify(gesehen.wen));
console.log(" Karte :", gesehen.titel);
console.log(" Fuss :", gesehen.fuss, "| Knopf:", gesehen.knopf);
console.log(" Hinweis:", gesehen.hinweis);
console.log(" Karten mit Namensreihe:", gesehen.mitReihe);
for (const r of gesehen.reihe) console.log(` ${r.wer} -> ${r.stand} [${r.art}]`);
if (konsole.length) console.log(" FEHLER IN DER KONSOLE:", konsole);
const karte = seite.locator('.vorlagenbrett__karte', { has: seite.locator('.vorlagenbrett__wer') }).first();
if (await karte.count()) {
await karte.screenshot({ path: `server/mess-alle-${wie}.png` });
console.log(` Bild: server/mess-alle-${wie}.png`);
/* UND DIE RUECKSEITE. Ohne sie waere nur bewiesen, dass die
Namen von der Vorderseite verschwunden sind -- nicht, dass
sie hinten ankommen. */
await karte.locator('.vorlagenbrett__drehknopf').click().catch(() => {});
await seite.waitForTimeout(600);
const hinten = await seite.evaluate(() => {
const k = document.querySelector('.vorlagenbrett__karte[data-gedreht="ja"]');
const r = k?.querySelector('.vorlagenbrett__seite--hinten');
return {
gedreht: !!k,
schild: r?.querySelector('.vorlagenbrett__wer-schild')?.textContent,
knoepfe: [...(r?.querySelectorAll('button') || [])].map((b) => b.textContent),
namen: [...(r?.querySelectorAll('.vorlagenbrett__wer-name') || [])]
.map((x) => x.textContent),
};
});
console.log(' Rueckseite:', JSON.stringify(hinten));
await karte.screenshot({ path: `server/mess-alle-${wie}-hinten.png` }).catch(() => {});
}
await kontext.close();
}
} finally {
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+187
View File
@@ -0,0 +1,187 @@
/* =====================================================================
WIE DER AUSHANG MIT SEINEN ZWEI KNOEPFEN AUSSIEHT (25.09.2026)
=====================================================================
Filipe: „jeder soll das fixierte individuel für sich lösen können
aber niemals so dass es sich für alle löst ... dogfather und die
rechte hand sollen die option haben für sich selbst oder für alle
zu lösen."
`pruef-pin-fuer-mich` misst, WAS PASSIERT (37 Pruefungen). Hier geht
es um das, was man nur sieht: Stehen die zwei Knoepfe nebeneinander,
ohne sich zu draengen? Sind sie auf dem Handy zu treffen? Sieht man
auf den ersten Blick, dass der zweite mehr bewirkt?
ZWEI BLICKE, weil es zwei Antworten gibt: DogFather sieht beide
Knoepfe, ein Modi nur einen. Ein Bild vom einen sagt nichts ueber
den anderen.
Aufrufen mit: node server/mess-aushang.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-aushang (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-aushang-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-aushang");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idModi = anlegen("Kessi", "modi", "CODE-MODI-0001");
d.close();
const roh = (keks, pfad, methode = "GET", koerper = null) =>
new Promise((fertig, schief) => {
const daten = koerper === null ? null : JSON.stringify(koerper);
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: CREW, ...(keks ? { Cookie: keks } : {}),
Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/chat.html`,
...(daten ? { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(daten) } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ status: r.statusCode, kopf: r.headers,
json: () => { try { return JSON.parse(t); } catch { return {}; } } }));
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
const anmelden = async (rolle, code) => {
const a = await roh(null, "/workspace/api/anmelden", "POST", { rolle, code });
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
};
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
const kModi = await anmelden("modi", "CODE-MODI-0001");
const gr = await roh(kDogi, "/workspace/api/chat/gruppe", "POST",
{ name: "Ansagen", mit: [idModi] });
const raumId = gr.json().raum_id;
const n = await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
{ text: "Guuuuuten Morgeeeen, ihr wundervollen Chaoten! Wir haben uns "
+ "durch die Woche gekämpft – heute wird gefeiert." });
const nid = n.json().nachricht?.id || n.json().id;
await roh(kDogi, `/workspace/api/chat/nachrichten/${nid}/anheften`, "PUT", { an: true });
console.log(`Aushang #${nid} in Raum #${raumId}`);
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
for (const [wer, keks, breite] of [
["dogfather-handy", kDogi, 390], ["modi-handy", kModi, 390],
["dogfather-rechner", kDogi, 1280],
]) {
const kontext = await browser.newContext({
viewport: { width: breite, height: 900 },
deviceScaleFactor: breite < 700 ? 2 : 1,
isMobile: breite < 700, hasTouch: breite < 700,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [x, ...r] = k.split("=");
return { name: x, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`https://${CREW}:${HP}/workspace/chat.html?raum=${raumId}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
const lage = await seite.evaluate(() => {
const pin = document.querySelector(".chat-pin");
if (!pin) return { da: false };
const r = pin.getBoundingClientRect();
const knoepfe = [...pin.querySelectorAll(".chat-pin__los")].map((k) => {
const kr = k.getBoundingClientRect();
return {
wort: (k.textContent || "").trim(),
b: Math.round(kr.width), h: Math.round(kr.height),
farbe: getComputedStyle(k).color,
};
});
return {
da: true,
kasten: `${Math.round(r.width)}x${Math.round(r.height)}`,
ausDemBild: r.right > window.innerWidth + 1,
knoepfe,
zuKlein: knoepfe.filter((k) => k.h < 44 && matchMedia("(pointer: coarse)").matches)
.map((k) => `${k.wort} ${k.h}px`),
};
});
console.log(`\n=== ${wer} (${breite} px) ===`);
if (!lage.da) console.log(" KEIN AUSHANG ZU SEHEN");
else {
console.log(` Kasten ${lage.kasten}`
+ (lage.ausDemBild ? " STEHT AUS DEM BILD" : ""));
for (const k of lage.knoepfe) {
console.log(` "${k.wort}" ${k.b}x${k.h} ${k.farbe}`);
}
if (lage.zuKlein.length) console.log(` ZU KLEIN: ${lage.zuKlein.join(", ")}`);
const el = seite.locator(".chat-pin").first();
await el.screenshot({ path: `server/mess-aushang-${wer}.png` }).catch(() => {});
console.log(` Bild: server/mess-aushang-${wer}.png`);
}
await kontext.close();
}
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+617
View File
@@ -0,0 +1,617 @@
/* =====================================================================
DIE ZWEI QUELLEN FUER OBS (28.09.2026)
Gemessen wird die Behauptung, auf der alles steht: Beide Seiten
arbeiten OHNE ANMELDUNG, nur mit einem Schluessel in der Adresse.
Dafuer wird ein Browserfenster ohne jeden Keks geoeffnet.
Dazu: Der falsche Schluessel fuehrt zu nichts, der Grund dafuer
steht im Bild, und eine bestaetigte Spende laeuft als Karte auf
der durchsichtigen Tafel.
Aufrufen mit: node server/mess-buehne.mjs
===================================================================== */
/* =====================================================================
WIE DIE REACTION AUSSIEHT (28.09.2026)
Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
schaltet sie durch alle drei Zustaende und legt Bilder ab.
Gemessen wird dabei auch, was man sonst uebersieht:
- laeuft etwas in die Konsole?
- ragt etwas seitlich hinaus?
- ist der Saal so hoch wie das Fenster, oder muss man scrollen?
NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
Zertifikat -- und am Ende ist alles weg.
Aufrufen mit: node server/mess-reaktion.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { request } from "node:http";
import { execFileSync } from "node:child_process";
import { createServer as httpsServer } from "node:https";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const ordner = mkdtempSync(join(tmpdir(), "mess-buehne-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HTTPS_PORT = await eigenerPort(import.meta, "mess-buehne (HTTPS)", 1);
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
for (const art of ["uncaughtException", "unhandledRejection"]) {
process.on(art, (f) => {
console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
process.exit(8);
});
}
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const CREW = "crew.dogfather-universe.com";
/* ---------- Leute ------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const kenn = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kenn, new Date().toISOString());
const ALT = new Date(Date.now() - 200 * 86400000).toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", kenn).update(code).digest("hex"), ALT);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
const IDS = {
admin: anlegen("DogFather", "admin", CODES.admin),
gast: anlegen("Nachtfalter", "gast", CODES.gast),
gast2: anlegen("Lenny", "gast", CODES.gast2),
};
d.close();
function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad, method,
headers: {
Host: CREW, Origin: `https://${CREW}`,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (s) => teile.push(s));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle) {
const a = await roh("/workspace/api/anmelden", {
method: "POST",
rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
});
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const k = {
admin: await anmelden("admin"),
gast: await anmelden("gast"),
gast2: await anmelden("gast2"),
};
/* ---------- Die Buehne fuellen ------------------------------------------ */
await roh("/workspace/api/reaktion", {
method: "PUT", keks: k.admin,
rumpf: {
titel: "Wir schauen den neuen Trailer",
video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
},
});
/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
for (const adresse of ["https://youtu.be/9bZkp7q19f0",
"https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
await roh("/workspace/api/reaktion/liste", {
method: "POST", keks: k.admin, rumpf: { video: adresse },
});
}
const REDEN = [
["gast", "Bin schon daaa 🐺"],
["gast2", "Ich auch! Hab extra Popcorn geholt"],
["gast", "Wie lange noch?"],
["admin", "Zwei Minuten, ich richte noch die Kamera"],
["gast2", "Kein Stress, wir sitzen bequem"],
["gast", "Letztes Mal war so gut, ich hab geheult"],
];
/* ---------- Der Vorbau -------------------------------------------------- */
const schluesselDatei = join(ordner, "p.key");
const zertDatei = join(ordner, "p.crt");
zertifikatBauen(schluesselDatei, zertDatei, CREW);
const vorbau = httpsServer({
key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
}, (von, nach) => {
const weiter = request({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => {
nach.writeHead(antwort.statusCode || 502, antwort.headers);
antwort.pipe(nach);
});
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const BASIS = `https://${CREW}:${HTTPS_PORT}`;
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
/* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
Aufnahme aus, als waere die Kamera kaputt. */
"--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
schliesst, nimmt den spaeteren den Platz weg.
GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
ein Messfehler, der wie ein Programmfehler aussah. */
async function alsWer(rolle, breite, hoehe) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860,
ignoreHTTPSErrors: true,
permissions: ["camera", "microphone"],
});
await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
const anfragen = [];
seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("response", (a) => {
if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
});
return { kontext, seite, konsole, anfragen };
}
async function bild(seite, name) {
await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
console.log(` Bild: server/mess-reaktion-${name}.png`);
}
async function vermessen(seite, wo) {
const m = await seite.evaluate(() => ({
ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
stand: document.getElementById("saal")?.dataset.stand || "?",
schild: document.getElementById("schild-wort")?.textContent || "",
}));
console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", `
+ `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
return m;
}
/* ---------- Die Buehne vorbereiten -------------------------------------- */
await roh("/workspace/api/reaktion", {
method: "PUT", keks: k.admin,
rumpf: { titel: "Probe fuer OBS", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
});
await roh("/workspace/api/reaktion/stand", {
method: "POST", keks: k.admin, rumpf: { stand: "live" },
});
await roh("/workspace/api/reaktion/dabei", { method: "POST", keks: k.admin });
const adr = await roh("/workspace/api/buehne/adressen", { keks: k.admin });
console.log(` Adressen: ${adr.code}`);
let ausgang = 0;
if (adr.code !== 200) {
console.log(" ACHTUNG: Die Adressen kommen nicht.");
process.exit(1);
}
const schluessel = new URL(adr.j.video).searchParams.get("s");
console.log(` Schluessel: ${schluessel.length} Zeichen`);
if (schluessel.length !== 64) {
console.log(" ACHTUNG: Der Schluessel ist zu kurz.");
ausgang = 1;
}
/* NUR DIE LEITUNG SIEHT DIE ADRESSEN. Wer sie hat, kann mitsehen --
das ist vertretbar fuer den, der sendet, und nicht fuer jeden im
Haus. Diese Messung legt nur DogFather und zwei Zuschauer an; die
uebrigen Rollen prueft `pruef-buehne`. */
const alsGast = await roh("/workspace/api/buehne/adressen", { keks: k.gast });
console.log(` Ein Zuschauer bekommt die Adressen: ${alsGast.code}`);
if (alsGast.code !== 403) {
console.log(" ACHTUNG: Ein Zuschauer darf die Adressen sehen.");
ausgang = 1;
}
/* ---------- Ein Fenster OHNE Anmeldung ---------------------------------- */
const fremd = await browser.newContext({
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
});
const konsole = [];
const tafelSeite = await fremd.newPage();
tafelSeite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
/* DIE ADRESSE GEHOERT IN DIE MELDUNG. Der Browser sagt nur
„Failed to load resource" -- das schickt einen auf die Suche,
ohne zu sagen wonach. Die uebrigen Seiten schreiben den Pfad
seit jeher mit; diese zwei nicht, und genau sie sind die
Quellen, die in den Stream gehen. */
tafelSeite.on("response", (a) => {
if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
});
tafelSeite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
/* Erst der FALSCHE Schluessel. Eine Pruefung, die nur den richtigen
kennt, beweist nicht, dass der falsche abgewiesen wird. */
await tafelSeite.goto(`${BASIS}/workspace/tafel.html?s=falschfalschfalsch`,
{ waitUntil: "domcontentloaded" });
await tafelSeite.waitForTimeout(1200);
const beiFalsch = await tafelSeite.evaluate(() => ({
fehler: document.querySelector(".obs-fehler")?.textContent?.trim() || "",
karten: document.querySelectorAll(".spendenkarte").length,
}));
console.log(` Falscher Schluessel: „${beiFalsch.fehler.slice(0, 48)}…", ${beiFalsch.karten} Karten`);
if (!beiFalsch.fehler.includes("stimmt nicht") || beiFalsch.karten !== 0) {
console.log(" ACHTUNG: Ein falscher Schluessel wird nicht sauber abgewiesen.");
ausgang = 1;
}
/* Jetzt der richtige. */
await tafelSeite.goto(`${BASIS}/workspace/tafel.html?s=${schluessel}`,
{ waitUntil: "domcontentloaded" });
await tafelSeite.waitForTimeout(1500);
const kekse = await fremd.cookies();
const grund = await tafelSeite.evaluate(() => ({
hintergrund: getComputedStyle(document.body).backgroundColor,
fehler: document.querySelector(".obs-fehler")?.textContent || "",
}));
console.log(` Kekse im Fenster: ${kekse.length} Hintergrund: ${grund.hintergrund}`);
if (kekse.length !== 0) {
console.log(" ACHTUNG: Die Quelle hat sich angemeldet -- das soll sie nicht muessen.");
ausgang = 1;
}
if (!/rgba\(0, 0, 0, 0\)|transparent/.test(grund.hintergrund)) {
console.log(` ACHTUNG: Der Grund ist nicht durchsichtig (${grund.hintergrund}).`);
ausgang = 1;
}
if (grund.fehler) {
console.log(` ACHTUNG: Die Tafel meldet einen Fehler: ${grund.fehler}`);
ausgang = 1;
}
/* ---------- Laeuft eine Karte an? --------------------------------------- */
await roh("/workspace/api/spenden", {
method: "POST", keks: k.admin,
rumpf: { dogen_mille: 2500, name: "Nachtfalter", gruss: "Gruss an die Buehne" },
});
await tafelSeite.waitForFunction(
() => document.querySelector('.spendenkarte[data-da="ja"]'), null, { timeout: 12000 },
).catch(() => {});
await tafelSeite.waitForTimeout(600);
const karte = await tafelSeite.evaluate(() => {
const k2 = document.querySelector(".spendenkarte");
if (!k2) return null;
const r = k2.getBoundingClientRect();
return {
/* Zahl, Wort und Zeichen getrennt -- `textContent` klebt sie
sonst zu „2,5Dogen" zusammen, und eine Suche darin traefe
frueher oder spaeter das Falsche. */
zahl: k2.querySelector(".dogen__zahl")?.textContent?.trim(),
wort: k2.querySelector(".dogen__wort")?.textContent?.trim(),
zeichen: !!k2.querySelector(".dogen__zeichen"),
wer: k2.querySelector(".spendenkarte__wer")?.textContent?.trim(),
stufe: k2.querySelector(".spendenkarte__stufe")?.textContent?.trim(),
breite: Math.round(r.width), hoehe: Math.round(r.height),
};
});
console.log(` Karte auf der Tafel: ${JSON.stringify(karte)}`);
if (!karte || karte.zahl !== "2,5" || karte.wort !== "Dogen" || !karte.zeichen) {
console.log(" ACHTUNG: Die Karte laeuft auf der Tafel nicht an -- "
+ "oder ohne Dogen und Zeichen.");
ausgang = 1;
}
/* IM STREAM DARF KEIN GELD STEHEN. Die Tafel ist die einzige
Flaeche, die wirklich jeder sieht. */
const geldImStream = await tafelSeite.evaluate(() =>
/€|EUR|Euro/.test(document.body.textContent || ""));
console.log(` Geld im Streambild: ${geldImStream}`);
if (geldImStream) {
console.log(" ACHTUNG: Auf der Buehnentafel steht Geld.");
ausgang = 1;
}
await tafelSeite.screenshot({ path: join(HIER, "mess-buehne-1-tafel.png") });
console.log(" Bild: server/mess-buehne-1-tafel.png");
/* ---------- Das Video als Quelle ---------------------------------------- */
const videoSeite = await fremd.newPage();
videoSeite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
/* DIE ADRESSE GEHOERT IN DIE MELDUNG. Der Browser sagt nur
„Failed to load resource" -- das schickt einen auf die Suche,
ohne zu sagen wonach. Die uebrigen Seiten schreiben den Pfad
seit jeher mit; diese zwei nicht, und genau sie sind die
Quellen, die in den Stream gehen. */
videoSeite.on("response", (a) => {
if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
});
await videoSeite.goto(`${BASIS}/workspace/buehne.html?s=${schluessel}`,
{ waitUntil: "domcontentloaded" });
await videoSeite.waitForTimeout(6000);
const video = await videoSeite.evaluate(() => ({
rahmen: !!document.querySelector("#platz iframe"),
fehler: document.querySelector(".obs-fehler")?.textContent || "",
bedienung: document.body.innerHTML.includes("controls=1"),
}));
console.log(` Video-Quelle: Rahmen=${video.rahmen} Fehler=„${video.fehler}"`);
if (!video.rahmen || video.fehler) {
console.log(" ACHTUNG: Die Video-Quelle zeigt kein Video.");
ausgang = 1;
}
await videoSeite.screenshot({ path: join(HIER, "mess-buehne-2-video.png") });
console.log(" Bild: server/mess-buehne-2-video.png");
/* ---------- Ein neuer Schluessel macht die alten Adressen tot ----------- */
const neu = await roh("/workspace/api/buehne/schluessel", { method: "POST", keks: k.admin });
const neuerSchluessel = new URL(neu.j.video).searchParams.get("s");
console.log(` Neuer Schluessel: ${neuerSchluessel !== schluessel ? "anders" : "GLEICH"}`);
const altNochGut = await roh(`/workspace/api/buehne/lage?s=${schluessel}`, {});
console.log(` Alte Adresse antwortet: ${altNochGut.code}`);
if (neuerSchluessel === schluessel || altNochGut.code !== 404) {
console.log(" ACHTUNG: Der alte Schluessel wirkt noch.");
ausgang = 1;
}
const neuGut = await roh(`/workspace/api/buehne/lage?s=${neuerSchluessel}`, {});
console.log(` Neue Adresse antwortet: ${neuGut.code}`);
if (neuGut.code !== 200) {
console.log(" ACHTUNG: Der neue Schluessel wirkt nicht.");
ausgang = 1;
}
/* ---------- Die Werbeeinblendung im Stream ------------------------------ */
console.log("");
console.log("=== Die Werbung in der Buehnenquelle ===");
{
const setz = (f) => roh("/workspace/api/reaktion/werbung",
{ method: "POST", keks: k.admin, rumpf: f });
const an = await setz({ art: "laufband", tempo: 12, ecke_dogi: "or", ecke_gross: 1.4 });
console.log(` Einschalten: ${an.code}`);
if (an.code !== 200) {
console.log(" ACHTUNG: Die Werbung laesst sich nicht einschalten.");
ausgang = 1;
}
/* DIE QUELLE WIRD NEU GELADEN und nicht nur abgewartet: OBS haelt
sie dauerhaft offen, aber diese Messung soll zuerst beweisen,
dass die Einblendung ueberhaupt ankommt. Ob sie auch WAEHREND
des Laufens nachzieht, ist die zweite Frage -- die steht
darunter. */
await videoSeite.goto(`${BASIS}/workspace/buehne.html?s=${neuerSchluessel}`,
{ waitUntil: "domcontentloaded" });
await videoSeite.waitForTimeout(2500);
const w = await videoSeite.evaluate(() => {
const b = document.querySelector(".werbung__band");
const e = document.querySelector('.werbung__ecke--dogi:not([hidden])');
const bilder = [...document.querySelectorAll(".werbung img")];
const rb = b?.getBoundingClientRect();
const re = e?.getBoundingClientRect();
return {
band: !!b && !b.hidden && rb.height > 10,
bandHoch: rb ? Math.round(rb.height) : 0,
/* IM BILD, NICHT NUR IM DOKUMENT. Die Quelle ist genau so
gross wie der Stream; was darunter liegt, filmt OBS nicht
mit. */
bandImBild: !!rb && rb.bottom <= window.innerHeight + 2 && rb.top >= -2,
stuecke: document.querySelectorAll(".werbung__stueck").length,
code: document.querySelector(".werbung__code")?.textContent || "",
ecke: e ? e.dataset.platz : "",
eckeBreit: re ? Math.round(re.width) : 0,
eckeRechtsOben: !!re
&& (window.innerWidth - re.right) < re.left && re.top < (window.innerHeight - re.bottom),
/* `naturalWidth` und nicht „das Element ist da": Ein Bild mit
404 steht genauso im Dokument wie eines, das laedt -- im
Stream ist dort nichts. */
geladen: bilder.filter((i) => i.naturalWidth > 0).length,
gesamt: bilder.length,
/* DIE SCHRIFT MUSS GROESSER SEIN ALS IM SAAL. Ein Stream wird
auf einem Handy gesehen, oft in einem Viertel des Bildes.
Steht hier dieselbe Groesse wie am Rechner, ist der
Rabattcode nicht zu lesen und die Einblendung umsonst. */
schrift: Math.round(parseFloat(
getComputedStyle(document.querySelector(".werbung__text") || document.body).fontSize)),
};
});
console.log(` Band: ${w.band ? `${w.bandHoch} px` : "FEHLT"}, ${w.stuecke} Stuecke, `
+ `Schrift ${w.schrift} px, Code „${w.code}"`);
console.log(` Ecke: ${w.ecke || "FEHLT"}, ${w.eckeBreit} px, `
+ `rechts oben: ${w.eckeRechtsOben}, Bilder ${w.geladen}/${w.gesamt}`);
if (!w.band || !w.bandImBild || w.stuecke < 2) {
console.log(" ACHTUNG: Im Stream laeuft kein Band.");
ausgang = 1;
}
if (!w.code) {
console.log(" ACHTUNG: Der Rabattcode ist im Stream nicht hervorgehoben.");
ausgang = 1;
}
if (w.ecke !== "or" || !w.eckeRechtsOben) {
console.log(" ACHTUNG: Das Logo steht im Stream nicht oben rechts.");
ausgang = 1;
}
if (!w.gesamt || w.geladen < w.gesamt) {
console.log(` ACHTUNG: ${w.gesamt - w.geladen} Bild(er) laden im Stream nicht.`);
ausgang = 1;
}
if (w.schrift < 20) {
console.log(` ACHTUNG: ${w.schrift} px Schrift ist fuer einen Stream zu klein.`);
ausgang = 1;
}
await videoSeite.screenshot({ path: join(HIER, "mess-buehne-4-werbung.png") });
console.log(" Bild: server/mess-buehne-4-werbung.png");
/* ---- Zieht die OFFENE Quelle nach? -----------------------------
Das ist die eigentliche Betriebsfrage. OBS laedt diese Seite
einmal beim Start der Szene und danach nie wieder -- wenn
Filipe mitten in der Sendung umschaltet und die Quelle das
nicht mitbekommt, wirbt der Stream bis zum naechsten Neustart
weiter fuer das Falsche. */
await setz({ ecke_dogi: "ul" });
await videoSeite.waitForTimeout(2500);
const nach = await videoSeite.evaluate(() => {
const e = document.querySelector('.werbung__ecke--dogi:not([hidden])');
return e ? e.dataset.platz : "";
});
console.log(` Umgeschaltet auf „unten links", die offene Quelle zeigt: „${nach || "nichts"}"`);
if (nach !== "ul") {
console.log(" ACHTUNG: Die offene Buehnenquelle zieht eine Aenderung nicht nach.");
ausgang = 1;
}
/* ---- Und wieder aus ---- */
await setz({ art: "aus", ecke_dogi: "aus", ecke_hase: "aus" });
await videoSeite.waitForTimeout(2000);
const weg = await videoSeite.evaluate(() => ({
band: !!document.querySelector(".werbung__band:not([hidden])"),
ecken: document.querySelectorAll(".werbung__ecke:not([hidden])").length,
}));
console.log(` Abgeschaltet: Band ${weg.band ? "STEHT NOCH" : "weg"}, `
+ `${weg.ecken} Ecken uebrig`);
if (weg.band || weg.ecken) {
console.log(" ACHTUNG: Abgeschaltete Werbung bleibt im Stream stehen.");
ausgang = 1;
}
}
/* ---------- Der Buehnenmodus der Reaction-Seite -------------------------- */
const hostSeite = await alsWer("admin", 1280, 720);
await hostSeite.seite.goto(`${BASIS}/workspace/reaktion.html?nur=buehne`,
{ waitUntil: "domcontentloaded" });
await hostSeite.seite.waitForTimeout(3000);
const nurBild = await hostSeite.seite.evaluate(() => {
const sicht = (w) => {
const e = document.querySelector(w);
if (!e) return false;
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(e).display !== "none";
};
return {
modus: document.body.dataset.nur,
kopf: sicht(".kopfleiste"), chat: sicht(".schiene"), pult: sicht(".pult"),
schild: sicht(".buehne-schild"), leinwand: sicht(".leinwand"),
saal: Math.round(document.getElementById("saal").getBoundingClientRect().height),
fenster: window.innerHeight,
};
});
console.log(` Buehnenmodus: Kopf=${nurBild.kopf} Chat=${nurBild.chat} `
+ `Pult=${nurBild.pult} Schild=${nurBild.schild} | Leinwand=${nurBild.leinwand} `
+ `Saal ${nurBild.saal} von ${nurBild.fenster}`);
if (nurBild.kopf || nurBild.chat || nurBild.pult || nurBild.schild || !nurBild.leinwand
|| nurBild.saal < nurBild.fenster - 4) {
console.log(" ACHTUNG: Der Buehnenmodus raeumt nicht sauber auf.");
ausgang = 1;
}
await hostSeite.seite.screenshot({ path: join(HIER, "mess-buehne-3-modus.png") });
console.log(" Bild: server/mess-buehne-3-modus.png");
/* ---------- Was in den Quellen schiefging -------------------------------
ZWEI FEHLSCHLAEGE SIND BESTELLT und stehen deshalb nicht in der
Meldung: der Abruf mit dem absichtlich falschen Schluessel und der
Nachfass der offenen Quelle auf den eben abgeloesten alten. Beide
sind das Gelingen einer Pruefung, nicht ihr Scheitern.
Sie standen bisher mit drin. Eine Meldung, die IMMER vier Zeilen
zeigt, wird nach dem zweiten Mal ueberlesen -- und ein echter 404
in genau der Seite, die in den Stream geht, faellt darin nicht
auf. Deshalb eng gefiltert, benannt, und der Rest ungekuerzt. */
const bestellt = [
(z) => z.includes("s=falschfalschfalsch"),
(z) => z.includes("/buehne/strom?s=") && z.includes(schluessel),
/* Dieselben Ereignisse ohne Adresse -- die Adresse steht seit
heute in der Zeile daneben. */
(z) => z.startsWith("Failed to load resource"),
];
const echt = konsole.filter((z) => !bestellt.some((f) => f(z)));
/* GEGENPROBE: Ein Filter, der alles durchlaesst, waere bequem und
wertlos. Diese vier Proben beweisen, dass er beide Richtungen
kann -- er nimmt die bestellten Faelle heraus UND laesst einen
echten 404 stehen, auch einen auf ein Werbebild. */
{
const proben = [
["404 /workspace/api/buehne/lage?s=falschfalschfalsch", false],
[`404 /workspace/api/buehne/strom?s=${schluessel}`, false],
["404 /workspace/assets/img/werbung-hase.webp", true],
["TypeError: x ist nicht definiert", true],
];
const daneben = proben.filter(([z, soll]) =>
(!bestellt.some((f) => f(z))) !== soll);
if (daneben.length) {
console.log(` ACHTUNG: Der Konsolenfilter ordnet ${daneben.length} Probe(n) falsch ein: `
+ daneben.map(([z]) => z.slice(0, 40)).join(" | "));
ausgang = 1;
}
}
if (echt.length) {
console.log(` KONSOLE: ${echt.length} unerwartete(r) Fehlschlag/Fehlschlaege`);
for (const z of echt.slice(0, 6)) console.log(` ${z}`);
ausgang = 1;
} else {
console.log(` Konsole sauber (${konsole.length} bestellte Fehlschlaege gefiltert).`);
}
await fremd.close();
await hostSeite.kontext.close();
await browser.close();
await new Promise((r) => vorbau.close(r));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\nFertig. Rueckgabe ${ausgang}.`);
process.exit(ausgang);
+217
View File
@@ -0,0 +1,217 @@
/* =====================================================================
WIE SIEHT DIE GESPRÄCHSLISTE AUS? (24.09.2026)
Filipe, mit zwei Bildschirmfotos: "ich will dass die komplette
kachel viel krasser und geiler aussieht ... viel moderner. hau
richtig rein und mach was spezielles."
---------------------------------------------------------------------
WARUM ES DIESE DATEI GIBT
Es gibt acht `pruef-chat*`-Dateien. Sie beweisen, dass der Chat
FUNKTIONIERT -- dass zwei Leute einander erreichen, dass die
Nachtruhe haelt, dass niemand in einen fremden Raum sieht. Keine
davon zeigt, wie die Liste AUSSIEHT, wenn sieben Gespraeche
verschiedener Art darin stehen: ein Kanal mit Erwaehnung und sechs
Ungelesenen, eine Gruppe, vier Zweiergespraeche mit Gesichtern.
Genau das war der Zustand auf Filipes Bildschirmfoto -- und genau
dort fiel auf, dass die "6" eine Zeile tiefer stand als das "@".
Diese Datei legt den Bestand an, den er sieht, und macht Bilder.
Sie prueft NICHTS und meldet keinen Fehlschlag.
Sie heisst `mess-` und nicht `pruef-`: Beide Sorten leiten ihre
Portnummer aus der Stelle im Alphabet ab, aber jede aus IHRER
eigenen Liste und in ihrem eigenen Bereich (Pruefungen ab 5000,
Messungen ab 5900). So verschiebt eine neue Messdatei die Nummern
der Pruefungen nicht -- und umgekehrt.
Aufrufen mit: node server/mess-chat-liste.mjs
Bilder: server/mess-chat-liste-breit.png
server/mess-chat-liste-handy.png
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-messchat-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "mess-chat-liste");
await new Promise((r) => setTimeout(r, 800));
const BASIS = `http://127.0.0.1:${PORT}`;
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
/* Dieselbe Besetzung wie auf Filipes Bildschirmfoto -- damit sich das
Bild unmittelbar mit seinem vergleichen laesst. */
const ich = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const leute = [
{ name: "VanVan", rolle: "hand" },
{ name: "Ghost", rolle: "modi" },
{ name: "Diene", rolle: "modi" },
{ name: "Marina", rolle: "modi" },
{ name: "BananaStift", rolle: "modi" },
{ name: "Patrick", rolle: "modi" },
];
for (const p of leute) p.id = anlegen(p.name, p.rolle, `CODE-${p.name.toUpperCase()}-1`);
d.close();
/* ---- Anmelden ------------------------------------------------------- */
async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const keksIch = await anmelden("admin", "CODE-DOGI-0001");
for (const p of leute) p.keks = await anmelden(p.rolle, `CODE-${p.name.toUpperCase()}-1`);
const hin = (weg, keks, koerper) => fetch(BASIS + weg, {
method: "POST", headers: { "Content-Type": "application/json", Cookie: keks },
body: JSON.stringify(koerper),
});
const wer = (name) => leute.find((p) => p.name === name);
/* ---- Die Gespräche -------------------------------------------------- */
const raeume = {};
{
/* Ein Kanal, eine Gruppe, vier Zweiergespräche -- die drei Arten, die
die Liste unterscheiden muss. */
const k = await hin("/workspace/api/chat/kanal", keksIch,
{ kategorie: "", eigener_name: "Chat-Moderation", mit: leute.map((p) => p.id) });
raeume.kanal = (await k.json()).raum_id;
const k2 = await hin("/workspace/api/chat/kanal", keksIch,
{ kategorie: "", eigener_name: "Das Rudel", mit: leute.map((p) => p.id) });
raeume.rudel = (await k2.json()).raum_id;
const g = await hin("/workspace/api/chat/gruppe", keksIch,
{ name: "Abmeldungen \u{1F5D2}", mit: leute.map((p) => p.id) });
raeume.gruppe = (await g.json()).raum_id;
for (const name of ["VanVan", "Ghost", "BananaStift", "Patrick"]) {
const a = await hin("/workspace/api/chat/direkt", keksIch, { mit: wer(name).id });
raeume[name] = (await a.json()).raum_id;
}
}
/* ---- Was darin steht ------------------------------------------------- */
const schreib = (raum, keks, text) =>
hin(`/workspace/api/chat/raeume/${raum}/nachrichten`, keks, { text });
/* Sechs Ungelesene und eine Erwähnung -- der Zustand aus dem
Bildschirmfoto. */
for (let i = 1; i <= 5; i++) {
await schreib(raeume.rudel, wer("Diene").keks,
`Kurze Ansage Nummer ${i} für alle im Rudel.`);
}
await schreib(raeume.rudel, wer("Diene").keks,
"Hallo ihr Lieben, schaut gerne mal rein @Filipe – das wollte ich dir zeigen.");
await schreib(raeume.VanVan, wer("VanVan").keks,
"Da hat Miss was falsch eingegeben. Ich kümmere mich gleich drum.");
await schreib(raeume.Ghost, wer("Ghost").keks,
"Sorry hab's gefunden ist gelöscht. Hatte ich übersehen.");
await schreib(raeume.kanal, wer("Marina").keks, "Ach man \u{1F97A}\u{1F97A}");
await schreib(raeume.gruppe, wer("Diene").keks,
"Guten Morgen ihr Lieben ich schaffe es heute leider nicht.");
await schreib(raeume.BananaStift, wer("BananaStift").keks,
"Community Talk \u{1F6EB} fällt der heute aus?");
await schreib(raeume.Patrick, keksIch, "Alles klar");
/* ---- Bilder ---------------------------------------------------------- */
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
for (const [wie, breite, hoehe] of [["breit", 1280, 900], ["handy", 390, 860]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keksIch.split("; ").map((k) => {
const [name, ...rest] = k.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
await seite.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
await seite.waitForSelector("#raeume .chat-raum", { timeout: 15_000 });
await seite.waitForTimeout(700);
const ziel = await seite.$(wie === "breit" ? ".chat" : ".chat__spalte--liste");
await ziel.screenshot({ path: `server/mess-chat-liste-${wie}.png` });
console.log(` -- Bild (${breite} px): server/mess-chat-liste-${wie}.png`);
if (wie === "breit") {
const z = await seite.evaluate(() =>
[...document.querySelectorAll("#raeume .chat-raum")].map((li) => {
const k = li.querySelector(".chat-raum__knopf");
return {
name: li.querySelector(".chat-raum__name")?.textContent || "",
art: li.dataset.art || "",
neu: li.dataset.neu || "-",
zahl: li.querySelector(".chat-raum__zahl")?.textContent || "",
at: li.querySelector(".chat-raum__erwaehnt") ? "@" : " ",
bilder: li.querySelectorAll(".chat-raum__zeichen img").length,
hoch: Math.round(k.getBoundingClientRect().height),
};
}));
console.log("");
for (const x of z) {
console.log(` ${x.name.padEnd(18)} ${x.art.padEnd(7)} `
+ `${x.at} ${String(x.zahl).padStart(3)} ${x.hoch}px Bilder: ${x.bilder}`);
}
const hoehen = [...new Set(z.map((x) => x.hoch))];
console.log("");
console.log(` -- Zeilenhoehen: ${hoehen.join(", ")} px `
+ `(${hoehen.length === 1 ? "alle gleich" : "UNTERSCHIEDLICH"})`);
const doppelt = z.filter((x) => x.bilder > 1);
console.log(` -- Zeilen mit doppeltem Gesicht: ${doppelt.length}`);
}
if (konsole.length) console.log(" !! Konsole: " + konsole[0]);
await kontext.close();
}
} finally {
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+263
View File
@@ -0,0 +1,263 @@
/* =====================================================================
WO STEHT DER VERLAUF, WENN MAN IHN AUFMACHT? (25.09.2026)
=====================================================================
Filipe: „wenn ich in den chat rein gehe und es neue kommentare gibt,
will ich dass mein chat sich da öffnet wo die neuen nachrichten
anfangen die ich noch nicht gesehen hab bitte und nicht immer ganz
unten. sonst muss man immer hoch scrollen um die neuen zu lesen und
das ist scheisse."
IM QUELLTEXT STEHT DAS SEIT DEM 09.09.2026 -- eine Linie „Ab hier
neu" und ein Sprung darauf. Filipe sagt, es passiert nicht. Also
wird gemessen statt gelesen: Zwei Menschen, echte Nachrichten, und
danach die Frage, wo der Verlauf wirklich steht.
GEMESSEN WIRD IN PIXELN, nicht im Gefuehl: Wie weit ist die Linie
vom oberen Rand des sichtbaren Bereichs entfernt? Steht sie im Bild?
Und wie viel Verlauf liegt darunter -- also: wie weit haette man
scrollen muessen?
Aufrufen mit: node server/mess-chat-neu-stelle.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-chat-neu-stelle (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-neustelle-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(300_000, "mess-chat-neu-stelle");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idA = anlegen("Kessi", "modi", "CODE-MODI-0001");
d.close();
const roh = (keks, pfad, methode = "GET", koerper = null) =>
new Promise((fertig, schief) => {
const daten = koerper === null ? null : JSON.stringify(koerper);
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: CREW, ...(keks ? { Cookie: keks } : {}),
Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/chat.html`,
...(daten ? { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(daten) } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ status: r.statusCode, kopf: r.headers,
json: () => { try { return JSON.parse(t); } catch { return {}; } } }));
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
const anmelden = async (rolle, code) => {
const a = await roh(null, "/workspace/api/anmelden", "POST", { rolle, code });
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
};
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
const kA = await anmelden("modi", "CODE-MODI-0001");
const gr = await roh(kDogi, "/workspace/api/chat/direkt", "POST", { mit: idA });
const raumId = gr.json().raum_id;
console.log(`Raum #${raumId}`);
const schreiben = (keks, text) =>
roh(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST", { text });
/* ---- 20 Nachrichten, die DogFather schon gelesen hat -------------- */
let letzteAlte = 0;
for (let i = 1; i <= 20; i++) {
const a = await schreiben(kA, `Alte Nachricht ${i} von Kessi.`);
letzteAlte = a.json().nachricht?.id || a.json().id || letzteAlte;
}
/* DogFather macht auf und liest. `bis` IST PFLICHT -- die Route
verlangt eine Nummer und lehnt sonst mit 400 ab. Im ersten Anlauf
habe ich ein leeres Objekt geschickt; `gelesen_bis` blieb damit auf
null, die Linie „Ab hier neu" entstand nie, und die Messung meldete
einen Fehler, den es nicht gab. Eine Messung, die falsch aufsetzt,
findet zuverlaessig Befunde -- nur nicht die echten. */
await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`);
const gel = await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/gelesen`,
"PUT", { bis: letzteAlte });
console.log(`gelesen bis #${letzteAlte}: HTTP ${gel.status}`);
await new Promise((r) => setTimeout(r, 300));
/* ---- Und jetzt zwoelf neue, die er NICHT gesehen hat -------------- */
for (let i = 1; i <= 12; i++) await schreiben(kA, `NEUE Nachricht ${i} von Kessi.`);
const stand = await roh(kDogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`);
console.log(`gelesen_bis beim Oeffnen: ${stand.json().gelesen_bis}`
+ `, Nachrichten: ${(stand.json().nachrichten || []).length}`);
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
for (const [wie, breite, hoehe, mobil] of [
["handy", 390, 844, true], ["rechner", 1280, 900, false],
]) {
/* ==== VOR JEDEM DURCHLAUF DEN LESESTAND ZURUECKSETZEN ===========
Beim Oeffnen meldet die Seite „gelesen bis hier" -- das ist
richtig und hat im ersten Anlauf den zweiten Durchlauf ruiniert:
Nach dem Handy war nichts mehr neu, die Linie entstand nicht, und
die Messung meldete fuer den Rechner „Linie FEHLT". Ein Befund,
den die Messung selbst erzeugt hatte.
Zurueckgesetzt wird direkt in der Datenbank und nicht ueber die
Route: Die laesst `bis` absichtlich nicht kleiner werden. */
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil,
});
await kontext.addCookies(kDogi.split("; ").map((k) => {
const [x, ...r] = k.split("=");
return { name: x, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
/* DER LESESTAND WIRD UNMITTELBAR VOR DEM AUFRUF ZURUECKGESETZT.
Beim Oeffnen meldet die Seite „gelesen bis hier" -- richtig, und
im ersten Anlauf hat es den zweiten Durchlauf ruiniert: Nach dem
Handy war nichts mehr neu, die Linie entstand nicht, und die
Messung meldete fuer den Rechner „Linie FEHLT". Ein Befund, den
die Messung selbst erzeugt hatte.
Der Reset stand zuerst vor `newContext` -- ein paar Millisekunden
zu frueh: Die verzoegerte Meldung des vorigen Durchlaufs kam
danach. Jetzt steht er unmittelbar vor dem Aufruf, mit einer
kurzen Pause davor, damit der vorige Browser wirklich fertig ist.
Zurueckgesetzt wird in der Datenbank und nicht ueber die Route:
Die laesst `bis` absichtlich nicht kleiner werden. */
await new Promise((r) => setTimeout(r, 600));
{
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
dd.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
.run(letzteAlte, raumId, idDogi);
dd.close();
}
await seite.goto(`https://${CREW}:${HP}/workspace/chat.html?raum=${raumId}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(2200);
const lage = await seite.evaluate(() => {
const v = document.getElementById("verlauf");
if (!v) return { da: false };
const linie = v.querySelector(".chat-neu");
const alle = [...v.querySelectorAll(".chat-nachricht")];
const sicht = v.getBoundingClientRect();
const ersteSichtbare = alle.find((z) => {
const r = z.getBoundingClientRect();
return r.bottom > sicht.top + 4;
});
return {
da: true,
hoehe: Math.round(v.clientHeight),
gesamt: Math.round(v.scrollHeight),
oben: Math.round(v.scrollTop),
amEnde: v.scrollHeight - v.scrollTop - v.clientHeight < 40,
linieDa: !!linie,
linieOben: linie ? Math.round(linie.offsetTop) : null,
/* Steht die Linie IM BILD? Das ist die eigentliche Frage. */
linieImBild: linie
? (linie.getBoundingClientRect().top >= sicht.top - 2
&& linie.getBoundingClientRect().bottom <= sicht.bottom + 2)
: false,
linieAbstandVonOben: linie
? Math.round(linie.getBoundingClientRect().top - sicht.top) : null,
ersteSichtbar: ersteSichtbare
? (ersteSichtbare.textContent || "").trim().slice(0, 34) : "-",
nachrichten: alle.length,
/* Wie weit muesste man hochscrollen, um die Linie zu sehen? */
muessteHoch: linie
? Math.max(0, Math.round(v.scrollTop - linie.offsetTop)) : null,
};
});
console.log(`\n=== ${wie} (${breite}x${hoehe}) ===`);
if (!lage.da) { console.log(" KEIN VERLAUF"); }
else {
console.log(` Verlauf ${lage.hoehe} px hoch, Inhalt ${lage.gesamt} px, `
+ `steht bei ${lage.oben}${lage.amEnde ? " (GANZ UNTEN)" : ""}`);
console.log(` Linie „Ab hier neu": ${lage.linieDa ? "da" : "FEHLT"}`
+ (lage.linieDa ? `, bei ${lage.linieOben} px` : ""));
if (lage.linieDa) {
console.log(` Im Bild: ${lage.linieImBild ? "JA" : "NEIN"}`
+ `, ${lage.linieAbstandVonOben} px unter dem oberen Rand`
+ (lage.muessteHoch ? ` -- man muesste ${lage.muessteHoch} px HOCHSCROLLEN` : ""));
}
console.log(` Oben im Bild steht: "${lage.ersteSichtbar}"`);
console.log(` ${lage.nachrichten} Nachrichten im Verlauf`);
}
if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`);
await seite.screenshot({ path: `server/mess-neustelle-${wie}.png` }).catch(() => {});
await kontext.close();
}
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+254
View File
@@ -0,0 +1,254 @@
/* =====================================================================
WIE DER CHAT AUSSIEHT (25.09.2026)
Filipe: „ich will dass du die komplette seite viel geiler und
moderner machst. die komplette kachel. die chat liste und die chats
selber. … es soll komplett aus der rolle fahren und was was wir noch
nie hatten."
SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
auf denen man vergleichen kann. Deshalb `mess-` und nicht `pruef-`.
VIER BILDER, weil die Seite zwei Zustaende und zwei Breiten hat:
Liste ohne Gespraech / Liste mit offenem Gespraech, jeweils am
Rechner und am Handy. Wer nur eines ansieht, baut fuer eines.
EIGENE WEGWERF-DATENBANK: Es entstehen echte Gespraeche und
Nachrichten. In einem laufenden System waeren das Testdaten, und die
sind verboten. Hier liegt alles in einem `mktemp`-Ordner.
Aufrufen mit: node server/mess-chat-optik.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-chat-optik (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-chatoptik-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(260_000, "mess-chat-optik");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const vorStunden = (h) => new Date(Date.now() - h * 3600_000).toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
const idVan = anlegen("VanVan", "hand", "CODE-HAND-0001");
const idFunny = anlegen("Funny", "linke", "CODE-LINK-0001");
const idDiene = anlegen("Diene", "modi", "CODE-MODI-0001");
const idMarina = anlegen("Marina", "modi", "CODE-MODI-0002");
const idGhost = anlegen("Ghost", "modi", "CODE-MODI-0003");
const idMiss = anlegen("Miss", "modi", "CODE-MODI-0004");
/* ---- Gespraeche und Nachrichten direkt in die Datenbank -------------
Ueber die Schnittstelle waeren es fuenfzehn Aufrufe fuer einen
Zustand, den es nur fuer das Bild braucht. */
const raum = (art, name, kategorie, haus = "crew") => d.prepare(
"INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von, haus)"
+ " VALUES (?,?,?,?,?,?) RETURNING id").get(art, name, kategorie, jetzt, idDogi, haus).id;
const rein = (r, p) => d.prepare(
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)")
.run(r, p, jetzt);
const sagen = (r, p, text, wann) => d.prepare(
"INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(r, p, text, wann);
const rudel = raum("kanal", "Das Rudel", "treff");
for (const p of [idDogi, idVan, idFunny, idDiene, idMarina, idGhost, idMiss]) rein(rudel, p);
sagen(rudel, idFunny, "@rudel das mache ich heute Abend fertig, versprochen 🙌", vorStunden(20));
const modkanal = raum("kanal", "Chat-Moderation", "chat");
for (const p of [idDogi, idVan, idFunny, idDiene, idMarina, idGhost]) rein(modkanal, p);
sagen(modkanal, idMarina, "Ach man 😢😢", vorStunden(30));
const gruppe = raum("gruppe", "Abmeldungen 🏥", null);
for (const p of [idDogi, idVan, idFunny, idDiene, idMarina, idGhost, idMiss]) rein(gruppe, p);
sagen(gruppe, idDiene, "Guten Morgen ihr Lieben – ich bin heute leider raus, Hals tut weh.", vorStunden(31));
const mitVan = raum("direkt", null, null);
rein(mitVan, idDogi); rein(mitVan, idVan);
sagen(mitVan, idVan, "Da hat Miss was falsch eingegeben glaube ich", vorStunden(29));
sagen(mitVan, idDogi, "Hab's gesehen. Ich schreib ihr gleich, kein Ding.", vorStunden(28));
sagen(mitVan, idVan, "Top. Und die Abrechnung vom Wochenende liegt bei dir im Ordner.", vorStunden(27));
sagen(mitVan, idDogi, "Perfekt, danke dir ❤️", vorStunden(2));
const mitGhost = raum("direkt", null, null);
rein(mitGhost, idDogi); rein(mitGhost, idGhost);
sagen(mitGhost, idGhost, "Sorry hab's gefunden ist gelöscht 👍", vorStunden(29));
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(20), rudel);
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(2), mitVan);
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(29), mitGhost);
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(30), modkanal);
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(vorStunden(31), gruppe);
d.close();
const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { Host: CREW, "Content-Type": "application/json" },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "admin", code: "CODE-DOGI-0001" });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${an.code}`); process.exit(2); }
const keks = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
const MARKE = process.env.MARKE || "jetzt";
try {
for (const [wie, breite, hoehe] of [["computer", 1440, 980], ["handy", 412, 915]]) {
/* MIT FINGER, WENN ES EIN HANDY IST.
Ohne `hasTouch` meldet der Browser `pointer: fine` -- und
damit greift KEINE Regel aus `@media (pointer: coarse)`.
Dort stehen im ganzen Haus die 44-Pixel-Beruehrziele und
seit dem 28.09. auch das Ausblenden der Tastenkuerzel. Eine
Handy-Messung mit Mauszeiger zeigt eine Seite, die auf
keinem Handy so aussieht. */
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
hasTouch: breite <= 860,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR|410/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/chat.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1600);
const liste = await seite.evaluate(() => ({
raeume: document.querySelectorAll("#raeume li").length,
namen: [...document.querySelectorAll(".chat-raum__name")].map((x) => x.textContent),
}));
console.log(`\n=== Chat, ${breite} x ${hoehe} (${wie}) ===`);
console.log(" Gespraeche:", liste.raeume, "|", liste.namen.join(" · "));
await seite.screenshot({ path: `server/mess-chat-${MARKE}-${wie}-liste.png` });
/* Und jetzt mit offenem Gespraech -- das ist die Haelfte der
Seite, die man am laengsten ansieht. */
await seite.evaluate(() => {
const zeilen = [...document.querySelectorAll("#raeume .chat-raum__knopf")];
(zeilen.find((z) => /VanVan/.test(z.textContent)) || zeilen[0])?.click();
});
await seite.waitForTimeout(1500);
const verlauf = await seite.evaluate(() => ({
nachrichten: document.querySelectorAll(".chat-nachricht").length,
titel: document.getElementById("verlauf-titel")?.textContent,
}));
console.log(" Verlauf:", verlauf.nachrichten, "Nachrichten in", verlauf.titel);
await seite.screenshot({ path: `server/mess-chat-${MARKE}-${wie}-verlauf.png` });
if (konsole.length) console.log(" FEHLER IN DER KONSOLE:", konsole);
console.log(` Bilder: server/mess-chat-${MARKE}-${wie}-{liste,verlauf}.png`);
if (breite <= 860) {
/* WIE VIELE BRECHEN UM -- nicht „die erste".
`querySelector` nimmt die erste Fusszeile, und wie breit
die ist, haengt vom Text darueber ab: Bei einer kurzen
Nachricht ist die Blase schmal, bei einer langen breit.
Gemessen an der ersten sah eine Aenderung deshalb mal
besser und mal schlechter aus, ohne dass sich etwas
geaendert hatte. Gefragt ist, wie viele Nachrichten
betroffen sind. */
const fuss = await seite.evaluate(() => {
const alle = [...document.querySelectorAll(".chat-nachricht__fuss")]
.map((f) => {
const kinder = [...f.children].map((e) => e.getBoundingClientRect());
/* DIE BREITESTE ZEILE, NICHT DIE SUMME. Seit die Uhrzeit
absichtlich in einer eigenen Zeile steht, waere eine
Summe ueber alle Kinder die Breite zweier Zeilen
uebereinander -- sie meldete 444 px in einer 234 px
breiten Fusszeile und sah wie ein Ueberlauf aus. */
const nach = new Map();
for (const q of kinder) {
const y = Math.round(q.top);
nach.set(y, (nach.get(y) || 0) + Math.round(q.width));
}
return { zeilen: nach.size, hoch: Math.round(f.getBoundingClientRect().height),
platz: Math.round(f.getBoundingClientRect().width),
inhalt: Math.max(...nach.values()) };
});
if (!alle.length) return null;
const um = alle.filter((x) => x.zeilen > 1);
return { gesamt: alle.length, um: um.length,
schlimmste: alle.reduce((a2, x) => (x.zeilen > a2.zeilen ? x : a2), alle[0]),
hoehe: Math.round(alle.reduce((a2, x) => a2 + x.hoch, 0) / alle.length) };
});
if (fuss) {
console.log(` Handgriffe: ${fuss.um} von ${fuss.gesamt} Fusszeilen brechen um, `
+ `im Schnitt ${fuss.hoehe} px hoch`);
console.log(` schlimmste: ${fuss.schlimmste.zeilen} Zeilen, `
+ `Platz ${fuss.schlimmste.platz} px, Inhalt ${fuss.schlimmste.inhalt} px`);
}
}
await kontext.close();
}
} finally {
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+227
View File
@@ -0,0 +1,227 @@
/* =====================================================================
KOMMT MAN AUS EINEM FENSTER WIEDER HERAUS? (25.09.2026)
---------------------------------------------------------------------
DER ANLASS
Miss, über den Support-Weg: „Ich konnte da wieder nicht zurück gehen,
musste die App schließen damit ich wieder auf die Hauptseite kam."
Dazu ein Bildschirmfoto von „Aufgabe bearbeiten" auf dem Handy.
„WIEDER" IST DAS WICHTIGE WORT: Es ist nicht das erste Mal. Ein
Fenster, aus dem man nur herauskommt, indem man die App beendet, ist
die schlimmste Sorte Sackgasse -- man verliert dabei auch alles, was
man gerade eingetippt hat.
---------------------------------------------------------------------
WAS GEMESSEN WIRD
Für JEDES Fenster auf JEDER Seite, auf der es eines gibt:
1. Ist ein Ausgang (Abbrechen/Schließen) überhaupt im Baum?
2. Liegt er INNERHALB des sichtbaren Bereichs -- oder unterhalb?
3. Und wenn er unterhalb liegt: Lässt sich dorthin SCROLLEN?
Punkt 3 ist der eigentliche: Ein Knopf weiter unten ist harmlos,
solange man hinkommt. `.dialog` hat aber `overflow: hidden`, und nur
`.dialog--breit .neu` bekommt eine Scroll-Höhe. Bei allen anderen
wird der Rest schlicht abgeschnitten.
GEMESSEN WIRD AUF 412 x 915 -- der häufigsten Android-Größe. Auf dem
Rechner fällt das nie auf: Dort ist der Bildschirm hoch genug.
Aufrufen mit: node server/mess-dialog-ausgang.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-dialog-ausgang (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-dialog-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-dialog-ausgang");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idModi = anlegen("Miss", "modi", "CODE-MODI-0001");
/* Eine Aufgabe, die dem Modi gehoert -- sonst gibt es nichts zu
bearbeiten, und die Messung waere gruen ueber einer Null. */
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet,
verantwortlich_id, frist, erstellt, erstellt_von, haus)
VALUES (?,?, 'offen', 'hoch', ?, ?, ?, ?, 'crew')`)
.run("Die Regeln des Rudels einmal ganz lesen",
"Nicht überfliegen. Wer sie kennt, kann sie erklären – und Erklären ist in "
+ "neun von zehn Fällen wirksamer als Löschen.",
idModi, "2026-09-26", jetzt, idDogi);
d.close();
const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { Host: CREW, "Content-Type": "application/json" },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "modi", code: "CODE-MODI-0001" });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${an.code}`); process.exit(2); }
const keks = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
/* Die Hoehen, die es wirklich gibt -- nicht die aus dem Datenblatt.
`app` ist die installierte App (nur die Systemleiste), `browser`
zusaetzlich mit Adresszeile, `klein` ein aelteres Geraet, `tastatur`
dasselbe Geraet mit offener Bildschirmtastatur. Die letzte ist die
wichtigste: Man hat ja gerade etwas eingetippt. */
const GROESSEN = [
["app", 412, 915],
["browser", 412, 780],
["klein", 360, 640],
["tastatur", 412, 430],
];
let schlimm = 0;
try {
/* MEHRERE GROESSEN UND NICHT EINE (25.09.2026). Der erste Anlauf
mass nur 412 x 915 -- die Angabe aus dem Geraetedatenblatt. Dort
passte es KNAPP: 782 px Inhalt in 784 px Fenster, Ausgang gerade
noch sichtbar, und die Messung meldete "JA".
Das ist der BESTFALL und nicht der Alltag: Im Browser steht die
Adresszeile darueber, in der App die Systemleiste, und beim Tippen
kommt die Tastatur. Jede dieser Hoehen ist kleiner. Eine Messung,
die nur den guenstigsten Fall kennt, sagt genau dann "in Ordnung",
wenn der Nutzer schon feststeckt. */
for (const [wie, breite, hoehe] of GROESSEN) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`https://${CREW}:${HP}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
/* Das Bearbeiten-Fenster oeffnen -- ueber den Stift an der Aufgabe,
genau so wie Miss es getan hat. */
const stift = seite.locator('.aufgabe [title*="earbeit"], .aufgabe button').first();
if (await stift.count()) { await stift.click().catch(() => {}); }
await seite.waitForTimeout(400);
const offen = await seite.evaluate(() => {
const dlg = document.getElementById("bearbeiten-dialog");
if (dlg && !dlg.open) { try { dlg.showModal(); } catch { /* egal */ } }
return !!(dlg && dlg.open);
});
await seite.waitForTimeout(500);
const mass = await seite.evaluate(() => {
const dlg = document.getElementById("bearbeiten-dialog");
if (!dlg || !dlg.open) return null;
const raus = dlg.querySelector("#b-abbrechen");
const rd = raus ? raus.getBoundingClientRect() : null;
const dd = dlg.getBoundingClientRect();
/* WELCHES ELEMENT KANN ROLLEN? Gefragt wird der gemessene Zustand
(scrollHeight > clientHeight UND overflow erlaubt es), nicht die
Absicht im Stilblatt. */
const rollt = (el) => {
if (!el) return false;
const s = getComputedStyle(el);
const geht = /auto|scroll/.test(s.overflowY);
return geht && el.scrollHeight - el.clientHeight > 4;
};
const form = dlg.querySelector("form");
return {
fensterHoehe: window.innerHeight,
dialogOben: Math.round(dd.top), dialogUnten: Math.round(dd.bottom),
dialogHoehe: Math.round(dd.height),
inhaltHoehe: form ? form.scrollHeight : null,
dialogOverflow: getComputedStyle(dlg).overflowY,
formOverflow: form ? getComputedStyle(form).overflowY : null,
ausgangDa: !!raus,
ausgangUnten: rd ? Math.round(rd.bottom) : null,
ausgangSichtbar: !!(rd && rd.bottom <= window.innerHeight + 1 && rd.top >= -1
&& rd.width > 0 && rd.height > 0),
dialogRollt: rollt(dlg),
formRollt: rollt(form),
};
});
console.log(`\n=== Aufgabe bearbeiten, ${breite} x ${hoehe} (${wie}) ===\n`);
if (!offen || !mass) {
console.log(" ABBRUCH: Das Fenster liess sich nicht oeffnen -- gemessen wurde nichts.");
schlimm = 1;
} else {
for (const [k, v] of Object.entries(mass)) console.log(` ${k.padEnd(16)} ${v}`);
const rauskommen = mass.ausgangSichtbar || mass.dialogRollt || mass.formRollt;
console.log(`\n ==> Kommt man heraus? ${rauskommen ? "JA" : "NEIN -- SACKGASSE"}`);
if (!rauskommen) schlimm = 1;
await seite.locator("#bearbeiten-dialog")
.screenshot({ path: `server/mess-dialog-${wie}.png` }).catch(() => {});
console.log(` Bild: server/mess-dialog-${wie}.png`);
}
await kontext.close();
}
} finally {
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(schlimm);
}
+192
View File
@@ -0,0 +1,192 @@
/* =====================================================================
WIE DER EINGANG AUSSIEHT (25.09.2026)
Filipe zum Bildschirmfoto der Seite: „lass das bitte auch geiler
aussehen bitte."
SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
auf denen man nachsehen kann. Deshalb `mess-` und nicht `pruef-`.
ZWEI BREITEN, und die kleinere ist die wichtigere: Ab 760 px steht
das Bild neben dem Text, darunter untereinander. Wer nur die grosse
ansieht, sieht die Haelfte der Regel nicht.
EIGENE WEGWERF-DATENBANK: Es entsteht eine echte Meldung mit Bild.
In einem laufenden System waeren das Testdaten, und die sind
verboten. Hier liegt alles in einem `mktemp`-Ordner.
Aufrufen mit: node server/mess-eingang-optik.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-eingang-optik (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-eingang-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-eingang-optik");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idModi = anlegen("Miss", "modi", "CODE-MODI-0001");
const idM2 = anlegen("Marina", "modi", "CODE-MODI-0002");
/* Ein winziges PNG als Anhang -- es geht um die FORM der Karte, nicht
um das Motiv. 1x1 Bildpunkt reicht dafuer nicht: Der Kasten waere
dann zwei Pixel gross und man saehe die Spaltenaufteilung nicht.
Deshalb ein hochkantes Rechteck, so wie ein Handy-Bildschirmfoto. */
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAGQAAADwCAYAAAB7ovsMAAAAWklEQVR42u3QMQEAAAjAoNu/"
+ "tBmeIAfpVoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAA"
+ "AQIECBAgQIAAAQIECBAgQIAAAe4W1H8B4aXvOgAAAABJRU5ErkJggg==", "base64");
/* DIE BILDER STEHEN IN `support_bilder` (ab 02.10.2026), nicht mehr
in Spalten der Meldung. Hier direkt dort eintragen und NICHT auf
die Uebernahme beim Start hoffen: Die ist schon gelaufen, der
Server steht oben in dieser Datei laengst. */
const meldungId = d.prepare(`INSERT INTO support_meldungen
(person_id, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?) RETURNING id`)
.get(idModi,
"Ich konnte da wieder nicht zurück gehen, musste die App schließen "
+ "damit ich wieder auf die Hauptseite kam",
"/workspace/support.html", "in_arbeit", jetzt, jetzt).id;
d.prepare(`INSERT INTO support_bilder
(meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
VALUES (?,NULL,?,?,?,?,?)`)
.run(meldungId, "probe.png", "image/png", 100, 240, jetzt);
d.prepare(`INSERT INTO support_meldungen
(person_id, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?)`)
.run(idM2, "Der Kalender springt beim Wischen manchmal zwei Monate weiter.",
"/workspace/kalender.html", "neu", jetzt, jetzt);
/* Die Bilddatei dort ablegen, wo die Route sie sucht. */
const { DATEN_ORDNER } = await import("./workspace.js");
try {
const { mkdirSync, writeFileSync } = await import("node:fs");
/* DER ORDNER HEISST `support-bilder`, nicht `support` -- so steht
es in workspace-support.js. Hier stand der falsche Name, und das
Bild landete neben der Tuer statt dahinter. */
const ziel = join(DATEN_ORDNER, "support-bilder");
mkdirSync(ziel, { recursive: true });
writeFileSync(join(ziel, "probe.png"), png);
} catch (f) { console.log("Bild nicht abgelegt:", f?.message); }
d.close();
const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { Host: CREW, "Content-Type": "application/json" },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "admin", code: "CODE-DOGI-0001" });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${an.code}`); process.exit(2); }
const keks = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
try {
for (const [wie, breite, hoehe] of [["computer", 1280, 1100], ["handy", 412, 915]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/support.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
const blick = await seite.evaluate(() => {
const block = document.getElementById("eingang-block");
const karten = [...document.querySelectorAll(".s-karte")];
const mitBild = karten.find((k) => k.querySelector(".s-karte__bild"));
const spalten = mitBild ? getComputedStyle(mitBild).gridTemplateColumns : "-";
return {
eingangSichtbar: !!(block && !block.hidden),
karten: karten.length,
mitBild: !!mitBild,
spalten,
felder: document.querySelectorAll(".s-bilanz__feld").length,
leerMarkiert: document.querySelectorAll('.s-bilanz__feld[data-leer="ja"]').length,
};
});
console.log(`\n=== Eingang, ${breite} x ${hoehe} (${wie}) ===`);
for (const [k, v] of Object.entries(blick)) console.log(` ${k.padEnd(16)} ${v}`);
if (konsole.length) console.log(" FEHLER IN DER KONSOLE:", konsole);
const ziel = seite.locator("#eingang-block");
if (await ziel.count()) {
await ziel.screenshot({ path: `server/mess-eingang-${wie}.png` }).catch(() => {});
console.log(` Bild: server/mess-eingang-${wie}.png`);
}
await kontext.close();
}
} finally {
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+224
View File
@@ -0,0 +1,224 @@
/* =====================================================================
WIE SIEHT DIE NEUE PERSONENKACHEL AUS? (24.09.2026)
Filipe: "mach das bitte viel krasser und viel geiler. die aufgaben
die wir verteilen sollen da auch zu sehen sein und so. viel
uebersichtlicher, viel moderner."
---------------------------------------------------------------------
WARUM ES DIESE DATEI GIBT, OBWOHL pruef-entwicklung-kacheln EXISTIERT
Die Pruefung nebenan misst, ob die Kachel STIMMT -- ob Ring, Ziffer,
Zeile und Vorleseschild dieselbe Zahl sagen. Dafuer braucht sie
einen kargen Bestand: zwei Leute, alles auf null.
Genau dadurch zeigt ihr Bildschirmfoto den Sonderfall, nicht den
Alltag: zwei leere Ringe nebeneinander. Ob ein Ring bei 68 Prozent
gut aussieht, ob eine Kachel mit drei ueberfaelligen Aufgaben noch
ruhig wirkt, ob sechs Kacheln am Handy uebersichtlich bleiben --
davon steht dort nichts.
Diese Datei legt deshalb einen Bestand an, der wie der echte
aussieht: sechs Leute, jeder anders weit, jeder mit anderen
Aufgaben. Sie prueft NICHTS und meldet keinen Fehlschlag. Sie macht
Bilder, an denen man entscheiden kann.
Sie heisst deshalb `mess-` und nicht `pruef-`: Die Portvergabe
leitet sich aus den `pruef-`-Namen ab, und eine Messdatei darf die
Nummern der Pruefungen nicht verschieben.
Aufrufen mit: node server/mess-entwicklung-kacheln.mjs
Bilder: server/mess-entwicklung-kacheln-breit.png
server/mess-entwicklung-kacheln-handy.png
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-messkachel-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "mess-entwicklung-kacheln");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const { ENTWICKLUNG_FREMD } = await import("./workspace-entwicklungs-punkte.js");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const ich = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const leute = [
{ name: "VanVan", rolle: "hand", anteil: 1.00, aufgaben: 0, spaet: 0 },
{ name: "Kessi", rolle: "linke", anteil: 0.68, aufgaben: 3, spaet: 1 },
{ name: "Rieke", rolle: "modi", anteil: 0.22, aufgaben: 5, spaet: 2 },
{ name: "Nala", rolle: "modi", anteil: 0.00, aufgaben: 1, spaet: 0 },
{ name: "Juli", rolle: "modi", anteil: 0.47, aufgaben: 0, spaet: 0 },
{ name: "Momo", rolle: "modi", anteil: 0.91, aufgaben: 2, spaet: 0 },
];
for (const p of leute) p.id = anlegen(p.name, p.rolle, `CODE-${p.name.toUpperCase()}-1`);
/* Eine zweite Stimme -- nur damit "verschieden gesehen" ueberhaupt
entstehen kann. Sie braucht keine Kachel. */
const zweite = anlegen("Mira", "linke", "CODE-MIRA-0001");
/* ---- Der Stand: wie weit ist Filipe bei wem? -----------------------
ERST ANMELDEN, DANN SCHREIBEN. `entwicklung_stand` entsteht nicht
beim Start, sondern beim ersten Aufruf der Entwicklungsseite
(tabellen() in workspace-entwicklung.js). Wer vorher hineinschreibt,
bekommt "no such table" -- genau der Fehler, den die Pruefung dort
schon einmal gefunden hat. */
d.close();
const vorlauf = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
});
const vorKeks = (vorlauf.headers.getSetCookie?.() || [])
.map((z) => z.split(";")[0]).join("; ");
await fetch(BASIS + "/workspace/api/entwicklung/lage", { headers: { Cookie: vorKeks } });
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
const setze = d2.prepare(
"INSERT OR REPLACE INTO entwicklung_stand (person_id, schluessel, von_id, stufe, anlass, am)"
+ " VALUES (?,?,?,?,NULL,?)");
const alt = new Date(Date.now() - 90 * 86400000).toISOString();
for (const p of leute) {
const wie_viele = Math.round(ENTWICKLUNG_FREMD.length * p.anteil);
ENTWICKLUNG_FREMD.slice(0, wie_viele).forEach((punkt, i) => {
/* Ein paar alte Eintraege bei Kessi -- so steht auf ihrer Kachel
auch die Zeile "x aelter als 60 Tage". */
const wann = (p.name === "Kessi" && i < 4) ? alt : jetzt;
setze.run(p.id, punkt.schluessel, ich, "laeuft", wann);
});
/* Zwei Punkte, die Mira anders sieht -- daraus wird "verschieden
gesehen" auf Riekes Kachel. */
if (p.name === "Rieke") {
ENTWICKLUNG_FREMD.slice(0, 2).forEach((punkt) =>
setze.run(p.id, punkt.schluessel, zweite, "hakt", jetzt));
}
}
/* ---- Die Aufgaben -------------------------------------------------- */
const tagPlus = (n) => {
const t = new Date(); t.setHours(12, 0, 0, 0); t.setDate(t.getDate() + n);
return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}`
+ `-${String(t.getDate()).padStart(2, "0")}`;
};
const TITEL = [
"Clips vom Samstag schneiden", "Plan für nächste Woche", "Ideen sammeln",
"Rückmeldung an die Neue", "Termine für Oktober eintragen",
];
const neueAufgabe = d2.prepare(
"INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id, frist,"
+ " erstellt, erstellt_von) VALUES (?,?,'mittel',?,?,?,?,?)");
for (const p of leute) {
for (let i = 0; i < p.aufgaben; i++) {
const frist = i < p.spaet ? tagPlus(-(i + 1)) : (i === p.spaet ? tagPlus(i + 1) : null);
neueAufgabe.run(TITEL[i % TITEL.length], "offen", ich, p.id, frist, jetzt, ich);
}
/* Eine erledigte je Person -- sie darf nicht mitzaehlen. */
neueAufgabe.run("Längst abgehakt", "erledigt", ich, p.id, tagPlus(-9), jetzt, ich);
}
d2.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
for (const [wie, breite, hoehe] of [["breit", 1280, 1000], ["handy", 390, 1100]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [name, ...rest] = k.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
await seite.goto(`${BASIS}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await seite.waitForSelector("#personen .e-person", { timeout: 15_000 });
/* Warten, bis die Aufgabenzeilen nachgezogen sind -- sie kommen aus
einem zweiten Abruf, der nach dem Zeichnen laeuft. */
await seite.waitForFunction(() =>
[...document.querySelectorAll(".e-person__auftrag")].some((z) => z.textContent.trim()),
{ timeout: 15_000 });
await seite.waitForTimeout(600);
const block = await seite.$("#personen-block");
await block.screenshot({ path: `server/mess-entwicklung-kacheln-${wie}.png` });
console.log(` -- Bild (${breite} px): server/mess-entwicklung-kacheln-${wie}.png`);
const zahlen = await seite.evaluate(() =>
[...document.querySelectorAll("#personen .e-person")].map((k) => ({
name: k.querySelector(".e-person__name")?.textContent,
ring: k.querySelector(".e-ring__zahl")?.textContent,
bogen: k.querySelector(".e-ring__bogen")?.getAttribute("stroke-dasharray"),
auftrag: (k.querySelector(".e-person__auftrag")?.textContent || "").trim(),
fuss: (k.querySelector(".e-person__fuss")?.textContent || "").trim(),
hoch: Math.round(k.getBoundingClientRect().height),
breit: Math.round(k.getBoundingClientRect().width),
})));
if (wie === "breit") {
console.log("");
for (const z of zahlen) {
console.log(` ${z.name.padEnd(8)} ${String(z.ring + "%").padStart(5)} `
+ `[${z.bogen}] ${z.breit}x${z.hoch}px`);
console.log(` ${z.auftrag.replace(/\s+/g, " ")}`);
console.log(` ${z.fuss.replace(/\s+/g, " ")}`);
}
console.log("");
} else {
const proZeile = {};
for (const z of zahlen) proZeile[z.breit] = (proZeile[z.breit] || 0) + 1;
console.log(` -- Handy: Kachelbreiten ${Object.keys(proZeile).join(", ")} px, `
+ `Hoehen ${[...new Set(zahlen.map((z) => z.hoch))].join(", ")} px`);
}
if (konsole.length) console.log(" !! Konsole: " + konsole[0]);
await kontext.close();
}
} finally {
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+138
View File
@@ -0,0 +1,138 @@
/* =====================================================================
WELCHE FARBE HAT DIESER TEXT WIRKLICH -- UND WORAUF LIEGT ER?
=====================================================================
Gebaut am 25.09.2026, weil `pruef-neue-seiten` einen Kontrast von
1,07 meldete ("Schoen, dass du da bist") und die Stelle im CSS
tadellos aussah. Diese Messung liest die Farben an Ort und Stelle
aus und geht die Elternkette hoch, bis sie eine deckende Flaeche
findet -- genau das, was die Pruefung tut, nur sichtbar.
Ergebnis damals: dunkler Text auf HELLBLAUER Flaeche, rund 10:1.
Die Pruefung konnte das Format `color(srgb ...)` nicht lesen, ging
deshalb zum fast schwarzen Elternteil weiter und rechnete dunkel
gegen dunkel. Ohne diese Messung haette ich an einem CSS geaendert,
an dem nichts war.
Aufrufen mit:
node server/mess-farbe-am-text.mjs <seite.html> "<Textanfang>"
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const SEITE = process.argv[2] || "treff-regeln.html";
const SUCHE = process.argv[3] || "Schön, dass du da bist";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-kontrast-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(200_000, "mess-kontrast");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
d.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`${BASIS}/workspace/${SEITE}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const fund = await seite.evaluate((suche) => {
const alle = [...document.querySelectorAll("*")].filter(
(e) => e.children.length === 0 && (e.textContent || "").trim().startsWith(suche));
if (!alle.length) return { da: false };
const e = alle[0];
const st = getComputedStyle(e);
const kette = [];
let p = e;
while (p && p !== document.documentElement) {
const ps = getComputedStyle(p);
kette.push({
was: p.tagName.toLowerCase()
+ (p.className && typeof p.className === "string"
? "." + p.className.trim().split(/\s+/).slice(0, 3).join(".") : ""),
farbe: ps.backgroundColor,
bild: ps.backgroundImage === "none" ? "" : ps.backgroundImage.slice(0, 60),
deckkraft: ps.opacity,
});
p = p.parentElement;
}
const r = e.getBoundingClientRect();
return {
da: true,
text: (e.textContent || "").trim().slice(0, 40),
farbe: st.color,
groesse: st.fontSize, fett: st.fontWeight,
clip: st.webkitBackgroundClip || st.backgroundClip,
eigenBild: st.backgroundImage === "none" ? "" : st.backgroundImage.slice(0, 80),
deckkraft: st.opacity,
stelle: `${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}`,
kette: kette.slice(0, 6),
};
}, SUCHE);
console.log(`\n=== "${SUCHE}" auf ${SEITE} ===`);
if (!fund.da) console.log(" NICHT GEFUNDEN");
else {
console.log(` Text "${fund.text}"`);
console.log(` Farbe ${fund.farbe} ${fund.groesse} / ${fund.fett}`);
console.log(` Deckkraft ${fund.deckkraft}`);
console.log(` clip ${fund.clip}`);
if (fund.eigenBild) console.log(` eigenes Hintergrundbild: ${fund.eigenBild}`);
console.log(` Stelle ${fund.stelle}`);
console.log(" Kette (von innen nach aussen):");
for (const k of fund.kette) {
console.log(` ${k.was.padEnd(38)} bg ${k.farbe}`
+ (k.bild ? ` bild ${k.bild}` : "")
+ (k.deckkraft !== "1" ? ` opacity ${k.deckkraft}` : ""));
}
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+140
View File
@@ -0,0 +1,140 @@
/* =====================================================================
SIEHT MAN, WO DER TASTATURFOKUS STEHT?
=====================================================================
Gebaut am 25.09.2026, weil `pruef-neue-seiten` einen Kontrast von
1,07 meldete ("Schoen, dass du da bist") und die Stelle im CSS
tadellos aussah. Diese Messung liest die Farben an Ort und Stelle
aus und geht die Elternkette hoch, bis sie eine deckende Flaeche
findet -- genau das, was die Pruefung tut, nur sichtbar.
Ergebnis damals: dunkler Text auf HELLBLAUER Flaeche, rund 10:1.
Die Pruefung konnte das Format `color(srgb ...)` nicht lesen, ging
deshalb zum fast schwarzen Elternteil weiter und rechnete dunkel
gegen dunkel. Ohne diese Messung haette ich an einem CSS geaendert,
an dem nichts war.
Aufrufen mit:
node server/mess-farbe-am-text.mjs <seite.html> "<Textanfang>"
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const SEITE = process.argv[2] || "wissen.html";
const SUCHE = process.argv[3] || ".kachel";
/* DIE NUMMER KOMMT AUS DEM ALPHABET, WIE BEI DEN PRUEFUNGEN
(01.10.2026). Hier stand eine feste 5457 mit einer eigenen, kurzen
Fassung des Waechters daneben. Die konnte weniger als das Original:
Sie kannte den dritten Ausgang nicht (EACCES -- das System gibt den
Port dauerhaft nicht her) und sagte nicht, wie man nachsieht, wer
ihn haelt. Eine Abschrift eines Waechters ist dieselbe Falle wie
import { eigenerPort } from "./helfer-port.mjs";
eine Abschrift einer Spaltenliste. */
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-kontrast-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(200_000, "mess-kontrast");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Sina", "scout", "CODE-SCOUT-0001");
d.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "scout", code: "CODE-SCOUT-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`${BASIS}/workspace/${SEITE}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const stil = (e) => { const c = getComputedStyle(e); return {
outlineStyle: c.outlineStyle, outlineWidth: c.outlineWidth, outlineColor: c.outlineColor,
boxShadow: c.boxShadow, borderColor: c.borderColor, backgroundColor: c.backgroundColor,
textDecorationLine: c.textDecorationLine, filter: c.filter, clipPath: c.clipPath }; };
const vorher = await seite.evaluate((sel) => {
const e = document.querySelector(sel);
if (!e) return null;
e.setAttribute("data-mess", "1");
const c = getComputedStyle(e);
return { text: (e.textContent || "").trim().slice(0, 30), tag: e.tagName,
outline: `${c.outlineStyle} ${c.outlineWidth} ${c.outlineColor}`,
boxShadow: c.boxShadow, borderColor: c.borderColor, backgroundColor: c.backgroundColor,
filter: c.filter, clipPath: c.clipPath.slice(0, 60) };
}, SUCHE);
if (!vorher) { console.log(`${SUCHE} nicht gefunden auf ${SEITE}`); process.exit(1); }
await seite.evaluate(() => document.body.focus?.());
let gefunden = null;
for (let i = 0; i < 40 && !gefunden; i++) {
await seite.keyboard.press("Tab");
await seite.waitForTimeout(140);
gefunden = await seite.evaluate(() => {
const e = document.activeElement;
if (!e || !e.hasAttribute?.("data-mess")) return null;
const c = getComputedStyle(e);
return { outline: `${c.outlineStyle} ${c.outlineWidth} ${c.outlineColor}`,
boxShadow: c.boxShadow, borderColor: c.borderColor, backgroundColor: c.backgroundColor,
filter: c.filter, clipPath: c.clipPath.slice(0, 60),
fv: e.matches(":focus-visible"), f: e.matches(":focus") };
});
}
console.log(`
=== ${SUCHE} auf ${SEITE} (${vorher.tag}, "${vorher.text}") ===`);
if (!gefunden) console.log(" mit der Tastatur NICHT erreicht");
else {
console.log(` :focus ${gefunden.f} :focus-visible ${gefunden.fv}`);
for (const k of ["outline", "boxShadow", "borderColor", "backgroundColor", "filter", "clipPath"]) {
const gleich = vorher[k] === gefunden[k];
console.log(` ${k.padEnd(16)} ${gleich ? "gleich " : "ANDERS "} `
+ `${String(vorher[k]).slice(0, 55)}`
+ (gleich ? "" : `
-> ${String(gefunden[k]).slice(0, 55)}`));
}
}
await browser.close();
process.exit(0);
+308
View File
@@ -0,0 +1,308 @@
/* =====================================================================
DIE GIF-KISTE AUF DEM HANDY -- UND WAS PASSIERT, WENN MAN DRAUFTIPPT
=====================================================================
Filipe, 25.09.2026: „mach auch das mit den gifs fertig. eine
perfektion von perfektion bitte."
`pruef-gifs` misst den WEG (17 Pruefungen, alle gruen): hineinlegen,
anzeigen, verschicken, herausnehmen, und dass ein Creator nicht
herankommt. Was sie NICHT misst, ist das, was man sieht:
1. Wie die Kiste auf 390 px aussieht -- der Breite, auf der die
Modis tatsaechlich tippen. Eine Tafel, die am Rechner schoen
ist und auf dem Handy in einer Spalte klebt, ist auf dem
Handy kaputt, und keine Route merkt es.
2. Ob ein verschicktes GIF sich in der BILDSCHAU oeffnet (seit
gestern) -- und ob es dort noch laeuft. Ein GIF, das beim
Vergroessern zum Standbild wird, ist kein GIF mehr.
SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder
und Zahlen, auf denen man nachsehen kann. Deshalb `mess-`.
EIGENE WEGWERF-DATENBANK in einem mktemp-Ordner: Es entstehen echte
GIFs und echte Nachrichten. In einem laufenden System waeren das
Testdaten, und die sind verboten.
Aufrufen mit: node server/mess-gifs-handy.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-gifs-handy (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-gifs-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(300_000, "mess-gifs-handy");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
d.close();
const anfrage = (pfad, art, koerper, keks, typ) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: {
Host: CREW,
...(koerper ? { "Content-Type": typ || "application/json" } : {}),
...(keks ? { Cookie: keks } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(Buffer.isBuffer(koerper) ? koerper : JSON.stringify(koerper));
a.end();
});
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "admin", code: "CODE-DOGI-0001" });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${an.code}`); process.exit(2); }
const keks = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
/* ---- Ein gueltiges GIF -------------------------------------------
Dasselbe wie in `pruef-gifs`: ein 1x1-Bildpunkt, der den strengen
Kopfcheck des Servers besteht ("GIF89a"). Mein erster Versuch war
ein selbstgebautes Zweibild-GIF und wurde mit 415 abgelehnt -- die
Bytefolge war naeher am Wunsch als an der Norm. Wo es eine
funktionierende Vorlage im Haus gibt, wird sie benutzt und nicht
nachgebaut.
Fuer diese Messung reicht ein Standbild: Gefragt sind die GROESSEN
der Kiste, ihrer Kacheln und Knoepfe -- und ob die Bildschau
aufgeht. Ob die Einzelbilder danach weiterlaufen, entscheidet der
Browser und nicht diese Seite. */
const GIF = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
/* Die Route nimmt den nackten Rumpf mit `x-dateiname` im Kopf --
nachgesehen in workspace-chat.js, nicht geraten (multipart wurde
mit 415 abgelehnt). */
const rein = await new Promise((fertig, schief) => {
const req = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/chat/gifs", method: "POST",
headers: {
Host: CREW, Cookie: keks,
"x-dateiname": encodeURIComponent("Winken.gif"),
"Content-Type": "application/octet-stream",
"Content-Length": GIF.length,
Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/chat.html`,
},
}, (r) => {
let x = ""; r.on("data", (c) => { x += c; });
r.on("end", () => fertig({ code: r.statusCode, text: x }));
});
req.on("error", schief);
req.write(GIF);
req.end();
});
console.log(`GIF in die Kiste: HTTP ${rein.code} ${rein.text.slice(0, 90)}`);
/* Ein Gespraech, in dem das GIF landen kann. */
const raum = await anfrage("/workspace/api/chat/direkt", "POST", { mit: idHand }, keks);
const raumId = JSON.parse(raum.text || "{}").raum_id;
console.log(`Gespraech: HTTP ${raum.code}, Raum ${raumId}`);
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
async function blicken(wie, breite, hoehe, mobil) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/chat.html?raum=${raumId}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
console.log(`\n=== ${wie} (${breite} x ${hoehe}) ===`);
/* ---- 1. Die Kiste aufmachen ----------------------------------- */
const knopf = seite.locator("#gif-auf").first();
const knopfDa = await knopf.count();
if (knopfDa) await knopf.click().catch(() => {});
await seite.waitForTimeout(700);
const kiste = await seite.evaluate(() => {
const tafel = document.getElementById("gif-tafel");
if (tafel && tafel.hidden) return { da: true, zu: true };
if (!tafel) return { da: false };
const r = tafel.getBoundingClientRect();
const st = getComputedStyle(tafel);
const kacheln = [...tafel.querySelectorAll("img")].map((b) => {
const k = b.getBoundingClientRect();
return { b: Math.round(k.width), h: Math.round(k.height), src: b.currentSrc || b.src };
});
const knoepfe = [...tafel.querySelectorAll("button")].map((b) => {
const k = b.getBoundingClientRect();
return { t: (b.textContent || b.getAttribute("aria-label") || "").trim().slice(0, 18),
b: Math.round(k.width), h: Math.round(k.height) };
});
return {
da: true,
links: Math.round(r.left), rechts: Math.round(r.right),
breite: Math.round(r.width), hoehe: Math.round(r.height),
ausDemBild: r.left < 0 || r.right > window.innerWidth,
spalten: st.gridTemplateColumns,
kacheln, knoepfe,
/* NUR AM FINGER STRENG. Die 44 px sind die Hausgroesse fuer
Beruehrziele -- am Rechner waere derselbe Massstab falsch:
Dort ist ein 28-px-Kreis in der Ecke einer Kachel richtig, und
ein Knopf von 44 px wuerde die halbe Kachel verdecken. Der
erste Anlauf dieser Messung hat am Rechner „ZU KLEIN" gemeldet
und damit zwei Stellen als Befund ausgegeben, die Absicht
sind. Eine Messung, die den Zusammenhang nicht kennt, erzeugt
Arbeit statt Erkenntnis. */
zuKlein: (matchMedia("(pointer: coarse)").matches
? knoepfe.filter((k) => k.h > 0 && k.h < 44).map((k) => `${k.t} ${k.h}px`)
: []),
};
});
console.log(` Knopf da: ${knopfDa ? "ja" : "NEIN"}`);
if (!kiste.da) console.log(" Tafel: NICHT GEFUNDEN");
else {
console.log(` Tafel ${kiste.breite}x${kiste.hoehe} px, links ${kiste.links}, `
+ `rechts ${kiste.rechts}${kiste.ausDemBild ? " STEHT AUS DEM BILD" : ""}`);
console.log(` Spalten ${kiste.spalten}`);
console.log(` Kacheln ${kiste.kacheln.map((k) => `${k.b}x${k.h}`).join(", ") || "keine"}`);
console.log(` Knoepfe ${kiste.knoepfe.map((k) => `"${k.t}" ${k.b}x${k.h}`).join(", ")}`);
if (kiste.zuKlein.length) console.log(` ZU KLEIN zum Tippen: ${kiste.zuKlein.join(", ")}`);
}
await seite.screenshot({ path: `server/mess-gifs-${wie}-kiste.png` }).catch(() => {});
/* ---- 2. Verschicken und in der Bildschau oeffnen --------------- */
const kachel = seite.locator("#gif-tafel .gif-kachel__knopf").first();
if (await kachel.count()) {
await kachel.click().catch(() => {});
await seite.waitForTimeout(1200);
}
const imVerlauf = await seite.evaluate(() => {
const bilder = [...document.querySelectorAll(".chat-nachricht img, .chat-anhang img")];
const g = bilder.find((b) => /\.gif|gifs\/|anhang/.test(b.currentSrc || b.src));
if (!g) return { da: false, wieviele: bilder.length };
const r = g.getBoundingClientRect();
return { da: true, b: Math.round(r.width), h: Math.round(r.height),
src: (g.currentSrc || g.src).slice(-40) };
});
console.log(` Im Verlauf: ${imVerlauf.da
? `${imVerlauf.b}x${imVerlauf.h} px (${imVerlauf.src})`
: `NICHT GEFUNDEN (${imVerlauf.wieviele} Bilder im Verlauf)`}`);
if (imVerlauf.da) {
await seite.locator(".chat-nachricht img, .chat-anhang img").first().click()
.catch(() => {});
await seite.waitForTimeout(900);
const schau = await seite.evaluate(() => {
const dlg = document.querySelector("dialog.bildschau");
if (!dlg) return { da: false };
/* und NICHT das erste <img>: Der Dialog traegt
oben ein Husky-Zeichen und an den Seiten zwei Figuren. Mein
erster Anlauf nahm und maass damit das
Logo -- die Meldung lautete "Mitte 174 von 640", und die
Bildschau sah kaputt aus, obwohl sie es nicht war. Ein
Messfehler, der wie ein Befund aussieht, kostet mehr Zeit als
gar keine Messung. */
/* `.bildschau__bild` und NICHT das erste <img>: Der Dialog
traegt oben ein Husky-Zeichen und an den Seiten zwei Figuren.
Der erste Anlauf nahm `querySelector("img")` und maass damit
das Logo -- die Meldung lautete „Mitte 174 von 640", und die
Bildschau sah kaputt aus, obwohl sie mittig stand. Ein
Messfehler, der wie ein Befund aussieht, kostet mehr Zeit als
gar keine Messung. */
const b = dlg.querySelector(".bildschau__bild");
const r = b?.getBoundingClientRect();
return {
da: true, offen: dlg.open === true,
bildSrc: (b?.currentSrc || b?.src || "").slice(-40),
bild: r ? `${Math.round(r.width)}x${Math.round(r.height)}` : "kein Bild",
istGif: /\.gif|gifs\/|anhang/.test(b?.currentSrc || b?.src || ""),
mitte: r ? Math.round(r.left + r.width / 2) : 0,
fensterMitte: Math.round(window.innerWidth / 2),
};
});
if (!schau.da) console.log(" Bildschau: GEHT NICHT AUF");
else {
console.log(` Bildschau offen=${schau.offen}, Bild ${schau.bild}, `
+ `GIF=${schau.istGif}, Mitte ${schau.mitte} (Fenster ${schau.fensterMitte})`);
await seite.screenshot({ path: `server/mess-gifs-${wie}-schau.png` }).catch(() => {});
}
}
if (konsole.length) console.log(` FEHLER IN DER KONSOLE: ${konsole.join(" | ")}`);
await kontext.close();
}
try {
await blicken("handy", 390, 844, true);
await blicken("rechner", 1280, 900, false);
} finally {
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+244
View File
@@ -0,0 +1,244 @@
/* =====================================================================
WAS SIEHT DIE RECHTE HAND AUF DER PERSONENSEITE? (25.09.2026)
---------------------------------------------------------------------
WARUM ES DIESE DATEI GIBT
Filipe, zum wiederholten Mal: „die rechte hand sieht das immer noch
nicht obwohl ich will dass die rechte hand das auch sieht." Dazu ein
Bildschirmfoto der Personenseite -- Rollenkarten oben, „Letzte
Ereignisse" unten.
ICH KOENNTE RATEN, welches „das" gemeint ist. Am 24.09.2026 habe ich
genau das getan, auf ein Bildschirmfoto hin an der falschen Seite
gebaut und es im Commit selbst notiert („ZUERST EIN IRRTUM VON MIR").
Diesmal wird nachgesehen, bevor gebaut wird: Zwei Anmeldungen,
dieselbe Seite, und die Unterschiede werden AUFGEZAEHLT.
SIE PRUEFT NICHTS und meldet keinen Fehlschlag. Sie macht eine Liste
und zwei Bilder, auf denen man nachsehen kann. Sie heisst `mess-` und nicht `pruef-`: Beide Sorten leiten ihre
Portnummer aus der Stelle im Alphabet ab, aber jede aus IHRER
eigenen Liste und in ihrem eigenen Bereich (Pruefungen ab 5000,
Messungen ab 5900). So verschiebt eine neue Messdatei die Nummern
der Pruefungen nicht -- und umgekehrt.
EIGENE WEGWERF-DATENBANK, NIE DIE ECHTE. Es entstehen echte Personen
und echte Protokollzeilen; in einem laufenden System waeren das
Testdaten, und die sind verboten.
Aufrufen mit: node server/mess-hand-personen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-hand-personen (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-hand-pers-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-hand-personen");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
/* Dieselbe Besetzung wie auf Filipes Bildschirmfoto: beide Haende,
mehrere Modis, mehrere aus der Community. */
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("VanVan", "hand", "CODE-HAND-0001");
anlegen("Kessi", "linke", "CODE-LINKE-0001");
anlegen("Diene", "modi", "CODE-MODI-0001");
anlegen("Marina", "modi", "CODE-MODI-0002");
anlegen("Ghost", "modi", "CODE-MODI-0003");
anlegen("Anni", "gast", "CODE-GAST-0001");
anlegen("Cleo", "gast", "CODE-GAST-0002");
/* Ein paar Protokollzeilen, damit „Letzte Ereignisse" nicht leer ist --
sonst sieht „sieht nichts" bei beiden Rollen gleich aus. */
for (let i = 0; i < 6; i++) {
d.prepare("INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip)"
+ " VALUES (?,1,'admin','einstellung_geaendert',?,'127.0.0.1')")
.run(new Date(Date.now() - i * 60000).toISOString(), `probe_${i}`);
}
d.close();
const anfrage = (pfad, art, koerper, keks) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: {
Host: CREW, "Content-Type": "application/json",
...(keks ? { Cookie: keks } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
const anmelden = async (rolle, code) => {
const a = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
if (a.code !== 200) {
console.log(`ABBRUCH: ${rolle} ließ sich nicht anmelden (HTTP ${a.code}) ${a.text.slice(0, 120)}`);
process.exit(2);
}
return (a.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
};
const kekse = {
DogFather: await anmelden("admin", "CODE-DOGI-0001"),
"rechte Hand": await anmelden("hand", "CODE-HAND-0001"),
};
/* ---- Erst die ANTWORTEN vergleichen, dann den Bildschirm ------------
Wenn der Server schon verschieden antwortet, muss man im Browser gar
nicht mehr suchen. */
console.log("\n=== Was der Server den beiden gibt ===");
for (const [wer, keks] of Object.entries(kekse)) {
const p = await anfrage("/workspace/api/verwaltung/personen", "GET", null, keks);
const prot = await anfrage("/workspace/api/verwaltung/protokoll?anzahl=25", "GET", null, keks);
let leute = 0, rollen = "";
try {
const j = JSON.parse(p.text);
leute = (j.personen || []).length;
rollen = [...new Set((j.personen || []).map((x) => x.rolle))].join(", ");
} catch { /* zeigt sich am Code */ }
let zeilen = -1;
try { zeilen = (JSON.parse(prot.text).eintraege || []).length; } catch { /* egal */ }
console.log(` ${wer.padEnd(12)} Personen HTTP ${p.code} → ${leute} Leute (${rollen})`);
console.log(` ${"".padEnd(12)} Protokoll HTTP ${prot.code} → ${zeilen < 0 ? prot.text.slice(0, 60) : zeilen + " Zeilen"}`);
}
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
const MARKE = process.env.MARKE || "hand";
const befund = {};
try {
for (const [wer, keks] of Object.entries(kekse)) {
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1500 } });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/personen.html`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
const lage = await seite.evaluate(() => {
const sicht = (e) => !!(e && e.offsetParent !== null);
const text = (s) => (document.querySelector(s)?.textContent || "").trim();
return {
wo: location.pathname,
gruppen: [...document.querySelectorAll(".gruppe__titel, .rollen-block__titel, h2")]
.map((e) => e.textContent.trim()).filter(Boolean).slice(0, 12),
karten: [...document.querySelectorAll("[data-rolle]")]
.map((e) => e.dataset.rolle).filter((x, i, a) => x && a.indexOf(x) === i),
knoepfe: [...document.querySelectorAll("button")]
.filter(sicht).map((e) => e.textContent.trim())
.filter(Boolean).filter((x, i, a) => a.indexOf(x) === i).slice(0, 25),
protokollBlock: !!document.querySelector(".protokoll-block"),
protokollSichtbar: sicht(document.querySelector(".protokoll-block")),
protokollZeilen: document.querySelectorAll(".protokoll-zeile").length,
protokollText: text("#protokoll").slice(0, 60),
schalter: sicht(document.getElementById("protokoll-schalter")),
};
});
befund[wer] = lage;
await seite.screenshot({
path: `server/mess-${MARKE}-personen-${wer === "DogFather" ? "dogi" : "hand"}.png`,
fullPage: true,
});
if (konsole.length) console.log(` [${wer}] Browser meldet: ${konsole[0]}`);
await kontext.close();
}
} finally {
await browser.close();
await new Promise((r) => vorbau.close(r));
}
console.log("\n=== Was auf dem Bildschirm steht ===");
for (const [wer, l] of Object.entries(befund)) {
console.log(`\n --- ${wer} (${l.wo}) ---`);
console.log(` Rollen-Karten: ${l.karten.join(", ") || "(keine)"}`);
console.log(` Protokoll-Block: ${l.protokollBlock ? "im Dokument" : "FEHLT"}`
+ ` / sichtbar: ${l.protokollSichtbar}`
+ ` / Zeilen: ${l.protokollZeilen}`
+ ` / Schalter: ${l.schalter}`);
console.log(` Protokoll-Text: ${JSON.stringify(l.protokollText)}`);
console.log(` Knoepfe: ${l.knoepfe.join(" · ")}`);
}
/* DER UNTERSCHIED IST DIE ANTWORT -- alles andere ist Beiwerk. */
const a = befund.DogFather, b = befund["rechte Hand"];
if (a && b) {
console.log("\n=== Der Unterschied ===");
const fehltKarte = a.karten.filter((x) => !b.karten.includes(x));
const fehltKnopf = a.knoepfe.filter((x) => !b.knoepfe.includes(x));
console.log(` Karten, die ihr fehlen: ${fehltKarte.join(", ") || "(keine)"}`);
console.log(` Knoepfe, die ihr fehlen: ${fehltKnopf.join(" · ") || "(keine)"}`);
console.log(` Protokollzeilen: DogFather ${a.protokollZeilen}, rechte Hand ${b.protokollZeilen}`);
}
console.log(`\n Bilder: server/mess-${MARKE}-personen-{dogi,hand}.png\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+249
View File
@@ -0,0 +1,249 @@
/* =====================================================================
WIE STEHEN DIE KACHELN — je Rolle, Gruppe für Gruppe (24.09.2026)
---------------------------------------------------------------------
WARUM ES DIESE DATEI GIBT
Filipe schickt seine Wünsche zur Startseite fast immer als
Bildschirmfoto: „die kachel auf screen 2 soll zwischen den kacheln
auf screen3 sein", „wechsel bitte die reihenfolge, links soll
unterstützen sein". Beides sind Fragen nach einer REIHE, nicht nach
einer Liste.
Und genau da liegt die Falle: Die Reihenfolge im Quelltext ist NICHT
die Reihenfolge auf dem Bildschirm.
* Das Raster hat drei Spalten, „Willkommen" belegt zwei — alles
danach verschiebt sich um eins.
* Gruppiert wird nach dem ERSTEN Auftreten einer Gruppe im Feld;
eine Kachel am Ende der Liste kann dadurch mitten auf der Seite
landen.
* Und eine Kachel ohne `gruppe` bekommt einen eigenen Abschnitt
ohne Überschrift — genau so stand der Support einen halben Tag
lang allein in einem namenlosen Kasten, und beim Lesen des
Quelltextes sieht man das nicht.
Wer das im Kopf ausrechnet, rechnet früher oder später falsch.
Deshalb wird es hier GEMESSEN: angemeldet, im Browser, je Rolle.
Sie prüft NICHTS und meldet keinen Fehlschlag. Sie beantwortet eine
Frage, die man vor jeder Umsortierung stellen muss.
Sie heisst `mess-` und nicht `pruef-`: Beide Sorten leiten ihre
Portnummer aus der Stelle im Alphabet ab, aber jede aus IHRER
eigenen Liste und in ihrem eigenen Bereich (Pruefungen ab 5000,
Messungen ab 5900). So verschiebt eine neue Messdatei die Nummern
der Pruefungen nicht -- und umgekehrt.
Aufrufen mit: node server/mess-kachelreihen.mjs
Nur eine Rolle: ROLLE=modi node server/mess-kachelreihen.mjs
Bild: SCHIRM=1 node server/mess-kachelreihen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-kachelreihen (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-kachelreihen-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-kachelreihen");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt);
};
/* Die fünf Rollen, die auf der Crew-Adresse eine Startseite haben. */
const ROLLEN = [
["Filipe", "admin", "CODE-DOGI-0001"],
["VanVan", "hand", "CODE-HAND-0001"],
["Kessi", "linke", "CODE-LINKE-0001"],
["Diene", "modi", "CODE-MODI-0001"],
["Zuschauer", "gast", "CODE-GAST-0001"],
];
for (const [name, rolle, code] of ROLLEN) anlegen(name, rolle, code);
d.close();
/* ÜBER DIE CREW-ADRESSE, nicht über 127.0.0.1: Welches Haus gemeint
ist, leitet der Server aus dem `Host`-Kopf ab. Über die Zahladresse
landet man im Agenturhaus, und dort gibt es weder `gast` noch
`modi` — man misst dann eine Seite, die niemand so sieht. */
const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { Host: CREW, "Content-Type": "application/json" },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
const kekse = {};
for (const [, rolle, code] of ROLLEN) {
const an = await anfrage("/workspace/api/anmelden", "POST",
rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code });
if (an.code !== 200) {
console.log(` ABBRUCH: ${rolle} ließ sich nicht anmelden (HTTP ${an.code}). `
+ "Ohne Anmeldung gibt es keine Startseite zu messen.");
process.exit(2);
}
kekse[rolle] = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
}
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
try {
const nur = process.env.ROLLE;
for (const [, rolle] of ROLLEN) {
if (nur && nur !== rolle) continue;
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1800 } });
await kontext.addCookies(kekse[rolle].split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 110));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/start.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(800);
const gruppen = await seite.evaluate(() =>
[...document.querySelectorAll("section")].map((s) => {
const titel = s.querySelector("h2, .gruppe__titel")?.textContent?.trim() || "";
const k = [...s.querySelectorAll(".kachel")].map((x) => {
const r = x.getBoundingClientRect();
return {
n: x.querySelector(".kachel__name")?.textContent,
x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width),
};
});
return k.length ? { titel, k } : null;
}).filter(Boolean));
console.log("");
console.log(`=== ${rolle} ===`);
for (const g of gruppen) {
/* NACH ZEILEN SORTIEREN, nicht nach Feldreihenfolge -- das ist ja
gerade die Frage. Gerundet auf 20 px, damit zwei Kacheln
derselben Zeile mit leicht verschiedener Oberkante
zusammenbleiben. */
const zeilen = new Map();
for (const k of g.k) {
const y = Math.round(k.y / 20) * 20;
if (!zeilen.has(y)) zeilen.set(y, []);
zeilen.get(y).push(k);
}
const sortiert = [...zeilen.entries()].sort((a, b) => a[0] - b[0]);
const name = g.titel || "!! OHNE ÜBERSCHRIFT !!";
console.log(` [${name}]`);
for (const [, ks] of sortiert) {
console.log(" " + ks.sort((a, b) => a.x - b.x)
.map((k) => `${k.n} (${k.b}px)`).join(" | "));
}
}
/* EIN ABSCHNITT OHNE ÜBERSCHRIFT IST EIN BEFUND, kein Zufall:
Er entsteht, wenn eine Kachel keine `gruppe` hat. */
const ohne = gruppen.filter((g) => !g.titel);
if (ohne.length) {
console.log(` !! ${ohne.length} Abschnitt(e) OHNE Überschrift: `
+ ohne.map((g) => g.k.map((k) => k.n).join(", ")).join(" / "));
}
if (konsole.length) console.log(" !! Konsole: " + konsole[0]);
if (process.env.SCHIRM) {
/* MIT `GRUPPE="Für dich"` nur diesen einen Abschnitt -- ein Bild
der ganzen Seite ist 1280 mal viertausend Pixel, und darauf
sucht man die drei Kacheln, um die es gerade geht. */
const nurGruppe = process.env.GRUPPE;
const window0 = await seite.evaluate(() => window.scrollY);
const feld = nurGruppe ? await seite.evaluate((titel) => {
const s2 = [...document.querySelectorAll("section")]
.find((x) => (x.querySelector("h2, .gruppe__titel")?.textContent || "").trim() === titel);
return s2 ? s2.getBoundingClientRect().toJSON() : null;
}, nurGruppe) : null;
const datei = `server/mess-kachelreihen-${rolle}.png`;
try {
if (feld) {
/* MIT `fullPage`, auch beim Ausschnitt: Die Gruppe liegt
meist unterhalb des Fensters, und ohne fullPage kennt
Playwright dort keine Koordinaten -- der Aufruf scheitert.
Gemessen am 24.09.2026, als genau das passierte. */
await seite.screenshot({ path: datei, fullPage: true, clip: {
x: Math.max(0, feld.x - 10), y: Math.max(0, feld.y + window0 - 10),
width: Math.min(1280, feld.width + 20), height: feld.height + 20 } });
} else {
await seite.screenshot({ path: datei, fullPage: true });
}
console.log(` -- Bild: ${datei}`);
} catch (f) {
/* NICHT STILL SCHEITERN. Ein Lauf, der erfolgreich aussieht und
kein Bild hinterlaesst, kostet drei Wiederholungen. */
console.log(` !! Bild ging nicht: ${String(f?.message || f).slice(0, 120)}`);
}
}
await kontext.close();
}
} finally {
await browser.close();
try { vorbau.close(); } catch { /* egal */ }
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+149
View File
@@ -0,0 +1,149 @@
/* =====================================================================
WIE DIE KOPFLEISTE AUF DEM HANDY STEHT -- UND WAS IM MENUE LANDET
=====================================================================
Filipe, 09.09.2026: „es soll alles in einer reihe sein."
Seit dem 25.09.2026 sorgt kopf.js dafuer, indem es MISST statt zu
rechnen: Passt die Leiste nicht in eine Zeile, wandert der naechste
Kandidat in ein Mehr-Menue. Diese Messung zeigt, was dabei
herauskommt -- je Breite, mit Bild.
SIE PRUEFT NICHTS. `pruef-handy` misst die Spanne der Oberkanten und
wird rot, wenn zwei Zeilen entstehen. Hier geht es um das, was man
nur sieht: Steht der Mehr-Knopf am richtigen Platz? Geht das Fach
auf? Sind die Woerter darin lesbar?
Aufrufen mit: node server/mess-kopfleiste.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-kopf-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "mess-kopfleiste");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Rieke", "hand", "CODE-HAND-0001");
d.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
for (const [wie, breite] of [["320", 320], ["360", 360], ["390", 390],
["412", 412], ["768", 768], ["1280", 1280]]) {
const kontext = await browser.newContext({
viewport: { width: breite, height: 860 },
deviceScaleFactor: 2, isMobile: breite < 700, hasTouch: breite < 700,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
const lage = await seite.evaluate(() => {
const leiste = document.querySelector(".kopfleiste__rechts");
const fach = document.getElementById("kopf-mehr-fach");
const knopf = document.getElementById("kopf-mehr");
const namen = (n) => [...n.children]
.filter((k) => k.id !== "kopf-mehr-fach")
.map((k) => `${String(k.className).split(" ")[0] || k.tagName.toLowerCase()}`
+ `(${Math.round(k.getBoundingClientRect().width)}x`
+ `${Math.round(k.getBoundingClientRect().height)})`);
const oben = [...leiste.children]
.filter((k) => k.id !== "kopf-mehr-fach" && !k.hidden
&& k.getBoundingClientRect().height > 0)
.map((k) => Math.round(k.getBoundingClientRect().top));
return {
inDerLeiste: namen(leiste),
imFach: fach ? namen(fach) : [],
mehrKnopf: knopf ? (knopf.hidden ? "versteckt" : "da") : "gibt es nicht",
spanne: oben.length ? Math.max(...oben) - Math.min(...oben) : 0,
hoehe: Math.round(document.querySelector(".kopfleiste").getBoundingClientRect().height),
};
});
console.log(`\n=== ${wie} px ===`);
console.log(` Leiste (${lage.spanne} px Spanne, Kopf ${lage.hoehe} px hoch):`);
console.log(` ${lage.inDerLeiste.join(" ")}`);
console.log(` Mehr-Knopf: ${lage.mehrKnopf}`);
console.log(` im Fach: ${lage.imFach.join(" ") || "nichts"}`);
/* Und einmal aufmachen -- ein Menue, das nicht aufgeht, ist schlimmer
als kein Menue. */
if (lage.mehrKnopf === "da") {
await seite.click("#kopf-mehr", { timeout: 4000 })
.catch((f) => console.log(" KLICK SCHEITERT: " + String(f.message).split(String.fromCharCode(10))[0].slice(0, 120)));
await seite.waitForTimeout(400);
const auf = await seite.evaluate(() => {
const f = document.getElementById("kopf-mehr-fach");
if (!f || f.hidden) return null;
const r = f.getBoundingClientRect();
return {
kasten: `${Math.round(r.width)}x${Math.round(r.height)} bei ${Math.round(r.left)}`,
ausDemBild: r.right > window.innerWidth + 1 || r.left < -1,
woerter: [...f.children].map((k) => (k.textContent || "").trim().slice(0, 16)),
zuKlein: [...f.children]
.filter((k) => k.getBoundingClientRect().height < 44)
.map((k) => (k.textContent || "").trim().slice(0, 12)),
};
});
if (!auf) console.log(" FACH GEHT NICHT AUF");
else {
console.log(` Fach offen: ${auf.kasten}`
+ (auf.ausDemBild ? " STEHT AUS DEM BILD" : ""));
console.log(` ${auf.woerter.join(" | ")}`);
if (auf.zuKlein.length) console.log(` ZU KLEIN: ${auf.zuKlein.join(", ")}`);
await seite.screenshot({ path: `server/mess-kopf-${wie}.png` }).catch(() => {});
}
}
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+330
View File
@@ -0,0 +1,330 @@
/* =====================================================================
WIE DIE MANAGER-ZIELE AUSSEHEN (02.10.2026)
SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
auf denen man nachsehen kann, und misst drei Dinge, die man auf
einem Bild schlecht sieht: waagerechtes Schieben, Beruehrziele unter
44 px und Ueberdeckungen.
Deshalb `mess-` und nicht `pruef-`.
ZWEI BREITEN, und die kleinere ist die wichtigere: 412 px ist die
haeufigste Android-Breite. Am 06.09.2026 hat genau diese Breite im
Creator Workspace eine Ueberdeckung aufgedeckt, bei der ein Knopf
ueber einem anderen lag -- auf dem Computer war nichts zu sehen.
EIGENE WEGWERF-DATENBANK in einem mktemp-Ordner. In einem laufenden
System waeren das Testdaten, und die sind verboten.
Aufrufen mit: node server/mess-manager-ziele.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "mess-manager-ziele");
const ordner = mkdtempSync(join(tmpdir(), "ws-mz-bild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "mess-manager-ziele");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
/* ---------- Leute und ein paar echte Eintraege ----------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const alt = new Date(Date.now() - 365 * 86400_000).toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), alt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Kevin", "scout", "CODE-KEVIN-0001");
anlegen("Patrick", "manager", "CODE-PAT-000001");
anlegen("Sandra", "scout", "CODE-SAND-00001");
anlegen("Spicy Media", "spicy", "CODE-SPICY-0001");
const { monatJetzt, tagBerlin } = await import("./workspace-manager-ziele.js");
const MONAT = monatJetzt();
const HEUTE = tagBerlin();
/* DER LAUFENDE MONAT MUSS DER DATENBANK GESAGT WERDEN, bevor hier von
Hand eingetragen wird. `mz_lage` steht nach dem Anlegen auf
'0000-00', und die Trigger lehnen dann JEDEN Eintrag ab -- auch den
fuer den aktuellen Monat. Das ist richtig so (zu, solange niemand
gesagt hat, welcher Monat laeuft) und hat diesen Lauf beim ersten
Mal abgebrochen. Im Betrieb setzt es jede Anfrage selbst. */
d.prepare("UPDATE mz_lage SET monat = ? WHERE eins = 1").run(MONAT);
/* EIN GEMISCHTER STAND -- genau darum geht es beim Ansehen: eine
erfuellte Zeile, eine halbe, eine leere. Stuende alles auf null,
sieht man die Ampel gar nicht. */
const setz = d.prepare(`INSERT INTO mz_eintrag
(person_id, aufgabe, monat, datum, name, name_klein, link, link_klein, art, notiz, erstellt)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`);
setz.run(idScout, "creator", MONAT, HEUTE, "@lunatanzt", "lunatanzt", null, null, null,
"über Discord gefunden, sehr aktiv", jetzt);
setz.run(idScout, "creator", MONAT, HEUTE, "@marakocht", "marakocht", null, null, null, null, jetzt);
setz.run(idScout, "meeting", MONAT, HEUTE, null, null, null, null, null, "Wochenrunde", jetzt);
setz.run(idScout, "werbung", MONAT, HEUTE, null, null,
"https://www.tiktok.com/@dogfather/video/7312", "tiktok.com/@dogfather/video/7312",
"agentur", null, jetzt);
/* EINE UEBERFAELLIGE AUFGABE -- nicht fuer diese Seite, sondern fuer
die KACHELWAND davor.
Seit dem 02.10.2026 traegt eine Kachel, an der eine Warnung haengt,
einen farbigen Saum (Vorlage, Punkt 05: „farbiger Rand + Badge").
Ohne irgendeine Warnung im Bestand waere auf dem Bild der
Startseite nichts davon zu sehen -- und „kein Saum" sieht genauso
aus wie „Saum kaputt". Eine ueberfaellige Aufgabe ist die
einfachste Warnung, die sich herstellen laesst, und sie beweist
den Saum fuer JEDE Kachel, nicht nur fuer eine. */
d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, verantwortlich_id, frist, erstellt, erstellt_von)
VALUES (?,?,?,?,?,?,?)`)
.run("Laengst faellig (nur fuer das Bild)", "offen", "hoch", idScout,
"2026-09-01", jetzt, idScout);
/* Ein uebergebener Lead, der noch nicht eingetragen ist -- damit der
Vorschlagskasten auf dem Bild zu sehen ist. */
d.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am)
VALUES (?,?,?,?,?,?,?)`)
.run("Nina B.", "@ninab", "uebergeben", idScout, jetzt, idScout, `${HEUTE}T09:00:00Z`);
d.close();
/* ---------- Anmelden -------------------------------------------------- */
const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${PORT}` },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
async function keksFuer(rolle, code) {
const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); }
return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
}
const keksScout = await keksFuer("scout", "CODE-KEVIN-0001");
const keksDogi = await keksFuer("admin", "CODE-DOGI-0001");
/* ---------- Bilder ---------------------------------------------------- */
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
/** Was man auf einem Bild schlecht sieht. */
const nachmessen = () => ({
/* Waagerechtes Schieben ist im Haus ausdruecklich unerwuenscht.
Zwei Pixel Toleranz fuer Rundungen. */
schiebtQuer: document.documentElement.scrollWidth > window.innerWidth + 2,
breite: window.innerWidth,
/* Jedes Beruehrziel. 44 px ist die Hausgroesse (`--feld-ziel`). */
zuKlein: [...document.querySelectorAll("button, a[href], input, select")]
.filter((e) => {
const k = e.getBoundingClientRect();
return k.width > 0 && k.height > 0 && k.height < 40;
})
.map((e) => `${e.tagName.toLowerCase()}.${e.className || "-"}: ${Math.round(e.getBoundingClientRect().height)}px`),
/* Ueberdeckungen zwischen den Knoepfen der Kopfzeile einer Aufgabe. */
zeilen: [...document.querySelectorAll(".mz-zeile")].map((z) => ({
aufgabe: z.dataset.aufgabe,
stufe: z.dataset.stufe,
zahl: z.querySelector(".mz-zeile__zahl")?.textContent,
satz: z.querySelector(".mz-zeile__lage")?.textContent,
})),
ring: document.getElementById("ring-zahl")?.textContent
+ " " + document.getElementById("ring-von")?.textContent,
kopf: document.getElementById("kopf-stand")?.textContent,
rest: document.getElementById("kopf-rest")?.textContent,
vorschlaege: document.querySelectorAll(".mz-vorschlag__knopf").length,
});
async function bild(name, keks, breite, hoehe, vorher) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|net::ERR/i.test(e.text())) konsole.push(e.text().slice(0, 160));
});
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/manager-ziele.html`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
if (vorher) await vorher(seite);
await seite.waitForTimeout(500);
const blick = await seite.evaluate(nachmessen);
await seite.screenshot({ path: `bild-manager-ziele-${name}.png`, fullPage: true });
console.log(`\n--- ${name} (${breite}px) ---`);
console.log(` Ring: ${blick.ring}`);
console.log(` Kopf: ${blick.kopf}`);
console.log(` Rest: ${blick.rest}`);
for (const z of blick.zeilen) {
console.log(` ${String(z.aufgabe).padEnd(9)} ${String(z.zahl).padEnd(6)} ${String(z.stufe).padEnd(8)} ${z.satz}`);
}
console.log(` Vorschlaege: ${blick.vorschlaege}`);
console.log(` Quer schieben: ${blick.schiebtQuer ? "JA — Befund!" : "nein"}`);
console.log(` Zu kleine Ziele: ${blick.zuKlein.length ? blick.zuKlein.join(", ") : "keine"}`);
if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`);
await kontext.close();
return blick;
}
/* Die vier Aufgabenzeilen einmal aufklappen, damit die Eintraege und
der Vorschlagskasten auf dem Bild stehen. */
const aufklappen = async (seite) => {
for (const kopf of await seite.$$(".mz-zeile__kopf")) {
await kopf.click();
await seite.waitForTimeout(250);
}
};
const handy = await bild("scout-handy", keksScout, 412, 915, aufklappen);
const computer = await bild("scout-computer", keksScout, 1280, 1100, aufklappen);
await bild("dogfather-team", keksDogi, 1280, 1200, null);
await bild("dogfather-handy", keksDogi, 412, 915, null);
/* Und das Formular, denn dort wird wirklich getippt. */
await bild("formular-handy", keksScout, 412, 915, async (seite) => {
const knopf = await seite.$(".mz-zeile__fuss .knopf");
if (knopf) { await knopf.click(); await seite.waitForTimeout(400); }
});
/* ---------- EIN KLICK, UND ES STEHT DRIN ----------------------------
Der Kern von Filipes Wunsch („so wenig wie moeglich zu tippen").
Gemessen wird nicht, ob der Knopf da ist, sondern ob danach eine
andere Zahl dasteht -- und ob „Rueckgaengig" sie zurueckholt. */
{
const kontext = await browser.newContext({ viewport: { width: 412, height: 915 }, hasTouch: true });
await kontext.addCookies(keksScout.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|net::ERR/i.test(e.text())) konsole.push(e.text().slice(0, 160));
});
await seite.goto(`http://127.0.0.1:${PORT}/workspace/manager-ziele.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
const zahlVon = (schluessel) => seite.evaluate((k) => document
.querySelector(`.mz-zeile[data-aufgabe="${k}"] .mz-zeile__zahl`)?.textContent, schluessel);
const vorher = await zahlVon("schulung");
await seite.click('.mz-zeile[data-aufgabe="schulung"] .mz-schnell .knopf');
await seite.waitForTimeout(900);
const nachher = await zahlVon("schulung");
const meldung = await seite.evaluate(() =>
document.querySelector(".mz-gelungen")?.textContent || "-");
await seite.screenshot({ path: "bild-manager-ziele-einklick.png" });
/* Und zurueck. */
const zurueckDa = await seite.$(".mz-gelungen__zurueck");
if (zurueckDa) { await zurueckDa.click(); await seite.waitForTimeout(900); }
const zurueckZahl = await zahlVon("schulung");
console.log("");
console.log("--- Ein Klick, ein Eintrag (412px) ---");
console.log(` vorher: ${vorher}`);
console.log(` nach EINEM Klick: ${nachher} ${nachher !== vorher ? "" : "— KEINE Aenderung, Befund!"}`);
console.log(` Meldung: ${meldung}`);
console.log(` nach Rueckgaengig: ${zurueckZahl} ${zurueckZahl === vorher ? "(wieder wie vorher)" : "— NICHT zurueck, Befund!"}`);
if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`);
await kontext.close();
}
/* ---------- Und die Kachelwand davor --------------------------------
Gemessen wird nicht, wie es aussieht, sondern WAS wirklich gesetzt
ist: das Merkmal am Element und der Schatten, den der Browser
daraus errechnet. Ein Bild allein kann einen Saum von einem Pixel
nicht beweisen. */
{
const kontext = await browser.newContext({ viewport: { width: 412, height: 915 }, hasTouch: true });
await kontext.addCookies(keksScout.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`http://127.0.0.1:${PORT}/workspace/start.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
const wand = await seite.evaluate(() => {
const meine = [...document.querySelectorAll('.kachel')]
.find((k) => (k.querySelector('.kachel__link')?.getAttribute('href') || '')
.startsWith('manager-ziele.html'));
const gewarnt = [...document.querySelectorAll('.kachel[data-warn="ja"]')];
return {
kachelDa: !!meine,
name: meine?.querySelector('.kachel__name')?.textContent || '-',
zahl: meine?.querySelector('.kachel__zahl')?.textContent || '-',
gewarnte: gewarnt.length,
saum: gewarnt[0] ? getComputedStyle(gewarnt[0]).boxShadow.slice(0, 60) : '-',
/* GEGENPROBE: Eine Kachel OHNE Warnung darf den Saum nicht haben.
Sonst waere „alle haben ihn" dasselbe wie „keiner hat ihn". */
ohneWarnung: [...document.querySelectorAll('.kachel:not([data-warn])')].length,
};
});
/* ZU DER KACHEL HIN, UM DIE ES GEHT. Ein Bild vom Seitenanfang
zeigte beim ersten Lauf den Begruessungsblock -- richtig
aufgenommen und ohne jeden Nutzen. */
const gewarnteKachel = await seite.$('.kachel[data-warn="ja"]');
if (gewarnteKachel) await gewarnteKachel.scrollIntoViewIfNeeded();
await seite.waitForTimeout(400);
await seite.screenshot({ path: "bild-manager-ziele-wand.png", fullPage: false });
console.log("\n--- Die Kachelwand (412px) ---");
console.log(` Kachel da: ${wand.kachelDa ? "ja" : "NEIN — Befund!"} (${wand.name})`);
console.log(` Abzeichen: ${wand.zahl}`);
console.log(` Kacheln mit Saum: ${wand.gewarnte}`);
console.log(` ohne Saum: ${wand.ohneWarnung}`);
console.log(` Schatten: ${wand.saum}`);
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
const befunde = [handy, computer].filter((b) => b.schiebtQuer || b.zuKlein.length);
console.log(befunde.length
? `\n${befunde.length} Breite(n) mit Befund — siehe oben.`
: "\nKein waagerechtes Schieben, kein zu kleines Beruehrziel.");
process.exit(0);
+255
View File
@@ -0,0 +1,255 @@
/* =====================================================================
WIE SIEHT DER NOTIZBLOCK AUS? (26.09.2026)
Ein Messwerkzeug, keine Pruefung: Es meldet nichts, es macht
Bilder. Rechner und Handy, leeres Blatt und beschriebenes.
node server/mess-notizblock.mjs
Filipe: „für die modis, rechte und linke hand und dogfather eine
kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das
auch wie ein notizblock erstellst."
---------------------------------------------------------------------
DREI DINGE WERDEN GEPRÜFT, UND ZWEI DAVON HAT NIEMAND BESTELLT
1. WER DARF ÜBERHAUPT. Genau die vier Rollen, und niemand sonst —
auch nicht über die Schnittstelle, auch nicht von der anderen
Adresse aus. Die Rollenmenge wird dabei nicht abgeschrieben,
sondern aus dem Programm gelesen und gegen die Rechtetafel
gehalten: Zwei Listen für dieselbe Frage laufen auseinander.
2. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht.
Das ist die Zusage, die auf der Kachel steht („nur du siehst
ihn"), und die einzige, deren Bruch man nicht bemerken würde:
Eine Notiz, die jemand Fremdes liest, sieht für den Schreiber
genauso aus wie eine, die niemand liest.
3. DIE DECKSCHICHT LIEGT AUF DEN PIXEL GENAU. Das Blatt besteht
aus einem unsichtbaren Schreibfeld und einer sichtbaren
Deckschicht darüber. Ändert eine Auszeichnung die BREITE eines
Zeichens (Fettdruck, andere Schriftgröße, Sperrung), brechen
beide Schichten an verschiedenen Stellen um — und der Cursor
steht neben dem Text. Das sieht aus wie ein Browserfehler und
wäre einer in notizen.css. Gemessen wird deshalb am echten
Umbruch, nicht an der Absicht.
---------------------------------------------------------------------
WARUM DIESE PRÜFUNG ÜBER https SPRICHT
Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com`
steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die
Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse
(127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block
antwortet zu Recht mit 404.
Aufrufen mit: node server/pruef-notizen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request } from "node:http";
const ordner = mkdtempSync(join(tmpdir(), "ws-notiz-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
/* DIE NUMMER KOMMT AUS DEM ALPHABET (01.10.2026). Hier stand: „eine
feste Nummer -- `eigenerPort` leitet seine aus der Stelle im
Alphabet der PRUEFdateien ab, und dieses Werkzeug ist keine." Das
stimmte, war aber die falsche Schlussfolgerung: Statt den Waechter
zu meiden, leiten die Messdateien jetzt aus IHRER eigenen Liste ab,
in einem eigenen Bereich ab 5900. Die abgeschriebene Kurzfassung,
die hier stand, kannte den dritten Ausgang nicht (EACCES) und sagte
auch nicht, wie man nachsieht, wer den Port haelt. */
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(200_000, "mess-notizblock");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const melde = (t) => console.log(t);
process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
const CREW = "crew.dogfather-universe.com";
const AGENTUR = "workspace.dogfather-universe.com";
/* ---------- Zugänge anlegen -------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = new Date().toISOString();
const kennSchluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kennSchluessel, nun);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", kennSchluessel).update(code).digest("hex"), nun);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
["modi", "Miss"], ["gast", "Zuschauerin"], ["creator", "Luna"]];
const CODES = {};
const IDS = {};
for (const [i, [rolle, name]] of ROLLEN.entries()) {
CODES[rolle] = `CODE-N${String(i).padStart(2, "0")}-000001`;
IDS[rolle] = anlegen(name, rolle, CODES[rolle]);
}
d.close();
/* ---------- Sprechen mit Hausadresse ------------------------------------
`fetch` kann die Host-Kopfzeile nicht setzen (verbotene Kopfzeile),
und an ihr entscheidet das Haus alles. Deshalb node:http. */
function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad, method,
headers: {
Host: host,
Origin: origin === undefined ? `https://${host}` : origin,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (s) => teile.push(s));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, host) {
const a = await roh(host, "/workspace/api/anmelden", {
method: "POST", rumpf: { rolle, code: CODES[rolle] },
});
const keks = []
.concat(a.kopf["set-cookie"] || [])
.map((z) => z.split(";")[0]).join("; ");
return a.code === 200 && keks ? keks : null;
}
const kekse = { modi: await anmelden("modi", CREW) };
if (!kekse.modi) { console.error("Anmeldung ging nicht"); process.exit(1); }
const PROBE = [
"# Sendung am Freitag",
"",
"[x] Gaesteliste an VanVan",
"[ ] Technik durchgehen -- Ton am Mischpult",
"[ ] Ansage schreiben",
"",
"- Idee: Zuschauerfragen in der letzten halben Stunde",
"- Idee: Gewinnspiel, aber ohne Punkte",
"",
"> Nicht vergessen: Casper braucht um 19 Uhr seine Runde.",
"",
"Unterlagen: https://crew.dogfather-universe.com/workspace/dateien.html",
].join(String.fromCharCode(10));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const { execFileSync } = await import("node:child_process");
const { createServer: httpsServer } = await import("node:https");
const { request: httpAnfrage } = await import("node:http");
const schluesselDatei = join(ordner, "m.key");
const zertDatei = join(ordner, "m.crt");
zertifikatBauen(schluesselDatei, zertDatei, CREW);
/* NICHT `PORT + 1`: `portNummer` ueberspringt gesperrte Nummern,
deshalb kann die naechste Zahl die NUMMER DER NAECHSTEN DATEI sein,
sobald einmal eine Sperre dazwischenliegt. Heute liegt keine dort --
das ist Glueck, kein Entwurf. Die zweite Nummer gibt es als nr=1. */
const HTTPS_PORT = await eigenerPort(import.meta, "mess-notizblock (HTTPS)", 1);
const vorbau = httpsServer({ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei) },
(von, nach) => {
const weiter = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url,
method: von.method, headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` } },
(antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); });
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const BASIS = `https://${CREW}:${HTTPS_PORT}`;
/* Drei Blaetter, damit der Stapel etwas zeigt. */
const BR = String.fromCharCode(10);
await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi,
rumpf: { text: ["Einkauf fuers Studio", "[ ] Kabel", "[ ] Kaffee"].join(BR) } });
await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi,
rumpf: { text: ["Woerter, die ich nicht mehr sagen will", "- eigentlich", "- quasi"].join(BR),
farbe: "rosa" } });
const haupt = await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi,
rumpf: { text: PROBE, farbe: "sonne" } });
await roh(CREW, `/workspace/api/notizblock/${haupt.j.notiz.id}`, { method: "PATCH",
keks: kekse.modi, rumpf: { angeheftet: true } });
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"] });
const kontext = await browser.newContext({ viewport: { width: 1440, height: 980 },
ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
await kontext.addCookies(kekse.modi.split("; ").filter(Boolean).map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
await seite.screenshot({ path: "server/mess-notizblock-rechner.png" });
console.log("server/mess-notizblock-rechner.png");
/* EIN EIGENER KONTEXT MIT FINGER.
`setViewportSize` aendert nur die Groesse -- `hasTouch` gehoert
zum KONTEXT und laesst sich danach nicht mehr setzen. Ohne ihn
meldet der Browser `pointer: fine`, und keine einzige Regel aus
`@media (pointer: coarse)` greift; dort stehen im ganzen Haus
die 44-Pixel-Beruehrziele. Ein Handy-Bild mit Mauszeiger zeigt
eine Seite, die auf keinem Handy so aussieht. */
const handyKontext = await browser.newContext({
viewport: { width: 390, height: 844 },
hasTouch: true,
ignoreHTTPSErrors: true,
deviceScaleFactor: 2,
});
await handyKontext.addCookies(kekse.modi.split("; ").filter(Boolean).map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const handy = await handyKontext.newPage();
await handy.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" });
await handy.waitForTimeout(900);
await handy.screenshot({ path: "server/mess-notizblock-handy-liste.png" });
await handy.locator(".n-zettel").first().click();
await handy.waitForTimeout(600);
await handy.screenshot({ path: "server/mess-notizblock-handy-blatt.png" });
console.log("server/mess-notizblock-handy-liste.png");
console.log("server/mess-notizblock-handy-blatt.png");
await browser.close();
await new Promise((r) => vorbau.close(r));
process.exit(0);
+607
View File
@@ -0,0 +1,607 @@
/* =====================================================================
WAS MUSS MAN SEITLICH SCHIEBEN? (28.09.2026)
Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
schaltet sie durch alle drei Zustaende und legt Bilder ab.
Gemessen wird dabei auch, was man sonst uebersieht:
- laeuft etwas in die Konsole?
- ragt etwas seitlich hinaus?
- ist der Saal so hoch wie das Fenster, oder muss man scrollen?
NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
Zertifikat -- und am Ende ist alles weg.
Aufrufen mit: node server/mess-reaktion.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { request } from "node:http";
import { execFileSync } from "node:child_process";
import { createServer as httpsServer } from "node:https";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HTTPS_PORT = await eigenerPort(import.meta, "mess-quer (HTTPS)", 1);
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
for (const art of ["uncaughtException", "unhandledRejection"]) {
process.on(art, (f) => {
console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
process.exit(8);
});
}
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const CREW = "crew.dogfather-universe.com";
/* ---------- Leute ------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const kenn = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kenn, new Date().toISOString());
const ALT = new Date(Date.now() - 200 * 86400000).toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", kenn).update(code).digest("hex"), ALT);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
const IDS = {
admin: anlegen("DogFather", "admin", CODES.admin),
gast: anlegen("Nachtfalter", "gast", CODES.gast),
gast2: anlegen("Lenny", "gast", CODES.gast2),
};
d.close();
function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad, method,
headers: {
Host: CREW, Origin: `https://${CREW}`,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (s) => teile.push(s));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle) {
const a = await roh("/workspace/api/anmelden", {
method: "POST",
rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
});
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const k = {
admin: await anmelden("admin"),
gast: await anmelden("gast"),
gast2: await anmelden("gast2"),
};
/* ---------- Die Buehne fuellen ------------------------------------------ */
await roh("/workspace/api/reaktion", {
method: "PUT", keks: k.admin,
rumpf: {
titel: "Wir schauen den neuen Trailer",
video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
},
});
/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
for (const adresse of ["https://youtu.be/9bZkp7q19f0",
"https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
await roh("/workspace/api/reaktion/liste", {
method: "POST", keks: k.admin, rumpf: { video: adresse },
});
}
const REDEN = [
["gast", "Bin schon daaa 🐺"],
["gast2", "Ich auch! Hab extra Popcorn geholt"],
["gast", "Wie lange noch?"],
["admin", "Zwei Minuten, ich richte noch die Kamera"],
["gast2", "Kein Stress, wir sitzen bequem"],
["gast", "Letztes Mal war so gut, ich hab geheult"],
];
/* ---------- Der Vorbau -------------------------------------------------- */
const schluesselDatei = join(ordner, "p.key");
const zertDatei = join(ordner, "p.crt");
zertifikatBauen(schluesselDatei, zertDatei, CREW);
const vorbau = httpsServer({
key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
}, (von, nach) => {
const weiter = request({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
}, (antwort) => {
nach.writeHead(antwort.statusCode || 502, antwort.headers);
antwort.pipe(nach);
});
weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
von.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
const BASIS = `https://${CREW}:${HTTPS_PORT}`;
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
/* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
Aufnahme aus, als waere die Kamera kaputt. */
"--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
schliesst, nimmt den spaeteren den Platz weg.
GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
ein Messfehler, der wie ein Programmfehler aussah. */
async function alsWer(rolle, breite, hoehe) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
/* MIT FINGER, WENN ES EIN HANDY IST.
Ohne `hasTouch` meldet der Browser `pointer: fine` -- und
damit greift KEINE der Regeln aus `@media (pointer: coarse)`.
Dort stehen im ganzen Haus die 44-Pixel-Beruehrziele und seit
dem 28.09. auch das Ausblenden der Tastenkuerzel. Eine
Handy-Messung mit Mauszeiger misst eine Seite, die auf keinem
Handy so aussieht -- und meldet Hoehen, die dort andere sind. */
hasTouch: breite <= 860,
ignoreHTTPSErrors: true,
permissions: ["camera", "microphone"],
});
await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
const anfragen = [];
seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("response", (a) => {
if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
});
return { kontext, seite, konsole, anfragen };
}
async function bild(seite, name) {
await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
console.log(` Bild: server/mess-reaktion-${name}.png`);
}
async function vermessen(seite, wo) {
const m = await seite.evaluate(() => ({
ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
stand: document.getElementById("saal")?.dataset.stand || "?",
schild: document.getElementById("schild-wort")?.textContent || "",
}));
console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", `
+ `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
return m;
}
/* =====================================================================
WAS MUSS MAN SEITLICH SCHIEBEN?
Filipe, 28.09.2026: „ich will auch nicht dass man sachen nach links
und rechts schieben muss. perfektion das untereinander. ich will
niemals irgendwas nach links oder rechts swippen muessen."
NICHT GERATEN, SONDERN AUFGEZAEHLT. Ein `grep` nach `overflow-x`
findet die absichtlichen Roller. Es findet NICHT die Stelle, an der
ein Inhalt breiter ist als sein Kasten und der Browser von sich aus
eine Rollleiste anhaengt -- und genau das ist der Fall auf Filipes
Bildschirmfoto. Dieses Werkzeug geht jedes Element durch und nennt
jedes, dessen Inhalt breiter ist als sein Platz.
===================================================================== */
/* AUF SENDUNG. Nur dann steht die Seite vollstaendig da: ohne
laufende Sendung fehlen Chat, Kamerafenster und der halbe
Transport -- und dann misst man eine Seite, die es im Betrieb
nie gibt.
EIN EIGENER FEHLER, GEFUNDEN AM BILDSCHIRMFOTO: Die erste Fassung
schickte `PUT`; die Route nimmt aber `POST`. Der Aufruf lief ins
Leere, die Messung lief weiter, und das Schild sagte noch immer
„GESCHLOSSEN". Deshalb steht unten eine Nachfrage: Eine Messung,
die ihre eigene Voraussetzung nicht nachprueft, misst irgendetwas
und meldet es als Ergebnis. */
for (const stufe of ["vorbereitung", "live"]) {
await roh("/workspace/api/reaktion/stand", {
method: "POST", keks: k.admin, rumpf: { stand: stufe },
});
}
/* Ein Gast dazu -- sonst fehlt die halbe Gaesteliste, und im Saal
steht nur ein Kamerafenster statt zweier. */
await roh("/workspace/api/reaktion/gast", {
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
});
/* Und ein paar Worte im Chat -- eine leere Schiene kann nicht
ueberlaufen, und genau das waere ein gruener Haken ohne Inhalt. */
for (const [wer, text] of REDEN) {
await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } });
}
/* EIN ZWEITES VIDEO -- SONST FEHLT DER UMSCHALTER.
Dieses Werkzeug meldete „nichts rollt seitlich", waehrend
`mess-reaktion` am selben Aufbau 76 Pixel fand. Der Unterschied:
Dort war ein zweites Video eingerichtet, also stand der
Umschalter in der Leiste -- mit einem ganzen Videotitel darin,
und genau der lief hinaus. Hier war er `hidden`.
Ein Knopf, den die Messung nie sieht, kann nicht ueberlaufen --
und ihr gruener Haken sagt dann nichts ueber ihn. */
await roh("/workspace/api/reaktion/zweitvideo", {
method: "POST", keks: k.admin,
rumpf: { video: "https://www.youtube.com/watch?v=kJQP7kiw5Fk" },
});
{
const nach = await roh("/workspace/api/reaktion", { keks: k.admin });
if (nach.j?.stand !== "live") {
console.error(`ABBRUCH: die Sendung steht auf „${nach.j?.stand}" statt „live" -- `
+ "ohne Sendung fehlen Chat und Kamerafenster, und dann misst dieses "
+ "Werkzeug eine Seite, die es im Betrieb nicht gibt.");
process.exit(9);
}
console.log(`Buehne steht: auf Sendung, ein Gast, Chat gefuellt, `
+ `zweites Video ${nach.j?.zweit?.video ? "bereit" : "FEHLT"}.`);
if (!nach.j?.zweit?.video) {
console.error("ABBRUCH: ohne zweites Video fehlt der Umschalter in der "
+ "Transportleiste -- und genau der lief am 28.09. seitlich hinaus.");
process.exit(9);
}
}
const GERAETE = [
["Rechner", 1440, 900],
["Tablet", 900, 1200],
["Handy", 412, 915],
["iPhone", 390, 844],
["kleines Handy", 360, 640],
["kleinstes Handy", 320, 568],
];
let befunde = 0;
for (const [gname, breite, hoehe] of GERAETE) {
console.log(`\n=== ${gname} (${breite}x${hoehe}) ===`);
const host = await alsWer("admin", breite, hoehe);
await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "domcontentloaded" });
/* AUF DIE LEISTE WARTEN, NICHT AUF DIE REGISTER (01.10.2026).
Seit die Register am Fingergeraet hinter einem Knopf liegen, ist
`#reiter` dort unsichtbar -- `waitForSelector` wartet aber auf
SICHTBARKEIT und lief in die Zeitsperre ("33 x locator resolved
to hidden"). Gewartet wird jetzt auf die Leiste selbst; die
steht in jedem Fall. */
await host.seite.waitForSelector("#regieleiste", { timeout: 15000 });
/* UND DANN AUFKLAPPEN WIE EIN MENSCH. Diese Messung sucht
seitlichen Ueberlauf IN den Registern -- sie muss sie also
sehen. Der Knopf gibt es nur am Fingergeraet; am Rechner steht
die Leiste ohnehin offen, und `?.` faengt das ab. */
await host.seite.evaluate(() => {
const l = document.getElementById("regieleiste");
if (l && l.dataset.auf !== "auf") document.getElementById("reiter-auf")?.click();
});
await host.seite.waitForSelector("#reiter", { timeout: 15000 });
/* ZUERST ZUGEKLAPPT -- DAS IST DER HAEUFIGSTE ZUSTAND.
Die erste Fassung mass nur mit offener Regie, weil dort die
gemeldete Stelle lag. `mess-reaktion` fand daraufhin 76 Pixel
seitlichen Ueberlauf auf einem 390er Handy, die hier nicht
auftauchten -- schlicht weil dort die Regie ZU war. Wer nur
den Zustand misst, in dem der Fehler gemeldet wurde, findet
den zweiten nie. */
{
/* AUSDRUECKLICH ZUKLAPPEN. Die Seite merkt sich den Zustand,
und beim Laden stand sie hier auf „offen" -- die Messung
haette den zugeklappten Fall also nie gesehen und trotzdem
„zugeklappt: 0 px" gemeldet. Ein gruener Haken ueber einen
Zustand, den man gar nicht hergestellt hat. */
await host.seite.evaluate(() => {
const p = document.getElementById("pult");
if (p && p.dataset.auf === "ja") document.getElementById("pult-griff")?.click();
});
await host.seite.waitForTimeout(400);
const zu = await host.seite.evaluate(() => ({
auf: document.getElementById("pult")?.dataset.auf,
ueber: Math.max(0, document.documentElement.scrollWidth
- document.documentElement.clientWidth),
breitestes: (() => {
let b = null, max = 0;
for (const e of document.querySelectorAll("*")) {
const r = e.getBoundingClientRect();
if (r.right > max) { max = r.right; b = e; }
}
return b ? `${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}`
+ `${typeof b.className === "string" && b.className
? "." + b.className.trim().split(/\s+/)[0] : ""} bis ${Math.round(max)} px` : "-";
})(),
}));
console.log(` zugeklappt (auf=${zu.auf}): seitlich ${zu.ueber} px`);
if (zu.ueber > 0) {
console.log(` ACHTUNG: die Seite rollt bei ZUGEKLAPPTER Regie ${zu.ueber} px seitlich`);
console.log(` am weitesten rechts: ${zu.breitestes}`);
befunde++;
}
}
/* Die Regie aufklappen -- zugeklappt sieht man die Tafeln nicht,
und genau in ihnen steckt der gemeldete Ueberlauf. */
const auf = await host.seite.evaluate(() => {
const p = document.getElementById("pult");
if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click();
return document.getElementById("pult")?.dataset.auf || "?";
});
await host.seite.waitForTimeout(400);
console.log(` Regie: ${auf}`);
const register = await host.seite.evaluate(() =>
[...document.querySelectorAll(".reiter__knopf")].map((b) => b.dataset.tafel));
console.log(` Register: ${register.join(" · ")}`);
{
const z = await host.seite.evaluate(() => {
const saal = document.getElementById("saal");
const teile = [...saal.children].filter((e) => e.getBoundingClientRect().height > 0)
.map((e) => { const q = e.getBoundingClientRect();
return `${e.id || e.className.split(" ")[0]} ${Math.round(q.top)}..${Math.round(q.bottom)}`; });
return `Zeilen ${getComputedStyle(saal).gridTemplateRows} | ${teile.join(" / ")}`;
});
console.log(` ${z}`);
}
/* Erst die Seite als Ganzes. */
const seitlich = await host.seite.evaluate(() =>
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth));
if (seitlich > 0) { console.log(` ACHTUNG: die SEITE selbst rollt ${seitlich} px seitlich`); befunde++; }
for (const reg of register) {
await host.seite.evaluate((r) => {
/* ERST AUFKLAPPEN, DANN WAEHLEN. Ein Klick auf ein Register
klappt die Leiste am Handy wieder zu -- das naechste waere
sonst nicht anklickbar. */
const leiste = document.getElementById("regieleiste");
if (leiste && leiste.dataset.auf !== "auf") {
document.getElementById("reiter-auf")?.click();
}
document.querySelector(`.reiter__knopf[data-tafel="${r}"]`)?.click();
}, reg);
await host.seite.waitForTimeout(250);
const quer = await host.seite.evaluate(() => {
const raus = [];
for (const el of document.querySelectorAll("*")) {
const w = el.scrollWidth - el.clientWidth;
if (w <= 1) continue;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue; /* verborgen */
const st = getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden") continue;
/* NUR WAS MAN WIRKLICH SCHIEBEN KANN.
`overflow-x: hidden` ist KEIN Wischen -- dort ist der Text
abgeschnitten (meist mit „…"), und schieben laesst sich
nichts. Das ist ein anderes Thema und gehoert nicht in
diese Messung; wer es mitzaehlt, meldet 78 Stellen, von
denen 60 gar keine sind, und findet die echten nicht mehr.
`visible` zaehlt ebenfalls nicht als Stelle -- dort wandert
der Ueberlauf nach oben zum naechsten Roller, und DER ist
die Stelle. Ausnahme: Wurzel und Rumpf, denn dort rollt
das Fenster selbst. */
const wurzel = el === document.documentElement || el === document.body;
if (!wurzel && st.overflowX !== "auto" && st.overflowX !== "scroll") continue;
raus.push({
wer: el.tagName.toLowerCase()
+ (el.id ? "#" + el.id : "")
+ (el.className && typeof el.className === "string"
? "." + el.className.trim().split(/\s+/).join(".") : ""),
ueber: w,
innen: el.clientWidth,
inhalt: el.scrollWidth,
ox: st.overflowX,
/* Was ist das breiteste Kind? Das ist fast immer die
Ursache -- und ohne diese Angabe sucht man sie von Hand. */
/* DIE KETTE BIS ZUR URSACHE. Das breiteste Kind zu nennen
reicht nicht: Es ist meist selbst nur ein Kasten, der
sich nach SEINEM breitesten Kind richtet. Erst am Ende
der Kette steht das Ding, das wirklich zu breit ist --
und nur dort kann man etwas reparieren. */
kette: (() => {
const teile = [];
let jetzt = el;
for (let tief = 0; tief < 8; tief++) {
let b = null, max = 0;
for (const kind of jetzt.children) {
const kr = kind.getBoundingClientRect();
if (kr.width > max) { max = kr.width; b = kind; }
}
if (!b) break;
teile.push(`${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}`
+ `${typeof b.className === "string" && b.className
? "." + b.className.trim().split(/\s+/)[0] : ""}:${Math.round(max)}`);
jetzt = b;
}
return teile.length ? teile.join(" > ") : "-";
})(),
});
}
return raus;
});
/* EIN BILD JE REGISTER AUF DEM RECHNER.
Zahlen sagen, dass nichts mehr rollt. Sie sagen nicht, ob es
danach gut aussieht -- und genau das war Filipes Frage. */
if (breite === 1440) {
await host.seite.locator("#pult-innen").screenshot({
path: join(HIER, `mess-quer-tafel-${reg}.png`) }).catch(() => {});
/* Die Stufenleiter steht weiter unten als das Fenster reicht --
sie braucht ein eigenes Bild. Ausgerechnet sie trug bis heute
den Stil der Chat-Leiste (Namensstreit), also ist sie die
Stelle, an der man nachsehen muss. */
if (reg === "gestaltung") {
await host.seite.locator("#stufen").screenshot({
path: join(HIER, "mess-quer-stufen.png") }).catch(() => {});
}
}
if (quer.length === 0) { console.log(` ${reg}: nichts rollt seitlich`); continue; }
console.log(` ${reg}: ${quer.length} Stelle(n) rollen seitlich`);
for (const q of quer) {
console.log(` ACHTUNG ${q.wer}`);
console.log(` Platz ${q.innen} px, Inhalt ${q.inhalt} px `
+ `(${q.ueber} px zu viel), overflow-x: ${q.ox}`);
console.log(` Kette: ${q.kette}`);
/* WER RAGT UEBER DIE RECHTE KANTE?
Die erste Fassung fragte nach der BREITE eines Kindes --
und fand nichts, obwohl 211 Pixel ueberliefen. Der Grund:
Ein Kind kann genau passen und sein ENKEL trotzdem rechts
hinausragen, weil dazwischen `overflow: visible` steht.
Gefragt ist also nicht „wer ist zu breit", sondern „wessen
rechte Kante liegt jenseits der Kante des Rollers" -- und
davon das oberste Glied, denn alles darunter folgt ihm. */
const schuld = await host.seite.evaluate((wer) => {
const nenne = (e) => e.tagName.toLowerCase()
+ (e.id ? "#" + e.id : "")
+ (e.className && typeof e.className === "string"
? "." + e.className.trim().split(/\s+/).join(".") : "");
const roller = [...document.querySelectorAll("*")].find((e) => nenne(e) === wer);
if (!roller) return ["(nicht gefunden)"];
const kante = roller.getBoundingClientRect().left + roller.clientWidth;
const zeilen = [];
/* IMMER ABSTEIGEN, AUCH DURCH UNAUFFAELLIGES.
Die erste Fassung stieg nur in Kinder hinab, die selbst
schon hinausragten -- und fand deshalb gar nichts: Die
sichtbare Tafel passte (327 px in 359), und erst IHR Kind
ragte 572 px weit hinaus. Eine Suche, die nur dort
weitersucht, wo sie schon fuendig ist, findet nie etwas,
das hinter etwas Unauffaelligem steckt. */
const gehe = (el, tief) => {
if (tief > 7) return;
for (const kind of el.children) {
const kr = kind.getBoundingClientRect();
if (kr.width === 0) continue;
if (kr.right <= kante + 1) { gehe(kind, tief); continue; }
const st = getComputedStyle(kind);
zeilen.push(`${" ".repeat(tief)}${nenne(kind).split(".").slice(0, 2).join(".")}`
+ ` ragt ${Math.round(kr.right - kante)} px hinaus`
+ ` (${Math.round(kr.width)} px breit)`
+ ` · ${st.display}`
+ (st.gridTemplateColumns !== "none" ? ` · spalten ${st.gridTemplateColumns}` : "")
+ (st.display.includes("flex") ? ` · wrap ${st.flexWrap} · basis ${st.flexBasis}` : "")
+ ` · min-width ${st.minWidth}`
+ (st.whiteSpace !== "normal" ? ` · ${st.whiteSpace}` : ""));
gehe(kind, tief + 1);
}
};
gehe(roller, 0);
return zeilen.length ? zeilen : ["(nichts ragt hinaus -- Rand oder Schatten?)"];
}, q.wer);
for (const z of schuld) console.log(` ${z}`);
befunde++;
}
}
/* ==== UND IN JEDER ANORDNUNG ====================================
`mess-reaktion` meldete auf einem 390er Handy 76 Pixel
seitlichen Ueberlauf, die hier nicht auftauchten. Der
Unterschied war die ANORDNUNG: Dort war vorher „nur Kamera"
eingestellt, hier stand die Vorgabe. Die Anordnung gehoert der
Sendung und gilt fuer alle -- wer sie nicht durchprobiert,
misst nur einen von fuenf Faellen. */
for (const anordnung of ["kino", "gleich", "kameras", "nur_video", "nur_kamera"]) {
await host.seite.evaluate((a) => {
document.querySelector(`#layout-reihe .layout-knopf[data-layout="${a}"]`)?.click();
}, anordnung);
await host.seite.waitForTimeout(500);
const m = await host.seite.evaluate(() => {
const ueber = Math.max(0, document.documentElement.scrollWidth
- document.documentElement.clientWidth);
if (ueber === 0) return { ueber };
let b = null, max = 0;
for (const e of document.querySelectorAll("*")) {
const r = e.getBoundingClientRect();
if (r.width === 0) continue;
if (r.right > max) { max = r.right; b = e; }
}
return {
ueber,
wer: b ? `${b.tagName.toLowerCase()}${b.id ? "#" + b.id : ""}`
+ `${typeof b.className === "string" && b.className
? "." + b.className.trim().split(/\s+/)[0] : ""}` : "-",
bis: Math.round(max),
};
});
if (m.ueber > 0) {
console.log(` ACHTUNG Anordnung „${anordnung}": ${m.ueber} px seitlich `
+ `-- am weitesten rechts ${m.wer} (bis ${m.bis} px)`);
befunde++;
} else {
console.log(` Anordnung „${anordnung}": seitlich 0 px`);
}
}
await host.seite.evaluate(() => {
document.querySelector('#layout-reihe .layout-knopf[data-layout="kino"]')?.click();
});
await host.seite.screenshot({ path: join(HIER, `mess-quer-${breite}.png`) });
await host.kontext.close();
}
console.log(`\n=== ${befunde === 0 ? "NICHTS rollt seitlich." : befunde + " Stelle(n) rollen seitlich."} ===`);
await browser.close();
await new Promise((r) => vorbau.close(r));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei noch */ }
process.exit(befunde === 0 ? 0 : 1);
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
/* =====================================================================
WAS STEHT AUF DIESER SEITE WIRKLICH?
=====================================================================
Gebaut am 25.09.2026, weil `pruef-neue-seiten` meldete, die
Entwicklungsseite sei nicht gefuellt (350 Zeichen). Sie ist gefuellt
-- mit zwei Personenkacheln, vier Abschnitten und einem Ring je
Person. Sie braucht nur weniger Worte, seit aus achtzehn
Zahlenkaesten zwei Kacheln geworden sind.
Die Messung zeigt den Text, die Zahl der Kacheln, den Zustand des
Kartenblocks und jeden Fehler aus der Browserkonsole. Mit ROLLE=hand
sieht sie die Seite mit den Augen der rechten Hand.
Aufrufen mit:
node server/mess-seiteninhalt.mjs <seite.html>
ROLLE=hand node server/mess-seiteninhalt.mjs entwicklung.html
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const SEITE = process.argv[2] || "entwicklung.html";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-inhalt-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(200_000, "mess-seiteninhalt");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Frida", "modi", "CODE-MODI-0001");
anlegen("Rieke", "hand", "CODE-HAND-0001");
d.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(process.env.ROLLE === "hand"
? { rolle: "hand", code: "CODE-HAND-0001" }
: { rolle: "admin", code: "CODE-DOGI-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text().slice(0, 160)); });
seite.on("pageerror", (e) => konsole.push("ABSTURZ: " + e.message.slice(0, 160)));
await seite.goto(`${BASIS}/workspace/${SEITE}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
/* Mit SUCHE=<text> wird EIN Element genau vermessen -- Kasten,
Darstellungsart, Umbruchregeln und die Kette darueber. */
if (process.env.SUCHE) {
const s = await seite.evaluate((suche) => {
const e = [...document.querySelectorAll("*")].find(
(x) => x.children.length === 0 && (x.textContent || "").trim().startsWith(suche));
if (!e) return null;
const r = e.getBoundingClientRect();
const st = getComputedStyle(e);
const kette = [];
for (let p2 = e.parentElement; p2 && kette.length < 5; p2 = p2.parentElement) {
const pr = p2.getBoundingClientRect();
const ps = getComputedStyle(p2);
kette.push(`${p2.tagName.toLowerCase()}.${String(p2.className).split(" ")[0]} `
+ `${Math.round(pr.width)}x${Math.round(pr.height)} `
+ `display:${ps.display} overflow:${ps.overflow} min-width:${ps.minWidth}`);
}
return {
text: (e.textContent || "").trim().slice(0, 30),
kasten: `${Math.round(r.width)}x${Math.round(r.height)} bei ${Math.round(r.left)},${Math.round(r.top)}`,
scroll: `${e.scrollWidth} / client ${e.clientWidth}`,
display: st.display, umbruch: st.overflowWrap, weiss: st.whiteSpace,
schrift: st.fontSize, richtung: st.writingMode,
kette,
};
}, process.env.SUCHE);
console.log("=== " + process.env.SUCHE + " ===");
console.log(s ? JSON.stringify(s, null, 2) : "NICHT GEFUNDEN");
}
const fund = await seite.evaluate(() => ({
text: (document.body.innerText || "").trim(),
laenge: (document.body.innerText || "").trim().length,
personen: document.querySelectorAll(".e-person").length,
abschnitte: document.querySelectorAll("section").length,
karteBlock: (() => { const e = document.getElementById("karte-block"); return e ? (e.hidden ? "VERSTECKT" : "sichtbar") : "gibt es nicht"; })(),
leer: [...document.querySelectorAll("[data-leer], .leer-hinweis")]
.map((e) => (e.textContent || "").trim().slice(0, 60)),
}));
console.log(`\n=== ${SEITE}: ${fund.laenge} Zeichen, `
+ `${fund.personen} Personenkacheln, ${fund.abschnitte} Abschnitte ===`);
console.log(fund.text.slice(0, 900));
console.log("karte-block: " + fund.karteBlock);
if (fund.leer.length) console.log("\nLeer-Hinweise: " + fund.leer.join(" | "));
if (konsole.length) console.log("\nKONSOLE: " + konsole.join("\n "));
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+2 -2
View File
@@ -23,9 +23,9 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4496, "mess-sichtbarkeit");
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-mess-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
+3 -3
View File
@@ -26,9 +26,9 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { portMussFreiSein } from "./helfer-port.mjs";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4471, "mess-startseite");
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-mess-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
@@ -124,7 +124,7 @@ try {
for (const p of leute) {
const keks = await anmelden(p.kachel, p.code);
if (!keks) { console.log(` !! ${p.rolle}: Anmeldung fehlgeschlagen`); continue; }
const kontext = await browser.newContext({ viewport: { width: BREITE, height: HOEHE } });
const kontext = await browser.newContext({ viewport: { width: BREITE, height: HOEHE }, hasTouch: BREITE <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [name, ...rest] = k.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
+273
View File
@@ -0,0 +1,273 @@
/* =====================================================================
WIE DER SUPPORT JETZT AUSSIEHT UND SICH ANFUEHLT (25.09.2026)
Filipe, zwei Wuensche an einem Abend:
„ich will dass die leute die mir was geschickt haben im support,
auch meine notiz bekommen wenn ich fertig bin ... und erst wenn
es funktioniert gedrueckt wird, will ich dass alles richtig
fertig ist."
„ich will dass du bitte diese kachel viel geiler viel
profissioneller machst, die soll koomplett anders aussehen und
strukturiert sein ... die hauptfarbe der kachel soll auch
babyblau sein mit bissl lila."
SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder,
auf denen man nachsehen kann. Deshalb `mess-` und nicht `pruef-`.
Gemessen wird die Wahrheit in `pruef-support.mjs` (58 Pruefungen);
hier geht es um das, was man nur SIEHT.
VIER BILDER, weil die Sache vier Seiten hat:
1. Die Leitung sieht die Meldung und ihren Knopf.
2. Der Melder sieht die Notiz und seine zwei Knoepfe -- der Kern
des ganzen Umbaus.
3. Derselbe Blick auf dem Handy, 412 px. Das ist die Breite, auf
der die Modis tatsaechlich arbeiten.
4. Nach zwei Runden: der Verlauf mit beiden Urteilen. Genau der
Fall, den Filipe gemeint hat („manchmal ... nicht sofort
perfekt").
EIGENE WEGWERF-DATENBANK in einem `mktemp`-Ordner: Es entstehen
echte Meldungen. In einem laufenden System waeren das Testdaten,
und die sind verboten.
Aufrufen mit: node server/mess-support-runde.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const HP = await eigenerPort(import.meta, "mess-support-runde (HTTPS)", 1);
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-supportrunde-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(240_000, "mess-support-runde");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Rechte Hand", "hand", "CODE-HAND-0001");
const idModi = anlegen("Miss", "modi", "CODE-MODI-0001");
const idM2 = anlegen("Marina", "modi", "CODE-MODI-0002");
/* `von_name` GEHOERT DAZU -- die echte Melderoute schreibt ihn mit.
Ohne ihn saehe die Leitung „Liegt beim Melder" statt „Liegt bei
Miss", und das Bild zeigte einen Fall, den es im Betrieb nicht
gibt. Eine Messung mit zu wenig Daten misst die falsche Seite. */
const meldung = (person, name, text, seite, stand) => d.prepare(`INSERT INTO support_meldungen
(person_id, von_name, von_rolle, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?,?,?) RETURNING id`)
.get(person, name, "modi", text, seite, stand, jetzt, jetzt).id;
const offen = meldung(idM2, "Marina",
"Der Kalender springt beim Wischen manchmal zwei Monate weiter.",
"/workspace/kalender.html", "neu");
const dran = meldung(idModi, "Miss",
"Ich konnte da wieder nicht zurück gehen, musste die App schließen "
+ "damit ich wieder auf die Hauptseite kam.",
"/workspace/support.html", "in_arbeit");
d.close();
const anfrage = (pfad, art, koerper, keks) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: {
Host: CREW, "Content-Type": "application/json",
...(keks ? { Cookie: keks } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
/** Eine Rueckmeldung des Melders -- UEBER DEN KOPF, nicht als JSON.
*
* ==== DIESE MESSUNG HAT ZWEI TAGE LANG NICHTS GEMESSEN ===========
*
* Am 02.10.2026 ist `/rueckmeldung` auf den Weg umgestellt worden,
* den auch das Melden nimmt: Urteil und Text reisen im KOPF
* (`x-geht`, `x-text`, prozentkodiert), der Rumpf ist fuer die
* Bilder da. Diese Datei schickte weiter JSON -- der Server fand
* also kein Urteil und keinen Text und antwortete mit 400.
*
* GEMERKT HAT ES NIEMAND, weil die Messung keine Rueckgabewerte
* prueft: Sie macht Bilder. Auf dem Bild stand danach eine Runde
* statt vier, und das sieht aus wie ein Ergebnis. Aufgefallen ist
* es erst, als die Bilder vier Rundenfarben zeigen sollten.
*
* Eine Messung, die nach einem Umbau stillschweigend etwas anderes
* misst, ist schlimmer als keine -- man glaubt ihr. */
const rueckmelden = (id, geht, satz, keks) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: `/workspace/api/support/${id}/rueckmeldung`,
method: "POST",
headers: {
Host: CREW, Cookie: keks,
"Content-Type": "application/octet-stream",
"x-geht": geht ? "ja" : "nein",
"x-text": encodeURIComponent(satz || ""),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, text: t }));
});
a.on("error", schief);
a.end();
});
const anmelden = async (rolle, code) => {
const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); }
return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
};
const kHand = await anmelden("hand", "CODE-HAND-0001");
const kModi = await anmelden("modi", "CODE-MODI-0001");
/* --- Runde 1: die Leitung sagt „behoben" ---------------------------- */
await anfrage(`/workspace/api/support/${dran}`, "PATCH",
{ stand: "erledigt", antwort: "Der Zurück-Knopf ist wieder da – "
+ "er war hinter der Kopfleiste verschwunden." }, kHand);
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
});
async function blicken(name, keks, breite, hoehe) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: CREW, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => {
if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
konsole.push(e.text().slice(0, 140));
}
});
await seite.goto(`https://${CREW}:${HP}/workspace/support.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
const blick = await seite.evaluate(() => {
const karten = [...document.querySelectorAll(".s-karte")];
const knopf = (k) => [...k.querySelectorAll(".s-karte__tun button")]
.map((b) => b.textContent.trim()).join(" | ");
return {
karten: karten.length,
staende: karten.map((k) => k.querySelector(".s-karte__stand")?.textContent.trim())
.join(" / "),
knoepfe: karten.map(knopf).filter(Boolean).join(" // ") || "-",
/* Der Hinweis „wer ist dran" wird MITGEMESSEN -- er ist einmal
stumm ausgefallen (before() auf einem heimatlosen Element),
und im Bild sah das aus wie Absicht. */
wartet: [...document.querySelectorAll(".s-karte__wartet")]
.map((w) => w.textContent.trim()).join(" | ") || "-",
runden: document.querySelectorAll(".s-runde").length,
urteile: [...document.querySelectorAll(".s-runde__urteil")]
.map((u) => u.dataset.urteil).join(", ") || "-",
akzent: getComputedStyle(document.querySelector(".inhalt")).getPropertyValue("--akzent").trim(),
felder: document.querySelectorAll(".s-bilanz__feld").length,
};
});
console.log(`\n=== ${name} (${breite} x ${hoehe}) ===`);
for (const [k, v] of Object.entries(blick)) console.log(` ${k.padEnd(10)} ${v}`);
if (konsole.length) console.log(" FEHLER IN DER KONSOLE:", konsole);
const datei = `server/mess-support-${name}.png`;
await seite.screenshot({ path: datei, fullPage: false }).catch(() => {});
console.log(` Bild: ${datei}`);
await kontext.close();
}
try {
await blicken("leitung-pc", kHand, 1280, 1000);
await blicken("melder-pc", kModi, 1280, 1000);
await blicken("melder-handy", kModi, 412, 915);
/* --- Runde 2: „noch nicht", dann behoben, dann bestaetigt --------- */
const r2 = await rueckmelden(dran, false,
"Der Knopf ja – aber jetzt springt die Seite nach oben.", kModi);
if (r2.code !== 200) console.log(` !! Rueckmeldung ging nicht: ${r2.code} ${r2.text}`);
await anfrage(`/workspace/api/support/${dran}`, "PATCH",
{ stand: "erledigt", antwort: "Das Springen kam vom Nachladen. Ist raus." }, kHand);
await blicken("melder-runde2", kModi, 1280, 1000);
/* ==== VIER RUNDEN -- WEIL ES VIER FARBEN SIND (03.10.2026) =======
Filipe: „jede runde soll auch immer eine spezielle farbe haben …
damit man einen viel besseren und krasseren ueberblick hat."
Mit zwei Runden sieht man zwei Toene und kann nicht sagen, ob es
eine Reihe ist. Vier ist die Zahl, nach der sich die Farben
wiederholen (siehe `rundenTon` in support.js) -- also genau die
Zahl, bei der man die Regel sieht statt sie zu glauben. */
for (const [wort, satz] of [
["Jetzt laedt die Liste nicht mehr nach.", "Und jetzt ist der Knopf doppelt."],
["Der doppelte Knopf ist raus.", "Jetzt stimmt die Reihenfolge nicht."],
]) {
const rr = await rueckmelden(dran, false, satz, kModi);
if (rr.code !== 200) console.log(` !! Rueckmeldung ging nicht: ${rr.code} ${rr.text}`);
await anfrage(`/workspace/api/support/${dran}`, "PATCH",
{ stand: "erledigt", antwort: wort }, kHand);
}
await blicken("melder-vierrunden", kModi, 1280, 1100);
await blicken("melder-vierrunden-handy", kModi, 412, 915);
console.log(`\n(Die offene Meldung #${offen} bleibt unangetastet stehen –`
+ " so sieht man beide Sorten nebeneinander.)");
} finally {
await browser.close();
vorbau.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
}
+144
View File
@@ -0,0 +1,144 @@
/* =====================================================================
WER MACHT DIE SUPPORT-SEITE ZU BREIT? (25.09.2026)
=====================================================================
`pruef-breiten` meldet auf jeder Breite unter 1440 px einen
Ueberstand -- 13 px auf dem Handy, 41 px auf dem Tablet quer -- und
die Liste der Schuldigen bleibt leer: `[]`. Damit weiss man, DASS
etwas zu breit ist, und nicht WAS.
DIESE MESSUNG SUCHT DEN SCHULDIGEN, indem sie jedes Element im
Dokument fragt, ob seine rechte Kante ueber die Dokumentbreite
hinausragt -- und dann den KLEINSTEN nennt, der es tut. Der groesste
ist immer <html>, und der sagt nichts; der kleinste ist der, den man
anfassen muss.
Aufrufen mit: node server/mess-support-ueberstand.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-ueber-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "mess-support-ueberstand");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idModi = anlegen("Miss", "modi", "CODE-MODI-0001");
/* Eine Meldung, damit die Seite nicht leer ist -- ein leerer Eingang
kann gar nicht zu breit sein. */
d.prepare(`INSERT INTO support_meldungen
(person_id, von_name, von_rolle, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?,?,?)`)
.run(idModi, "Miss", "modi",
"Der Kalender springt beim Wischen manchmal zwei Monate weiter, "
+ "und danach komme ich nicht mehr zurueck.",
"/workspace/kalender.html", "neu", jetzt, jetzt);
d.close();
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
});
const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
for (const [wie, breite] of [["Handy", 390], ["Tablet quer", 1024], ["Laptop", 1280]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: 900 }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/" + (process.env.SEITE || "support.html"), { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const fund = await seite.evaluate(() => {
const doc = document.documentElement;
const ueber = doc.scrollWidth - doc.clientWidth;
const grenze = doc.clientWidth;
const schuld = [];
for (const e of document.querySelectorAll("*")) {
const r = e.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
const rechts = r.right + window.scrollX;
if (rechts <= grenze + 0.5) continue;
/* Nur das KLEINSTE, das herausragt -- ein Elternteil ragt
immer mit heraus, wenn es ein Kind tut, und nennt den Grund
nicht. */
const hatSchuldigesKind = [...e.children].some((k) => {
const kr = k.getBoundingClientRect();
return kr.right + window.scrollX > grenze + 0.5;
});
if (hatSchuldigesKind) continue;
const st = getComputedStyle(e);
schuld.push({
was: e.tagName.toLowerCase()
+ (e.id ? "#" + e.id : "")
+ (e.className && typeof e.className === "string"
? "." + e.className.trim().split(/\s+/).join(".") : ""),
links: Math.round(r.left), rechts: Math.round(rechts),
breite: Math.round(r.width),
ueberstand: Math.round(rechts - grenze),
position: st.position,
transform: st.transform === "none" ? "" : st.transform.slice(0, 40),
minBreite: st.minWidth,
text: (e.textContent || "").trim().slice(0, 30),
});
}
return { ueber, grenze, schuld: schuld.sort((a, b) => b.ueberstand - a.ueberstand) };
});
console.log(`\n=== ${wie} (${breite} px) -- Ueberstand ${fund.ueber} px ===`);
if (!fund.schuld.length) console.log(" niemand ragt heraus (dann ist es ein Rand/Schatten)");
for (const s of fund.schuld.slice(0, 6)) {
console.log(` ${s.was}`);
console.log(` links ${s.links}, rechts ${s.rechts} (Grenze ${fund.grenze}), `
+ `breit ${s.breite}, ueber ${s.ueberstand}`);
console.log(` position:${s.position} min-width:${s.minBreite}`
+ (s.transform ? ` transform:${s.transform}` : "")
+ (s.text ? ` "${s.text}"` : ""));
}
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+492
View File
@@ -0,0 +1,492 @@
/* =====================================================================
VANVANS MELDUNG AUS DEM SUPPORT (30.09.2026)
„Wenn ich im Bereich eure Aufgaben runterscrolle und dort bei den 68
Aufgaben … Aufgaben an einen Modi zutrage und auch bewerte, dann
sieht der Modi zwar die Auswertung im Bereich Entwicklung, aber bei
ihm taucht nichts im Bereich eure Aufgaben unten in der Karte auf.
Bei mir zum Beispiel steht in der Karte im Bereich eure Aufgaben
bei jedem Punkt läuft obwohl auch wenn die Aufgaben darüber gar
nicht zugetragen wurde."
---------------------------------------------------------------------
ZWEI FRAGEN, UND BEIDE LASSEN SICH NUR AM BILDSCHIRM BEANTWORTEN
1. Was sieht der MODI unten in seiner eigenen Karte, nachdem ihm
etwas zugetragen und bewertet wurde?
2. Was steht bei der LEITUNG an einem Punkt, der gar nicht
zugetragen ist?
Der Quelltext allein sagt das nicht. Er sagt, was gerendert wird --
nicht, was ein Mensch daraus liest. Und genau darum geht es in ihrer
Meldung.
---------------------------------------------------------------------
DER AUFBAU IST IHR FALL, NICHT EIN AEHNLICHER
Filipe (admin) und VanVan (hand) sind die beiden Beurteiler --
genauso wie im Haus. Diene ist ein Modi. VanVan traegt ihm ZWEI von
68 Punkten zu und bewertet sie; einen DRITTEN bewertet sie, ohne ihn
zuzutragen. Filipe bewertet ABSICHTLICH NICHTS -- denn genau so war
es bei ihr, und wenn das der Grund ist, muss er hier sichtbar
werden.
Sie prueft nichts und meldet keinen Fehlschlag. Sie sagt, was auf
dem Bildschirm steht.
Aufrufen mit: node server/mess-vanvan-karte.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-vanvan-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "mess-vanvan-karte");
await new Promise((r) => setTimeout(r, 800));
const BASIS = `http://127.0.0.1:${PORT}`;
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f?.stack || f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ:", f?.stack || f); process.exit(7); });
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const { ENTWICKLUNG_FREMD } = await import("./workspace-entwicklungs-punkte.js");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const geheim = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", geheim, jetzt);
function anlegen(name, rolle, code) {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salz, 32768,
createHmac("sha256", geheim).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idVan = anlegen("VanVan", "hand", "CODE-VANVAN-01");
const idModi = anlegen("Diene", "modi", "CODE-DIENE-001");
d.close();
/* ---- Anmelden ---------------------------------------------------- */
async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code, alter_ok: true }),
});
return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const kVan = await anmelden("hand", "CODE-VANVAN-01");
const kModi = await anmelden("modi", "CODE-DIENE-001");
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
if (!kVan || !kModi || !kDogi) {
console.error("KONNTE NICHT NACHSEHEN: die Anmeldung hat nicht geklappt.");
process.exit(1);
}
const ruf = (keks, weg, rumpf, art = "GET") => fetch(BASIS + weg, {
method: art,
headers: { "Content-Type": "application/json", Cookie: keks },
body: rumpf === undefined ? undefined : JSON.stringify(rumpf),
});
/* ---- VanVans Handgriffe ------------------------------------------ */
const P1 = ENTWICKLUNG_FREMD[0].schluessel;
const P2 = ENTWICKLUNG_FREMD[1].schluessel;
const P3 = ENTWICKLUNG_FREMD[2].schluessel; // bewertet, NICHT zugetragen
console.log("");
console.log("=== Was VanVan getan hat ===");
for (const [p, wort] of [[P1, "zugetragen"], [P2, "zugetragen"]]) {
const a = await ruf(kVan, `/workspace/api/entwicklung/person/${idModi}/punkt/${p}`,
{ an: true }, "PUT");
console.log(` ${wort}: ${p} (HTTP ${a.status})`);
}
for (const p of [P1, P2, P3]) {
const a = await ruf(kVan, `/workspace/api/entwicklung/person/${idModi}/${p}`,
{ stufe: "laeuft", anlass: "Im Stream gesehen, passt." }, "PUT");
console.log(` bewertet „Läuft": ${p} (HTTP ${a.status})`);
}
console.log(` Filipe bewertet NICHTS -- genau wie in ihrem Fall.`);
console.log(` Von ${ENTWICKLUNG_FREMD.length} Punkten sind 2 zugetragen und 3 bewertet.`);
/* =====================================================================
FRAGE 1: WAS SIEHT DER MODI UNTEN IN SEINER KARTE?
===================================================================== */
console.log("");
console.log("=== 1. Die eigene Karte des Modi (API) ===");
{
const k = await (await ruf(kModi, "/workspace/api/entwicklung/meine-karte")).json();
console.log(` hat_karte=${k.hat_karte} Beurteiler=${k.beurteiler} `
+ `fertig=${k.fertig} von ${k.gesamt}`);
const alle = (k.bloecke || []).flatMap((b) => b.punkte);
const offen = alle.filter((p) => p.offen).length;
console.log(` ${offen} von ${alle.length} Punkten stehen auf „offen"`);
for (const s of [P1, P2, P3]) {
const p = alle.find((x) => x.schluessel === s);
console.log(` ${s.padEnd(26)} offen=${p?.offen} `
+ `zugetragen=${"zugetragen" in (p || {}) ? p.zugetragen : "(gibt es nicht)"} `
+ `antworten=${JSON.stringify(p?.antworten ?? null)}`);
}
/* DIE KERNFRAGE: Steht in seiner Karte ueberhaupt irgendwo, WELCHE
Punkte ihm als Aufgabe zugetragen wurden? */
const kennt = alle.some((p) => "zugetragen" in p);
console.log(` Kennt seine Karte das Wort „zugetragen"? ${kennt ? "JA" : "NEIN"}`);
}
/* =====================================================================
FRAGE 2: WAS STEHT BEI VANVAN AN EINEM PUNKT?
===================================================================== */
console.log("");
console.log("=== 2. VanVans Karte ueber Diene (API) ===");
{
const k = await (await ruf(kVan, `/workspace/api/entwicklung/person/${idModi}`)).json();
const alle = (k.bloecke || []).flatMap((b) => b.punkte);
console.log(` ${alle.length} Punkte insgesamt, `
+ `${alle.filter((p) => p.zugetragen).length} davon zugetragen, `
+ `${alle.filter((p) => p.meins).length} bewertet, `
+ `${alle.filter((p) => p.schritt).length} mit einem Schritt`);
for (const s of [P1, P3]) {
const p = alle.find((x) => x.schluessel === s);
console.log(` ${s.padEnd(26)} zugetragen=${p?.zugetragen} `
+ `meins=${p?.meins?.stufe ?? "-"} schritt=${p?.schritt ? p.schritt.status : "-"}`);
}
}
/* =====================================================================
UND JETZT DER BILDSCHIRM
===================================================================== */
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function alsWer(keks, breite = 1280, hoehe = 1000) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860 });
await kontext.addCookies(keks.split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
return { kontext, seite, konsole };
}
console.log("");
console.log("=== 3. Was der Modi auf der Seite Eure Aufgaben WIRKLICH sieht ===");
{
const { kontext, seite, konsole } = await alsWer(kModi);
await seite.goto(`${BASIS}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
const w = await seite.evaluate(() => {
/* GEMESSEN WIRD IN SEINER KARTE, nicht auf der ganzen Seite. Das
Wort „Aufgabe" steht oben in der Kopfleiste und in der
Vorlagen-Kachel -- eine Suche ueber die ganze Seite haette
„ja" gesagt, ohne dass in der Karte irgendetwas steht. Genau
die Sorte gruener Haken, die nichts geprueft hat. */
const karte = document.getElementById("karte-block");
const auf = karte?.querySelector(".e-block--auftrag") || null;
return {
ueberschriften: [...document.querySelectorAll("h1, h2")]
.map((x) => x.textContent.trim()).filter(Boolean).slice(0, 12),
karteDa: !!karte && !karte.hidden,
/* SEIT DEM 30.09.2026 IST DER KOPF EIN SCHALTER. Der Name steht
in `.e-block__name`, die Zahl daneben in `.e-block__stand` --
`.e-block__titel` gibt es hier nicht mehr. Die Zeile meldete
`undefined` und haette das stillschweigend weiter getan. */
auftragsblock: auf
? [auf.querySelector(".e-block__name")?.textContent.trim(),
auf.querySelector(".e-block__stand")?.textContent.trim()].join(" ")
: null,
auftragKlappbar: !!auf?.querySelector(".e-block__schalter"),
aufgabenTitel: auf
? [...auf.querySelectorAll(".e-punkt__titel")].map((x) => x.textContent.trim())
: [],
aufgabenLage: auf
? [...auf.querySelectorAll(".e-punkt__wann")].map((x) => x.textContent.trim())
: [],
satz: (document.getElementById("karte-satz")?.textContent || "").trim().slice(0, 160),
punkte: karte ? karte.querySelectorAll(".e-punkt").length : 0,
};
});
console.log(` Ueberschriften: ${w.ueberschriften.join(" | ")}`);
console.log(` Karte sichtbar: ${w.karteDa}, Punktzeilen darin: ${w.punkte}`);
console.log(` Abschnitt: ${JSON.stringify(w.auftragsblock)} `
+ `(einklappbar=${w.auftragKlappbar})`);
console.log(` Seine Aufgaben: ${JSON.stringify(w.aufgabenTitel)}`);
console.log(` Lage dazu: ${JSON.stringify(w.aufgabenLage)}`);
console.log(` Satz: ${w.satz}`);
if (!w.aufgabenTitel.length) {
console.log(" ACHTUNG: In seiner Karte steht nicht, welche Punkte seine "
+ "Aufgaben sind -- genau VanVans Meldung.");
}
await seite.screenshot({ path: "server/mess-vanvan-karte-modi.png", fullPage: true });
console.log(" Bild: server/mess-vanvan-karte-modi.png");
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
await kontext.close();
}
console.log("");
console.log("=== 4. Was VanVan an einem Punkt sieht ===");
{
const { kontext, seite, konsole } = await alsWer(kVan);
await seite.goto(`${BASIS}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
/* Dienes Kachel aufmachen. */
const auf = await seite.evaluate((name) => {
const k = [...document.querySelectorAll("#personen .e-person")]
.find((x) => (x.querySelector(".e-person__name")?.textContent || "").includes(name));
if (!k) return false;
(k.querySelector("button, a") || k).click();
return true;
}, "Diene");
console.log(` Dienes Kachel geoeffnet: ${auf}`);
await seite.waitForTimeout(2000);
const z = await seite.evaluate(() => {
const zeilen = [...document.querySelectorAll(".e-punkt")].slice(0, 6).map((p) => ({
titel: (p.querySelector(".e-punkt__titel")?.textContent || "").trim().slice(0, 34),
zugetragen: p.dataset.zugetragen,
auftrag: (p.querySelector(".e-punkt__auftrag")?.textContent || "").trim(),
/* WAS WIRKLICH AN DER ZEILE STEHT -- ohne die Knopfbeschriftungen
vorher zu kennen. Genau das ist die Frage: Was liest ein
Mensch dort? */
woerter: (p.innerText || "").replace(/\s+/g, " ").trim().slice(0, 150),
gedrueckt: [...p.querySelectorAll("[aria-pressed='true']")]
.map((b) => b.textContent.trim()),
}));
return {
anzahl: document.querySelectorAll(".e-punkt").length,
zeilen,
/* Sieht SIE, dass es bei ihm noch nicht ankommt? */
wartet: [...document.querySelectorAll(".e-punkt__wartet")]
.map((x) => x.textContent.trim()),
/* Und gibt es den Knopf, mit dem sie auf das Zugetragene
eingrenzen kann? */
knoepfe: [...document.querySelectorAll(".e-stufen__knopf")]
.map((k) => `${k.querySelector(".e-stufen__name")?.textContent.trim()}`
+ `=${k.querySelector(".e-stufen__zahl")?.textContent.trim()}`),
};
});
console.log(` ${z.anzahl} Punktzeilen sichtbar. Die ersten sechs:`);
for (const r of z.zeilen) {
console.log(` · ${r.titel.padEnd(36)} zugetragen=${r.zugetragen} `
+ `Knopf=„${r.auftrag}" gedrueckt=${JSON.stringify(r.gedrueckt)}`);
console.log(` „${r.woerter}"`);
}
console.log(` „Er sieht das noch nicht" steht an ${z.wartet.length} Punkten`);
if (z.wartet[0]) console.log(` „${z.wartet[0]}"`);
console.log(` Filterknoepfe: ${z.knoepfe.join(" ")}`);
if (!z.wartet.length) {
console.log(" ACHTUNG: Sie hat bewertet, es kommt beim Modi nicht an, und "
+ "nichts auf ihrem Bildschirm sagt ihr das.");
}
await seite.screenshot({ path: "server/mess-vanvan-karte-leitung.png", fullPage: true });
console.log(" Bild: server/mess-vanvan-karte-leitung.png");
/* ---- WOMIT GEHT DIE KARTE AUF? (01.10.2026) -----------------
Filipe: „mach alles los" -- damit auch: „Zugetragen" ist die
Vorgabe, wenn es etwas zu zeigen gibt.
VORHER KLICKTE DIESE MESSUNG AUF DEN FILTER und zaehlte nach.
Seit er beim Aufmachen schon steht, haette derselbe Klick ihn
AUSgeschaltet -- die Messung haette das Gegenteil gemessen und
trotzdem eine Zahl gemeldet. Genau die Sorte gruener Haken, die
nichts prueft.
Gefragt wird deshalb, was DASTEHT, bevor jemand etwas anfasst. */
const beimAufmachen = await seite.evaluate(() => {
const gedrueckt = [...document.querySelectorAll(".e-stufen__knopf")]
.find((k) => k.getAttribute("aria-pressed") === "true");
return {
filter: gedrueckt?.querySelector(".e-stufen__name")?.textContent.trim() || null,
punkte: document.querySelectorAll(".e-punkt").length,
alleZugetragen: [...document.querySelectorAll(".e-punkt")]
.every((p) => p.dataset.zugetragen === "ja"),
};
});
console.log(` Beim Aufmachen steht der Filter auf „${beimAufmachen.filter}": `
+ `${beimAufmachen.punkte} Punkte, alle zugetragen=${beimAufmachen.alleZugetragen}`);
if (beimAufmachen.filter !== "Zugetragen" || beimAufmachen.punkte !== 2
|| !beimAufmachen.alleZugetragen) {
console.log(" ACHTUNG: Die Karte geht nicht auf dem Zugetragenen auf.");
}
/* GEGENPROBE: „Alle" ist einen Klick entfernt und zeigt wieder alle
68. Ohne sie hiesse „2 Punkte" womoeglich nur, dass es nicht
mehr gibt. */
const zurueck = await seite.evaluate(() => {
const k = [...document.querySelectorAll(".e-stufen__knopf")]
.find((x) => x.querySelector(".e-stufen__name")?.textContent.trim() === "Alle");
if (!k) return null;
k.click();
return true;
});
if (zurueck) {
await seite.waitForTimeout(1200);
const alle = await seite.evaluate(() => document.querySelectorAll(".e-punkt").length);
console.log(` Nach „Alle": ${alle} Punkte`);
if (alle !== 68) {
console.log(` ACHTUNG: „Alle“ zeigt nicht mehr alle 68.`);
}
} else {
console.log(` ACHTUNG: Den Knopf „Alle“ gibt es nicht.`);
}
await seite.screenshot({ path: "server/mess-vanvan-karte-filter.png", fullPage: true });
console.log(" Bild: server/mess-vanvan-karte-filter.png");
/* ==== UND WER NICHTS ZUGETRAGEN HAT? (01.10.2026) ==============
Das ist die Gegenprobe, die der alte Kommentar verlangt: „Ein
Filter, der beim Öffnen schon etwas versteckt, lässt einen
Punkte suchen, die gestern noch da waren." Bei einem Menschen
ohne zugetragene Punkte zeigte „Zugetragen" eine LEERE Karte --
und eine leere Karte sieht aus wie ein Fehler.
VanVan hat in diesem Aufbau nichts zugetragen bekommen; ihre
Karte muss deshalb auf „Alle" aufgehen. */
const beiVanVan = await seite.evaluate(async () => {
const k = [...document.querySelectorAll("#personen .e-person")]
.find((x) => (x.querySelector(".e-person__name")?.textContent || "").includes("VanVan"));
if (!k) return null;
(k.querySelector("button, a") || k).click();
await new Promise((r) => setTimeout(r, 2200));
const gedrueckt = [...document.querySelectorAll(".e-stufen__knopf")]
.find((x) => x.getAttribute("aria-pressed") === "true");
return {
filter: gedrueckt?.querySelector(".e-stufen__name")?.textContent.trim() || null,
punkte: document.querySelectorAll(".e-punkt").length,
};
});
if (!beiVanVan) {
console.log(" KONNTE NICHT NACHSEHEN: VanVans Kachel war nicht zu finden.");
} else {
console.log(` Bei jemandem OHNE Zugetragenes: Filter „${beiVanVan.filter}", `
+ `${beiVanVan.punkte} Punkte`);
if (beiVanVan.filter !== "Alle" || beiVanVan.punkte !== 68) {
console.log(" ACHTUNG: Wer nichts zugetragen hat, bekommt eine leere Karte.");
}
}
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
await kontext.close();
}
/* =====================================================================
5. VANVANS ZWEITE MELDUNG: „Deine Karte kann man nicht einklappen"
„was die Seite unnötig lang zieht und unübersichtlich macht."
IHRE EIGENE KARTE, nicht die eines Modi. Im Bildschirmfoto steht an
jedem Punkt „1 × Läuft" -- eine einzige Antwort. Ueber die rechte
Hand urteilt nur DogFather, also ist bei ihr alles „fertig" und die
Karte entsprechend lang. Ein Modi braucht zwei Stimmen und sieht
deshalb meist gar nichts.
GEMESSEN WIRD DIE HOEHE. „Unnötig lang" ist eine Zahl, keine
Meinung -- und ohne sie laesst sich nachher nicht sagen, ob es
besser geworden ist.
===================================================================== */
console.log("");
console.log("=== 5. VanVans eigene Karte: wie lang wird die Seite? ===");
{
/* Filipe bewertet VanVan durch -- alle Punkte, wie im echten Haus
ueber Monate gewachsen. */
let gesetzt = 0;
for (const pt of ENTWICKLUNG_FREMD) {
const a = await ruf(kDogi, `/workspace/api/entwicklung/person/${idVan}/${pt.schluessel}`,
{ stufe: "laeuft", anlass: "" }, "PUT");
if (a.status === 200) gesetzt++;
}
console.log(` Filipe hat ${gesetzt} von ${ENTWICKLUNG_FREMD.length} Punkten gesetzt.`);
const { kontext, seite, konsole } = await alsWer(kVan, 1280, 1000);
await seite.goto(`${BASIS}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
const m = await seite.evaluate(() => {
const karte = document.getElementById("karte-block");
const r = karte?.getBoundingClientRect();
return {
seitenhoehe: document.documentElement.scrollHeight,
kartenhoehe: r ? Math.round(r.height) : 0,
bloecke: karte ? karte.querySelectorAll(".e-block").length : 0,
zeilen: karte ? karte.querySelectorAll(".e-punkt").length : 0,
/* Gibt es ueberhaupt einen Schalter zum Zuklappen? */
schalter: karte ? karte.querySelectorAll(".e-block__schalter").length : 0,
alleKnopf: karte ? karte.querySelectorAll(".e-bloecke__alle").length : 0,
};
});
console.log(` Seite gesamt: ${m.seitenhoehe} px`);
console.log(` Davon „Deine Karte": ${m.kartenhoehe} px `
+ `(${m.bloecke} Abschnitte, ${m.zeilen} Zeilen)`);
console.log(` Schalter zum Zuklappen: ${m.schalter}, „Alle"-Knopf: ${m.alleKnopf}`);
if (!m.schalter) {
console.log(" ACHTUNG: Kein einziger Abschnitt laesst sich zuklappen -- "
+ "genau VanVans Meldung.");
} else {
/* ZUKLAPPEN UND NACHMESSEN. Ohne diesen Griff hiesse „es gibt
Schalter" nur, dass es Schalter gibt. */
const nach = await seite.evaluate(() => {
const k = document.querySelector("#karte-block .e-bloecke__alle");
if (k && k.textContent.includes("zuklappen")) k.click();
else document.querySelectorAll("#karte-block .e-block__schalter")
.forEach((s) => { if (s.getAttribute("aria-expanded") === "true") s.click(); });
const r = document.getElementById("karte-block")?.getBoundingClientRect();
return {
kartenhoehe: r ? Math.round(r.height) : 0,
sichtbar: [...document.querySelectorAll("#karte-block .e-punkt")]
.filter((z) => z.getBoundingClientRect().height > 0).length,
};
});
console.log(` Zugeklappt: ${nach.kartenhoehe} px `
+ `(${nach.sichtbar} Zeilen noch sichtbar)`);
const gespart = m.kartenhoehe - nach.kartenhoehe;
const anteil = Math.round(gespart / Math.max(1, m.kartenhoehe) * 100);
console.log(` Gespart: ${gespart} px (${anteil} %)`);
/* EIN ANTEIL UND KEINE FESTE PIXELZAHL. Wie hoch die Karte ist,
haengt am Bestand -- 68 Punkte heute, vielleicht 90 im
naechsten Jahr. Eine feste Grenze waere die Rechnung von
gestern; „mehr als die Haelfte weg" gilt auch dann noch. */
if (anteil < 50 || nach.sichtbar > 0) {
console.log(" ACHTUNG: Zuklappen bringt kaum etwas "
+ `(${anteil} %, ${nach.sichtbar} Zeilen bleiben stehen).`);
}
}
if (m.schalter && !m.alleKnopf) {
console.log(" ACHTUNG: Es gibt Schalter, aber keinen Knopf, der alle "
+ "auf einmal zuklappt -- bei sechs Abschnitten ist das sechsmal tippen.");
}
await seite.screenshot({ path: "server/mess-vanvan-karte-eigene.png", fullPage: true });
console.log(" Bild: server/mess-vanvan-karte-eigene.png");
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest im Temp */ }
console.log("");
console.log("Fertig.");
process.exit(0);
+157
View File
@@ -0,0 +1,157 @@
/* =====================================================================
DER NOTIZBLOCK — die Tabelle und die Regeln dahinter (26.09.2026)
=====================================================================
Filipe: „für die modis, rechte und linke hand und dogfather eine
kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das
auch wie ein notizblock erstellst."
---------------------------------------------------------------------
DIE EINE ENTSCHEIDUNG, DIE ALLES ANDERE NACH SICH ZIEHT
EIN NOTIZBLOCK GEHÖRT EINEM MENSCHEN. Nicht dem Team, nicht dem
Haus, nicht der Leitung. Wer seine Gedanken aufschreibt, schreibt
anders, wenn jemand mitlesen könnte — und dann ist es kein Block
mehr, sondern ein Bericht.
Deshalb gibt es hier KEINE Rolle, die mehr sieht. `siehtAlles()`
wird in diesem Modul nicht gefragt, und das ist Absicht: DogFather
sieht die Notizen eines Modis so wenig wie umgekehrt. Jede Abfrage
hat `person_id = ?` fest eingebaut, nicht als Bedingung, die man
weglassen könnte.
Das ist dieselbe Linie wie bei `befinden.html` („Ein paar Fragen
über dich, nur für dich. Niemand aus dem Team sieht deine
Antworten") — dort steht sie schon im Haus, hier wird sie
fortgesetzt.
---------------------------------------------------------------------
WARUM ON DELETE CASCADE UND NICHT SET NULL
Bei Aufgaben ist es richtig, den Namen zu entfernen und die Sache
stehen zu lassen: Sie gehört dem Projekt. Eine Notiz gehört
niemandem sonst. Wird ein Zugang gelöscht, hat die Zeile keinen
Leser mehr — sie wäre ein Text ohne Menschen, den irgendwann jemand
in der Datenbank findet. Also geht sie mit.
---------------------------------------------------------------------
DER PAPIERKORB IST EIN DATUM, KEINE ZWEITE TABELLE
`weg_am` gesetzt heißt „liegt im Papierkorb, seit …". Eine zweite
Tabelle wäre dieselbe Struktur noch einmal, und beim nächsten neuen
Feld hätte eine davon es nicht. Geräumt wird über die
Aufbewahrungsliste (workspace-aufbewahrung.js) — dort steht die
Frist, dort wird sie eingelöst. Ein Versprechen ohne Eintrag dort
ist kein Versprechen; genau das ist dem Support am 24.09. passiert.
===================================================================== */
/** Wie lange etwas im Papierkorb liegt, bevor es wirklich weg ist.
*
* Dreißig Tage. Kürzer wäre eine Falle für den, der drei Wochen im
* Urlaub war; länger würde aus dem Papierkorb ein zweites Archiv,
* und dann räumt ihn niemand mehr aus. */
export const PAPIERKORB_TAGE = 30;
/** Wie lang eine einzelne Notiz werden darf.
*
* Zwanzigtausend Zeichen sind rund acht eng beschriebene Seiten.
* Die Zahl steht nicht gegen den Benutzer, sondern gegen ein
* hängendes Skript: Ohne Grenze schreibt ein Fehler in einer Schleife
* die Platte voll, und das merkt man erst, wenn nichts mehr geht. */
export const TEXT_MAX = 20000;
/** Wie viele Blätter ein Block hat.
*
* Dreihundert. Wer so viele hat, sucht ohnehin über die Suche und
* nicht über die Liste — und auch diese Zahl steht gegen ein Skript,
* nicht gegen einen Menschen. */
export const BLAETTER_MAX = 300;
/* =====================================================================
DIE PAPIERFARBEN
Sechs, und keine davon ist ein Papierweiß. Auf einer dunklen Wand
wäre ein cremefarbenes Blatt eine Lampe — die Hausregel heißt
augenschonend, und sie gilt auch für ein Blatt Papier.
Die Farbe ist deshalb die TINTE: Rand, Linien und Kante des Blattes
tragen sie, die Fläche bleibt dunkel. Das ist näher an einer
schwarzen Kladde mit farbigem Lesebändchen als an einem Klebezettel
— und man kann sechs Blätter nebeneinander auseinanderhalten, ohne
dass eines blendet.
ALLE SECHS HALTEN 4,5:1 GEGEN DEN DUNKLEN GRUND (#0a121e) —
gemessen, nicht geschätzt, und nachgeprüft in pruef-notizen. Sie
tragen ja auch Text.
WARUM NICHT DIE 46 KACHELFARBEN: Die sind gegeneinander
auseinandergerechnet, damit man 46 Kacheln unterscheidet. Hier
müssen sechs auseinandergehen, und dafür sind größere, ruhigere
Abstände besser als sechs Nachbarn aus einer vollen Palette. */
export const FARBEN = [
{ name: "block", wort: "Block", hex: "#9cde90" },
{ name: "sonne", wort: "Sonne", hex: "#e8bd63" },
{ name: "rosa", wort: "Rosa", hex: "#f2a0c0" },
{ name: "himmel", wort: "Himmel", hex: "#86c5f5" },
{ name: "flieder", wort: "Flieder", hex: "#c3a6f2" },
{ name: "minze", wort: "Minze", hex: "#7ee0c4" },
];
export const FARB_NAMEN = FARBEN.map((f) => f.name);
/**
* Legt die Tabelle an. Wird aus workspace.js gerufen, nachdem
* `personen` steht — die Notiz hängt daran.
*
* ACHTUNG, EIN GEGEN-APOSTROPH IN DIESEM TEXT BEENDET DAS
* TEMPLATE-LITERAL. Das hat in diesem Haus schon zweimal den Start
* gekostet (support-tabellen.js, workspace.js). Deshalb steht in den
* Kommentaren hier drin kein einziger.
*/
export function notizTabellen(d) {
d.exec(`
CREATE TABLE IF NOT EXISTS notizen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
/* CASCADE: Eine Notiz ohne ihren Menschen ist ein Text, den
niemand lesen darf und niemand vermisst. */
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
/* DER GANZE TEXT IN EINEM FELD -- Titel, Haken, Absaetze.
Eine eigene Titelspalte waere die naheliegende Loesung und
die falsche: Dann muesste die Oberflaeche zwei Eingabefelder
haben, und ein Notizblock mit Formular ist kein Notizblock
mehr. Die erste Zeile IST der Titel, und zwar dort, wo sie
hingehoert -- im Text. */
text TEXT NOT NULL DEFAULT '',
farbe TEXT NOT NULL DEFAULT 'block',
/* Angeheftet: bleibt oben. Als Zahl und nicht als Datum -- hier
geht es um ein Ja oder Nein, und ein Datum waere ein Feld,
das etwas anderes verspricht, als es haelt. */
angeheftet INTEGER NOT NULL DEFAULT 0,
erstellt TEXT NOT NULL,
geaendert TEXT NOT NULL,
/* Im Papierkorb seit. NULL heisst: auf dem Block. */
weg_am TEXT
);
/* Die eine Frage, die dieses Modul stellt: „meine Blaetter, die
angehefteten zuerst, danach das zuletzt Angefasste". Der Index
beantwortet sie in genau dieser Reihenfolge. */
CREATE INDEX IF NOT EXISTS idx_notizen_person
ON notizen (person_id, weg_am, angeheftet DESC, geaendert DESC);
/* Und die Frage des Aufraeumers: „was liegt lange genug im
Papierkorb?" */
CREATE INDEX IF NOT EXISTS idx_notizen_weg
ON notizen (weg_am);
`);
/* DIE FARBPRUEFUNG STEHT NICHT IN DER TABELLE.
Ein CHECK (farbe IN (...)) waere die naheliegende Stelle -- und
die, an der die Liste ein zweites Mal steht. Waechst FARBEN oben
um eine Farbe, muesste die Tabelle umgebaut werden, damit ein
Wert erlaubt wird, der im Programm laengst gilt. Genau diese
Sorte Abschrift hat am 11.09. drei Spalten gekostet.
Geprueft wird deshalb an der Route, gegen FARB_NAMEN -- eine
Liste, die niemand pflegen muss, weil sie abgeleitet ist. */
}
+55 -3
View File
@@ -49,6 +49,48 @@ notbremse(480_000, "pruef-abbrechen-optik");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
/* ==== DER ANZEIGENAME FUER „review" -- ABGELESEN, NICHT GETIPPT =====
Er steht in `STATUS_NAME` in workspace/assets/js/aufgaben.js und
hiess bis zum 20.09.2026 „Review", seither „Zur Freigabe". Diese
Pruefung hatte ihn abgeschrieben und war seitdem rot.
GELESEN WIRD DIE DATEI, nicht importiert: aufgaben.js ist ein
Browser-Skript (eine IIFE ohne Export) -- ein `import` bekaeme dort
nichts. Ein Auszug aus dem Quelltext ist der einzige Weg, der ohne
eine zweite Wahrheit auskommt.
FINDET ER NICHTS, BRICHT DIE PRUEFUNG AB. Ein stiller Rueckfall auf
„Review" waere genau der Zustand, der hier gerade repariert wird:
ein Wort, das niemand mehr pflegt. */
const WORT_REVIEW = await (async () => {
const { readFileSync } = await import("node:fs");
const { fileURLToPath } = await import("node:url");
const { dirname, join: verbinde } = await import("node:path");
const hier = dirname(fileURLToPath(import.meta.url));
const quelle = readFileSync(
verbinde(hier, "..", "workspace", "assets", "js", "aufgaben.js"), "utf8");
/* GESUCHT WIRD IN STATUS_WORT, nicht irgendwo. Der erste Anlauf
nahm das erste `review:` der Datei -- und das gehoert einer
Uebergangstabelle (`review: 'erledigt'`, was der naechste
Schritt ist, nicht der Name). Die Pruefung las dadurch
"erledigt" und blieb rot. Ein Ausdruck, der die falsche
Stelle trifft, ist schlimmer als keiner: Er sieht aus wie
eine Ableitung. */
/* SIE HEISST STATUS_NAME, nicht STATUS_WORT -- die zweite
heisst so in entwicklung.js. Zwei Dateien, zwei Namen fuer
dieselbe Tafel; nachgesehen statt erinnert. */
const tafel = /STATUS_NAME\s*=\s*{([^}]*)}/.exec(quelle);
const treffer = tafel && /review:\s*'([^']+)'/.exec(tafel[1]);
if (!treffer) {
console.error("ABBRUCH: In aufgaben.js steht kein Anzeigename fuer `review`. "
+ "Wurde STATUS_NAME umbenannt? Ohne ihn prueft diese Datei nichts.");
process.exit(3);
}
return treffer[1];
})();
console.log(` -- Anzeigename fuer "review": "${WORT_REVIEW}" (aus aufgaben.js gelesen)`);
const PROTOKOLL = join(process.cwd(), "pruef-abbrechen-optik-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
@@ -102,7 +144,7 @@ const browser = await pw.chromium.launch();
genauso aus wie "der Server antwortet nicht". */
const konsole = [];
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } });
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
const seite = await ctx.newPage();
seite.on("pageerror", (f) => konsole.push(`pageerror: ${f.message}`));
seite.on("console", (m) => {
@@ -172,7 +214,17 @@ melde("\n=== Der Dialog verlangt einen Grund ===");
/* Sagt er, was passiert -- und was NICHT passiert? Die Sorge bei so
einem Knopf ist immer, ob etwas verloren geht. */
const was = await seite.$eval("#abbruch-was", (e) => e.textContent);
ok(/nicht gelöscht/i.test(was) && /Review/.test(was),
/* „REVIEW" HEISST SEIT DEM 20.09.2026 „ZUR FREIGABE" (nachgezogen
25.09.2026). Die Oberflaeche wurde umbenannt, diese Pruefung
nicht -- sie war seither rot, ohne dass etwas kaputt war, und
eine Warnung, die immer kommt, wird ueberlesen.
GEPRUEFT WIRD GEGEN DIE EINE QUELLE, nicht gegen ein zweites
abgeschriebenes Wort: `STATUS_WORT` in aufgaben.js ist die Stelle,
an der der Anzeigename steht. Wird er morgen wieder geaendert,
zieht diese Zeile von selbst mit -- und wer ihn aendert, ohne
dass der Dialog mitgeht, wird hier rot. */
ok(/nicht gelöscht/i.test(was) && new RegExp(WORT_REVIEW).test(was),
`er erklärt es: "${was.trim().slice(0, 95)}…"`);
/* Ohne Grund abschicken -- der Dialog muss offen bleiben. */
@@ -236,7 +288,7 @@ melde("\n=== Der Bereich 'Abgebrochen' ===");
ok(/Kampagne verschoben/.test(grund), `der Grund steht dran: "${grund}"`);
const vorher = await seite.$eval(".abbruch-karte__vorher", (e) => e.textContent.trim());
ok(vorher === "Review", `und in welchem Status sie war: "${vorher}"`);
ok(vorher === WORT_REVIEW, `und in welchem Status sie war: "${vorher}"`);
const wer = await seite.$eval(".abbruch-karte__wer", (e) => e.textContent.trim());
ok(/Filipe/.test(wer), `und wer es war: "${wer}"`);
+676
View File
@@ -0,0 +1,676 @@
/* =====================================================================
DIE ZAHL AUF DEM APP-SYMBOL (30.09.2026)
Filipe: „ich will dass du die benarichtigungen perfektionierst …
und das sehr hochwertig."
Das Abzeichen ist die kleine Zahl, die auf dem Startbildschirm am
Symbol der installierten App klebt. Es fehlte ganz — `setAppBadge`
kam im ganzen Haus nicht ein einziges Mal vor.
---------------------------------------------------------------------
WARUM ES UNGELESENE NACHRICHTEN ZÄHLT UND NICHTS SONST
Ein Abzeichen muss WEGGEHEN können. Ungelesene Nachrichten
verschwinden, sobald man sie liest. Eine überfällige Aufgabe
verschwindet nicht dadurch, dass man die App öffnet — eine Zahl,
die dauerhaft dasteht, ist nach drei Tagen keine Auskunft mehr,
sondern ein Fleck. Dann übersieht man auch die, die etwas bedeutet.
Dieselbe Überlegung wie bei der Warnung, die immer kommt.
Deshalb ist Abschnitt 3 der wichtigste in dieser Datei: Er misst
nicht, dass die Zahl STEIGT, sondern dass sie wieder FÄLLT.
---------------------------------------------------------------------
WAS HIER GEMESSEN WIRD — UND ZWAR AM ECHTEN WEG
Ein nachgebauter Browser meldet ein Gerät an, behält seinen
privaten Schlüssel, und ein nachgebauter Push-Dienst nimmt die
Nachricht entgegen. Aufgemacht wird sie mit dem Schlüssel des
Geräts — also genau so, wie es auf Filipes Handy passiert.
Ohne das Aufmachen wäre die Prüfung wertlos: Der Inhalt ist
verschlüsselt, und `benachrichtige` gibt die Zahl nicht zurück. Man
sähe nur, DASS etwas verschickt wurde, nicht WAS darin steht.
---------------------------------------------------------------------
GEGENPROBEN (Abschnitt 4)
Ohne sie hieße „die Zahl ist da" nur, dass irgendeine Zahl da ist:
* Eine Benachrichtigung OHNE Zahl darf auch keine mitschicken.
Sonst löschte eine Aufgabenerinnerung das Abzeichen des Chats.
* Etwas, das keine Zahl ist, darf nicht durchrutschen.
Aufrufen mit: node server/pruef-abzeichen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
import { pushAufmachen } from "./helfer-push-aufmachen.mjs";
import { jsOhneKommentar } from "./helfer-ohne-kommentar.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const WURZEL = join(HIER, "..");
const PORT = await eigenerPort(import.meta, "pruef-abzeichen");
const DIENST = await eigenerPort(import.meta, "pruef-abzeichen (Push-Dienst)", 1);
const ordner = mkdtempSync(join(tmpdir(), "ws-abz-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
/* ---- KEINE RUHEZEIT, UND ZWAR HERGESTELLT STATT GEHOFFT ----------
`chat_nachricht` trägt keine eigene Ruheregel, gilt also als
„immer": zwischen 22 und 7 Uhr geht nichts hinaus. Ohne diese zwei
Zeilen wäre die Prüfung eine Zeitbombe — grün am Nachmittag, rot um
halb zwölf nachts, und beides ohne eine Aussage über die Software.
PUSH_RUHE_AB = 24 gesperrt ab Stunde 24, die es nicht gibt
PUSH_RUHE_BIS = 0 und frei ab Stunde 0
Damit ist `s >= 24 || s < 0` zu jeder Tageszeit falsch. Die Uhr
wird nicht gefälscht, nur die Regel klar gesetzt — dieselbe Technik
wie in pruef-push-ziel, dort andersherum. */
process.env.PUSH_RUHE_AB = "24";
process.env.PUSH_RUHE_BIS = "0";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(120_000, "pruef-abzeichen");
await new Promise((r) => setTimeout(r, 900));
/* Eigene Zuhörer NEBEN denen aus index.js — `process.on` ergänzt, es
ersetzt nicht. Sonst notiert das Auffangnetz des Betriebs meinen
Absturz nur, und der Lauf sieht von außen aus wie „läuft noch". */
process.on("uncaughtException", (f) => {
console.error("ABSTURZ:", f?.stack || f); process.exit(1);
});
process.on("unhandledRejection", (f) => {
console.error("ABSTURZ (Versprechen):", f?.stack || f); process.exit(1);
});
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0;
const ok = (b, t) => {
geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++;
};
/* DER DRITTE AUSGANG. Eine Prüfung, die ihre Voraussetzung nicht hat,
muss das SAGEN — „grün" und „rot" sind beide gelogen. */
const konntenicht = (t) => {
geprueft++; console.log(" ?? konnte nicht nachsehen: " + t); fehler++;
};
/* ---- Der nachgebaute Push-Dienst --------------------------------------
Er hebt die Rümpfe auf, damit wir hineinsehen können. Ein echter
Dienst kann das NICHT (das beweist pruef-push-weg) — hier geht es
nur deshalb, weil wir gleich auch den privaten Schlüssel des
„Geräts" besitzen. */
const pakete = [];
const dienst = createServer((q, a) => {
const teile = [];
q.on("data", (t) => teile.push(t));
q.on("end", () => { pakete.push(Buffer.concat(teile)); a.writeHead(201); a.end(); });
});
await new Promise((r) => dienst.listen(DIENST, "127.0.0.1", r));
const schliessen = () => new Promise((f) => {
if (!dienst.listening) return f();
dienst.close(() => f());
});
/* ---- Bestand ----------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createECDH } = await import("node:crypto");
/* Einmal anklopfen, damit index.js die Datenbank angelegt hat. */
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }),
}).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare(`INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)
VALUES (?,?,?,?,32768,1,?)`).run(name, rolle, hash, salz, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001");
/* DAS GERÄT — und wir behalten seinen privaten Schlüssel.
Ein erfundener Schlüssel würde beim Verschlüsseln scheitern; dann
wäre „nichts angekommen" die Antwort auf eine ganz andere Frage. */
const geraet = createECDH("prime256v1");
geraet.generateKeys();
const geheim = randomBytes(16);
d.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt)
VALUES (?,?,?,?,?)`)
.run(idAyla, `http://127.0.0.1:${DIENST}/push/${idAyla}`,
geraet.getPublicKey().toString("base64url"), geheim.toString("base64url"), jetzt);
/* ---- Anmelden an der Crew-Wand ------------------------------------------
`fetch` setzt den Host-Kopf selbst; für die Wand braucht es
node:http. Danach genügt der Keks. */
const { request: httpAnfrage } = await import("node:http");
function anmeldenAn(rolle, code, wand = "crew.dogfather-universe.com") {
return new Promise((fertig) => {
const rumpf = JSON.stringify({ rolle, code });
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(rumpf),
Host: wand,
},
}, (antwort) => {
antwort.resume();
antwort.on("end", () => fertig({
ok: antwort.statusCode === 200,
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
wand,
}));
});
a.on("error", () => fertig({ ok: false, keks: "", wand }));
a.end(rumpf);
});
}
/** Ein Aufruf MIT Wand — und warum das hier nicht `fetch` sein darf.
*
* Das Haus einer Sitzung kommt aus dem Host-Feld (sitzungLesen liest
* `req.get("host")`), und `fetch` setzt dieses Feld selbst aus der
* Adresse; ein mitgegebenes ignoriert es. Über 127.0.0.1 ist das
* Haus damit weder crew noch agentur — `hausWo` gibt „1=1" zurück,
* und Abschnitt 7 hätte die Hausregel an einer Verbindung gemessen,
* die gar kein Haus hat.
*
* Genau das ist beim ersten Anlauf passiert: Die Prüfung blieb rot,
* nachdem die Regel schon eingebaut war. Ein Messfehler, der wie ein
* Befund aussieht — und andersherum hätte er einen echten Befund
* verdeckt. */
function rufe(sitzung, weg, koerper, methode = "GET") {
const rumpf = koerper === undefined ? undefined : JSON.stringify(koerper);
return new Promise((fertig) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: weg, method: methode,
headers: {
"Content-Type": "application/json",
Cookie: sitzung.keks,
Host: sitzung.wand,
...(rumpf === undefined ? {} : { "Content-Length": Buffer.byteLength(rumpf) }),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (t) => teile.push(t));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
fertig({
ok: antwort.statusCode >= 200 && antwort.statusCode < 300,
status: antwort.statusCode,
json: () => { try { return JSON.parse(text); } catch { return {}; } },
});
});
});
a.on("error", () => fertig({ ok: false, status: 0, json: () => ({}) }));
a.end(rumpf);
});
}
const dogi = await anmeldenAn("admin", "CODE-DOGI-0001");
const ayla = await anmeldenAn("modi", "CODE-TEAM-0001");
if (!dogi.ok || !ayla.ok) {
konntenicht("die Anmeldung hat nicht geklappt — ohne sie ist nichts zu messen");
await schliessen(); d.close();
rmSync(ordner, { recursive: true, force: true });
process.exit(1);
}
/* Ein Zweiergespräch. Ayla schaut NICHT zu (kein Ereignisstrom offen)
— nur dann entsteht überhaupt eine Benachrichtigung. */
const raumId = ((await rufe(dogi, "/workspace/api/chat/direkt",
{ mit: idAyla }, "POST")).json()).raum_id;
/** Schickt eine Nachricht und wartet, bis das Paket beim Push-Dienst
* liegt — auf das ERGEBNIS, nicht auf die Uhr. `chatEreignis` ruft
* die Benachrichtigung mit `void` auf; die Antwort der Route ist
* also schon da, bevor irgendetwas verschickt wurde. */
async function schreibenUndWarten(text, frist = 8000) {
const vorher = pakete.length;
const a = await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text }, "POST");
if (!a.ok) return { fehlgeschlagen: `HTTP ${a.status}` };
const bis = Date.now() + frist;
while (pakete.length === vorher && Date.now() < bis) {
await new Promise((r) => setTimeout(r, 60));
}
if (pakete.length === vorher) return { fehlgeschlagen: `nach ${frist / 1000} s nichts angekommen` };
return { paket: pakete[pakete.length - 1] };
}
/** Das letzte Paket aufmachen und als Objekt zurückgeben. */
const inhalt = (paket) => JSON.parse(pushAufmachen(paket, geraet, geheim));
/* =======================================================================
1. DIE ZAHL KOMMT ÜBERHAUPT MIT
======================================================================= */
console.log("");
console.log("=== 1. Die Zahl reist in der Benachrichtigung mit ===");
let eins = null;
{
const r = await schreibenUndWarten("Erste Nachricht");
if (r.fehlgeschlagen) {
konntenicht(`die erste Nachricht kam nicht am Push-Dienst an (${r.fehlgeschlagen})`);
} else {
eins = inhalt(r.paket);
ok(typeof eins.titel === "string" && eins.titel.length > 0,
`die Benachrichtigung ist lesbar angekommen ("${eins.titel}")`);
ok(eins.zahl === 1,
`und trägt die Zahl 1 für das App-Symbol (zahl=${JSON.stringify(eins.zahl)})`);
}
}
/* =======================================================================
2. SIE ZÄHLT MIT
======================================================================= */
console.log("");
console.log("=== 2. Bei der zweiten Nachricht steht 2 darauf ===");
{
const r = await schreibenUndWarten("Zweite Nachricht");
if (r.fehlgeschlagen) {
konntenicht(`die zweite Nachricht kam nicht an (${r.fehlgeschlagen})`);
} else {
/* OHNE DIESE ZEILE beweist Abschnitt 1 nur, dass irgendeine Eins
mitkommt — eine fest eingebaute täte dasselbe. */
ok(inhalt(r.paket).zahl === 2,
`die Zahl wächst mit (zahl=${JSON.stringify(inhalt(r.paket).zahl)})`);
}
}
/* =======================================================================
3. UND SIE GEHT WIEDER WEG — das Wichtigste an der ganzen Sache
======================================================================= */
console.log("");
console.log("=== 3. Wer liest, wird das Abzeichen los ===");
{
const letzte = d.prepare("SELECT MAX(id) AS n FROM chat_nachrichten WHERE raum_id = ?")
.get(raumId)?.n;
const a = await rufe(ayla, `/workspace/api/chat/raeume/${raumId}/gelesen`,
{ bis: letzte }, "PUT");
ok(a.ok, `Ayla liest bis Nachricht ${letzte} (HTTP ${a.status})`);
const z = (await rufe(ayla, "/workspace/api/chat/ungelesen")).json();
ok(z.ungelesen === 0,
`danach steht die Zahl auf null — das Abzeichen verschwindet (${z.ungelesen})`);
/* UND SIE FÄNGT WIEDER BEI EINS AN. Das trennt „die Zahl wird
zurückgesetzt" von „die Zahl wird nur nicht mehr geschickt". */
const r = await schreibenUndWarten("Dritte Nachricht");
if (r.fehlgeschlagen) {
konntenicht(`die dritte Nachricht kam nicht an (${r.fehlgeschlagen})`);
} else {
ok(inhalt(r.paket).zahl === 1,
`die nächste Nachricht zählt wieder ab eins (zahl=${JSON.stringify(inhalt(r.paket).zahl)})`);
}
}
/* =======================================================================
4. GEGENPROBEN — was KEINE Zahl tragen darf
======================================================================= */
console.log("");
console.log("=== 4. Gegenproben ===");
{
const push = await import("./workspace-push.js");
/* EINE MELDUNG OHNE ZAHL DARF AUCH KEINE MITSCHICKEN.
Das ist die Zeile, an der die ganze Überlegung hängt: Käme bei
einer Aufgabenerinnerung `zahl: 0` mit, würde sie das Abzeichen
löschen — und die ungelesenen Nachrichten wären vom
Startbildschirm verschwunden, ohne dass jemand sie gelesen hat. */
const vorher = pakete.length;
await push.benachrichtige(idAyla, "test",
{ titel: "Erinnerung", text: "Etwas ganz anderes" });
if (pakete.length === vorher) {
konntenicht("die Probe-Benachrichtigung kam nicht an");
} else {
const o = inhalt(pakete[pakete.length - 1]);
ok(!("zahl" in o),
`eine Meldung ohne Zahl trägt auch keine (${JSON.stringify(Object.keys(o))})`);
}
/* UND ETWAS, DAS KEINE ZAHL IST, RUTSCHT NICHT DURCH. `Number
.isFinite` und nicht `typeof === "number"`: NaN wäre sonst eine
Zahl, und `setAppBadge(NaN)` ist keine Auskunft. */
const vorher2 = pakete.length;
await push.benachrichtige(idAyla, "test",
{ titel: "Krumm", text: "Probe", zahl: Number.NaN });
if (pakete.length === vorher2) {
konntenicht("die zweite Probe kam nicht an");
} else {
ok(!("zahl" in inhalt(pakete[pakete.length - 1])),
"eine Zahl, die keine ist (NaN), kommt gar nicht erst mit");
}
/* DIE GEGENPROBE ZUR GEGENPROBE. Ohne sie hieße „keine zahl dabei"
womöglich nur, dass NIE eine mitgeht — dann wären die drei
Abschnitte darüber grün gewesen, weil sie alle dasselbe Nichts
gemessen hätten. */
const vorher3 = pakete.length;
await push.benachrichtige(idAyla, "test",
{ titel: "Gerade", text: "Probe", zahl: 7 });
if (pakete.length === vorher3) {
konntenicht("die dritte Probe kam nicht an");
} else {
ok(inhalt(pakete[pakete.length - 1]).zahl === 7,
"eine echte Zahl geht dagegen mit — die Messung unterscheidet also");
}
}
/* =======================================================================
5. WER DIE ZAHL IM BROWSER SETZT
======================================================================= */
console.log("");
console.log("=== 5. Die eine Stelle in der Kopfleiste ===");
{
/* ==== GEMESSEN WIRD AM KOMMENTARFREIEN TEXT (30.09.2026) =======
Beim ersten Lauf war Abschnitt 6 rot — zu Unrecht. Die Suche
nahm die ERSTE Fundstelle von `setAppBadge`, und die steht im
Kommentar darüber („`setAppBadge` gibt es nicht überall"). Sie
hat damit den Kommentar vermessen, nicht den Code.
Dieselbe Falle wie in pruef-bewegung, dort unter „ein Beispiel
im Kommentar zählt nicht". Sie fällt nur auf, wenn sie zufällig
rot wird — andersherum hätte ein Kommentar eine fehlende Zeile
jahrelang gedeckt. */
const kopf = jsOhneKommentar(
readFileSync(join(WURZEL, "workspace", "assets", "js", "kopf.js"), "utf8"));
/* SIE MUSS IN `chatZahlZeigen` STEHEN und nirgends sonst. Diese
Funktion wird beim Laden, aus dem Ereignisstrom und aus dem Chat
gerufen — eine zweite Zählung wäre die, die irgendwann abweicht. */
const block = kopf.slice(kopf.indexOf("window.chatZahlZeigen"));
const ende = block.indexOf("\n };");
const rumpf = ende > 0 ? block.slice(0, ende) : "";
ok(rumpf.includes("setAppBadge"),
"das Abzeichen wird in chatZahlZeigen() gesetzt");
ok(rumpf.includes("clearAppBadge"),
"und bei null wieder gelöscht — sonst bliebe es für immer stehen");
/* KEINE ZWEITE STELLE. `setAppBadge` darf im ganzen Haus nur dort
und im Service Worker vorkommen. */
const zweite = [];
const { readdirSync } = await import("node:fs");
const suche = (verz) => {
for (const e of readdirSync(verz, { withFileTypes: true })) {
const p = join(verz, e.name);
if (e.isDirectory()) { suche(p); continue; }
if (!e.name.endsWith(".js")) continue;
const t = jsOhneKommentar(readFileSync(p, "utf8"));
if (!/setAppBadge|clearAppBadge/.test(t)) continue;
const kurz = p.slice(WURZEL.length + 1).replace(/\\/g, "/");
if (kurz !== "workspace/assets/js/kopf.js" && kurz !== "workspace/sw.js") {
zweite.push(kurz);
}
}
};
suche(join(WURZEL, "workspace"));
ok(zweite.length === 0,
zweite.length === 0
? "und es gibt keine zweite Stelle, die das Abzeichen anfasst"
: `ZWEITE STELLE: ${zweite.join(", ")}`);
/* GEGENPROBE: Die Suche findet überhaupt etwas — sonst bewiese die
Null oben nur, dass sie nichts liest. */
ok(/setAppBadge/.test(kopf),
"die Suche findet setAppBadge in kopf.js (sie liest also wirklich)");
}
/* =======================================================================
6. DER SERVICE WORKER — für die geschlossene App
======================================================================= */
console.log("");
console.log("=== 6. Wenn die App zu ist ===");
{
const sw = jsOhneKommentar(readFileSync(join(WURZEL, "workspace", "sw.js"), "utf8"));
ok(/Number\.isFinite\(d\.zahl\)/.test(sw),
"der Service Worker rührt das Abzeichen nur an, wenn eine Zahl dabei ist");
/* KEIN BEDINGUNGSLOSES LÖSCHEN. Genau das wäre der stille Schaden:
Jede beliebige Meldung nähme dem Chat sein Abzeichen weg. */
const zeilen = sw.split("\n").filter((z) => /clearAppBadge/.test(z));
ok(zeilen.length > 0 && zeilen.every((z) => /else/.test(z)),
zeilen.length
? `gelöscht wird nur im else-Zweig von „Zahl > 0" (${zeilen.length} Stelle(n))`
: "KEIN clearAppBadge gefunden — dann geht das Abzeichen nie weg");
/* Und es darf nichts zum Absturz bringen: Firefox kennt die
Funktion nicht, Safari nur für installierte Apps. Eine Zugabe,
die die Benachrichtigung mitreißt, wäre ein schlechtes Geschäft. */
/* AB DER WACHE GEMESSEN, nicht ab irgendeinem Vorkommen. `indexOf`
nimmt das erste — und das war beim ersten Lauf das im Kommentar. */
const stelle = sw.indexOf("Number.isFinite(d.zahl)");
const umher = sw.slice(stelle, stelle + 400);
ok(stelle >= 0 && /try\s*\{/.test(umher) && /catch/.test(umher),
"und der Versuch ist abgesichert — ein Gerät ohne Abzeichen bekommt trotzdem seine Meldung");
}
/* =======================================================================
7. DIE ZAHL GEHOERT ZU EINEM HAUS
Seit dem 24.09.2026 sieht jedes Haus nur seinen eigenen Bestand --
auch DogFather, der in beiden arbeitet. Die Raumliste haelt sich
daran (`hausWo(req.person, "r")`), die Zaehlung der ungelesenen
Nachrichten wurde nie danach gefragt.
Das faellt jetzt ins Gewicht: Bis heute war es eine Zahl an einem
Knopf. Ab heute steht dieselbe Zahl auf dem Startbildschirm -- und
eine Zahl, die etwas Falsches zeigt, ist schlimmer als keine, weil
man ihr glaubt.
======================================================================= */
console.log("");
console.log("=== 7. Jede Adresse zaehlt nur ihr eigenes Haus ===");
{
const idMana = anlegen("Cigdem", "manager", "CODE-MANA-0001");
const mana = await anmeldenAn("manager", "CODE-MANA-0001",
"workspace.dogfather-universe.com");
if (!mana.ok) {
konntenicht("die Managerin kam nicht an der Agenturwand herein");
} else {
/* Ein Gespraech im ANDEREN Haus. `direktRaum` traegt das Haus der
Person ein, die es anlegt -- hier also "agentur". */
const raumA = ((await rufe(mana, "/workspace/api/chat/direkt",
{ mit: idDogi }, "POST")).json()).raum_id;
ok(Number.isInteger(raumA), `die Agentur legt ein Gespraech an (Raum ${raumA})`);
const vorher = (await rufe(dogi, "/workspace/api/chat/ungelesen")).json();
await rufe(mana, `/workspace/api/chat/raeume/${raumA}/nachrichten`,
{ text: "Agentursache" }, "POST");
const nachher = (await rufe(dogi, "/workspace/api/chat/ungelesen")).json();
ok(nachher.ungelesen === vorher.ungelesen,
`DogFather zaehlt auf crew. keine Agenturnachricht mit `
+ `(vorher ${vorher.ungelesen}, nachher ${nachher.ungelesen})`);
/* GEGENPROBE: Auf der AGENTURADRESSE muss dieselbe Nachricht
gezaehlt werden. Ohne sie hiesse "nicht mitgezaehlt" womoeglich
nur, dass gar nichts gezaehlt wird. */
const dogiAgentur = await anmeldenAn("admin", "CODE-DOGI-0001",
"workspace.dogfather-universe.com");
if (!dogiAgentur.ok) {
konntenicht("DogFather kam nicht an der Agenturwand herein");
} else {
const dort = (await rufe(dogiAgentur,
"/workspace/api/chat/ungelesen")).json();
ok(dort.ungelesen >= 1,
`auf der Agenturadresse steht sie dagegen sehr wohl (${dort.ungelesen})`);
}
}
}
/* =======================================================================
8. DIENES MELDUNG: DIE APP IM HINTERGRUND (30.09.2026)
„Die Benachrichtigungen werden nicht angezeigt, wenn neue
Nachrichten reinkommen. Erst, wenn man die App öffnet."
Am echten Bestand nachgesehen: Diene HAT ein angemeldetes Geraet
(Android, Chrome, seit dem 29.09.), und alle zehn Geraete im Haus
melden `fehler = 0`. An der Zustellung liegt es nicht.
DER VERDACHT steht in workspace-chat.js:
if ((zuschauer.get(personId) || new Set()).size) continue;
Wer eine offene Verbindung hat, bekommt KEINE Benachrichtigung.
Der Gedanke daneben ist richtig -- „wer die Seite offen hat, sieht
die Nachricht ohnehin, und ihm auch noch eine Meldung aufs Handy zu
schicken ist der schnellste Weg, dass er Benachrichtigungen
abschaltet."
NUR HEISST EINE OFFENE VERBINDUNG NICHT, DASS JEMAND HINSIEHT.
Ein Handy mit der App im Hintergrund haelt sie weiter; der Server
haelt ihn dann fuer anwesend und schweigt.
HERGESTELLT UND NICHT ANGENOMMEN: Der Strom wird geoeffnet und
offen gelassen -- genau das, was ein Telefon im Hintergrund tut.
======================================================================= */
console.log("");
console.log("=== 8. Wenn die App im Hintergrund liegt ===");
{
const { request: httpRoh } = await import("node:http");
/* EINE ROHE VERBINDUNG, DIE OFFEN BLEIBT. `fetch` wartet auf das
Ende; ein Ereignisstrom endet nie. Genau daran ist im September
schon einmal eine Pruefung haengengeblieben. */
const stromAuf = (keks) => new Promise((fertig) => {
const a = httpRoh({
host: "127.0.0.1", port: PORT, path: "/workspace/api/chat/strom",
method: "GET",
headers: { Cookie: keks, Host: "crew.dogfather-universe.com", Accept: "text/event-stream" },
}, (antwort) => {
antwort.on("data", () => { }); /* mitlesen, nichts tun */
fertig({ anfrage: a, code: antwort.statusCode });
});
a.on("error", () => fertig({ anfrage: a, code: 0 }));
a.end();
});
const strom = await stromAuf(ayla.keks);
ok(strom.code === 200, `Aylas Handy haelt die Verbindung (HTTP ${strom.code})`);
/* Kurz warten, damit der Server sie wirklich eingetragen hat. */
await new Promise((r) => setTimeout(r, 400));
const vorher = pakete.length;
const g = await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "Kommt das an, wenn die App hinten liegt?" }, "POST");
ok(g.ok, `DogFather schreibt ihr (HTTP ${g.status})`);
/* Auf das Ergebnis warten, nicht auf die Uhr -- aber hier ist das
AUSBLEIBEN das Ergebnis, also braucht es eine Frist. Drei
Sekunden sind grosszuegig: Der Weg zum Push-Dienst dauert
oertlich Millisekunden. */
const bis = Date.now() + 3000;
while (pakete.length === vorher && Date.now() < bis) {
await new Promise((r) => setTimeout(r, 60));
}
const kam = pakete.length > vorher;
console.log(` Benachrichtigung verschickt: ${kam ? "ja" : "NEIN"}`);
ok(kam,
kam
? "sie bekommt die Meldung, obwohl eine Verbindung offen ist "
+ "(die App liegt hinten)"
/* BACKTICKS, WEIL EINE DEUTSCHE ANFUEHRUNG DARIN STEHT. In
einem "..."-String beendet das ASCII-Zeichen nach dem Wort
den String -- mir heute zum fuenften Mal passiert. Wo eine
deutsche Anfuehrung hinein soll, gehoeren Backticks darum. */
: `KEINE MELDUNG — der Server haelt eine offene Verbindung fuer `
+ `„sie sieht ja hin". Genau Dienes Befund.`);
/* ==== DIE GEGENPROBE, OHNE DIE DIE ZEILE OBEN NICHTS BEWEIST ====
Wer WIRKLICH hinsieht, soll KEINE Meldung bekommen -- das war
der richtige Gedanke hinter der alten Zeile, und er bleibt.
Ohne diesen Teil hiesse die Reparatur nur „jetzt kommt immer
eine", und das waere der schnellste Weg, dass jemand
Benachrichtigungen abschaltet.
GEMELDET WIRD, WAS DIE SEITE SAGEN WUERDE: `sichtbar: true` --
genau das schickt die Kopfleiste, wenn die App vorn liegt. */
const sicht = await rufe(ayla, "/workspace/api/chat/sicht",
{ sichtbar: true }, "POST");
ok(sicht.ok, `Ayla meldet: ich sehe hin (HTTP ${sicht.status})`);
const vorherS = pakete.length;
await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "Und wenn sie wirklich hinsieht?" }, "POST");
await new Promise((r) => setTimeout(r, 2500));
ok(pakete.length === vorherS,
`wer hinsieht, bekommt KEINE Meldung aufs Handy `
+ `(${pakete.length - vorherS} verschickt)`);
/* Und wenn sie die App weglegt, kommt sie wieder. */
await rufe(ayla, "/workspace/api/chat/sicht", { sichtbar: false }, "POST");
const vorherW = pakete.length;
await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "App weggelegt." }, "POST");
const bisW = Date.now() + 5000;
while (pakete.length === vorherW && Date.now() < bisW) {
await new Promise((r) => setTimeout(r, 60));
}
ok(pakete.length > vorherW,
`legt sie die App weg, kommt die Meldung wieder `
+ `(${pakete.length - vorherW})`);
/* GEGENPROBE ZUM SCHLUSS: Ohne offene Verbindung erst recht. */
strom.anfrage.destroy();
await new Promise((r) => setTimeout(r, 500));
const vorher2 = pakete.length;
await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "Und jetzt, mit geschlossener App?" }, "POST");
const bis2 = Date.now() + 5000;
while (pakete.length === vorher2 && Date.now() < bis2) {
await new Promise((r) => setTimeout(r, 60));
}
ok(pakete.length > vorher2,
`Gegenprobe: ohne offene Verbindung kommt sie sehr wohl `
+ `(${pakete.length - vorher2})`);
}
/* ------------------------------------------------------------------- */
await schliessen();
d.close();
/* AUFRAEUMEN IST KEIN BEFUND. Der Server hält die Datenbank offen,
solange der Prozess läuft; unter Windows gibt `rmSync` darauf
EPERM. Das ist ein Rest im Temp-Ordner, kein Fehler in der
Software — und er als Absturz gemeldet hätte die ganze Prüfung rot
gefärbt, obwohl alle 15 Messungen grün waren. Genau so entsteht
ein Rot, das man sich abgewöhnt zu lesen. */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest im Temp */ }
console.log("");
console.log(fehler === 0
? `ALLES IN ORDNUNG (${geprueft} Pruefungen)`
: `${fehler} von ${geprueft} Pruefungen sind nicht in Ordnung.`);
process.exit(fehler === 0 ? 0 : 1);
+30 -7
View File
@@ -697,33 +697,56 @@ melde("\n=== Die Kachel ===");
Regeln koennen vollstaendig in der Antwort stehen und trotzdem
nirgends gezeichnet werden -- ein vergessener Aufruf in karte()
reicht, und niemand merkt es, weil die Karte ja da ist. */
/* ==== AUF DIE NEUE KARTE UMGESTELLT (03.10.2026) ================
Die Eventkarte wurde umgebaut (siehe pruef-eventkarte.mjs): Buehne
mit Titel auf dem Banner, Zeitraum einmal statt dreimal, Aufgaben
als Liste zum Abhaken. Dabei haben die Bausteine neue Namen
bekommen -- `.event-zeitraum__marke` heisst jetzt `.ev-noch`,
`.event-block__text` ist zu einer Liste aus `.ev-punkt__text`
geworden.
DIESE PRUEFUNG WURDE DESHALB NICHT GELOESCHT, SONDERN NACHGEZOGEN:
Sie sagt weiterhin dieselben FUENF Dinge zu, nur an der neuen
Stelle. Eine Pruefung wegzunehmen, weil sie nach einem Umbau rot
wird, senkt die Zahl und beweist danach nichts mehr
(Projektgedaechtnis, 28.08.2026: 789 statt 804 Pruefungen, und
acht davon fielen still weg).
WAS SICH INHALTLICH AENDERT: Der Zustand steht nicht mehr als
"laeuft bis TT.MM.JJJJ" da, sondern als "noch N Tage" -- dieselbe
Aussage, nur beantwortet sie jetzt die Frage, die man wirklich
hat. Geprueft wird deshalb auf ein WORT (nicht nur Farbe), nicht
mehr auf einen bestimmten Satz. */
const event = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion");
if (!k) return null;
return {
stand: k.dataset.event,
marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(),
spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()),
texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()),
marke: k.querySelector(".ev-noch")?.textContent.trim(),
spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".ev-block__schild")].map((s) => s.textContent.trim()),
texte: [...k.querySelectorAll(".ev-punkt__text")].map((s) => s.textContent.trim()),
};
});
ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite");
ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`);
ok(/läuft bis/.test(event?.marke || ""),
/* Ein Wort, kein leerer Platzhalter -- und es muss zum Zustand
passen: "noch N Tage" bzw. "heute ist der letzte Tag". */
ok(/^(noch \d+ Tage?|heute ist der letzte Tag)$/.test(event?.marke || ""),
`der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`);
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
ok(event?.schilder.join(",") === "Aufgaben,Regeln",
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"),
"und tragen wirklich ihren Inhalt");
`und tragen wirklich ihren Inhalt (${JSON.stringify(event?.texte)})`);
/* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon
haben. Sonst bewiese der Block oben nur, dass die Elemente
existieren -- nicht, dass sie zur richtigen Art gehoeren. */
const schulung = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung");
return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".event-block").length } : null;
return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".ev-block").length } : null;
});
ok(schulung && !schulung.event && schulung.bloecke === 0,
`eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`);
+77 -1
View File
@@ -128,12 +128,72 @@ async function liste(bereich, rolle) {
return { code: a.code, n: (j?.eintraege || []).length };
}
/* Der Katalog wird schon fuer Abschnitt 0 gebraucht -- er steht
deshalb vor ihm und nicht mehr in Abschnitt 1. */
const KATALOG_VORAB = { ...ARBEIT_START, ...TREFF_START };
const KATALOG = KATALOG_VORAB;
/* =======================================================================
0. EIN BRETT OHNE KATALOG BEKOMMT SEINEN EINTRAG -- UND ZWAR ZUERST
=======================================================================
„highlight" hat als einziges Treff-Brett keinen Katalog: Dort stehen
echte Hoehepunkte, keine Vorlagen. Abschnitt 2 misst aber, ob die
Community DASSELBE sieht wie DogFather -- und an einem leeren Brett
ist das nicht zu messen (null gleich null waere auch dann wahr, wenn
die Community gar nichts duerfte).
WARUM GANZ AM ANFANG: Es gibt eine Schreibbremse -- rund fuenfzig
Beitraege je Stunde. Der Katalog unten legt 110 an; danach ist sie
ausgeloest, und der Versuch, hier noch etwas zu schreiben, bekommt
429. Genau so ist es im ersten Anlauf passiert. Die Bremse ist
richtig und bleibt; die Pruefung stellt sich davor statt dahinter.
UEBER DIE ECHTE ROUTE, nicht ueber die Datenbank: So ist der Weg,
den auch ein Mensch nimmt, gleich mitgeprueft.
======================================================================= */
{
/* NUR DIE BRETTER OHNE KATALOG. Der erste Anlauf fragte „ist es
leer?" -- und vor dem Katalog sind ALLE leer. Er legte damit
ueberall einen Eintrag an, und bei „ansteht" stimmte danach die
Zahl nicht mehr (5 gegen 4). Gefragt wird deshalb, was wirklich
gemeint ist: Gibt es fuer dieses Brett eine Vorlage? */
const ohneKatalog = TREFF_BRETTER.filter((b) => !KATALOG_VORAB[b]);
ok(ohneKatalog.length >= 1,
`${ohneKatalog.length} Treff-Brett(er) ohne Katalog: ${ohneKatalog.join(", ") || "keines"}`);
for (const b of ohneKatalog) {
const art = Object.keys(BEREICHE[b]?.arten || {})[0];
const neu = await roh(`/workspace/api/bereich/${b}`,
{ cookie: kekse.admin, origin: `https://${CREW}` },
{ art, titel: "Damit dieses Brett etwas zeigt",
text: "Ein Eintrag fuer die Messung -- dieses Brett hat keine Vorlagen." });
ok(neu.code === 201, `${b}: bekommt einen Eintrag (${neu.code})`);
/* UND ER MUSS FREIGEGEBEN WERDEN. Auf den Freigabe-Brettern sieht
die Community nur, was jemand aus dem Team durchgelassen hat --
beim Katalog passiert das automatisch (workspace-bereiche.js),
beim gewoehnlichen Schreiben nicht. Ohne diesen Schritt maesse
Abschnitt 2 „DogFather 1, Community 0" und haette recht: Der
Eintrag ist wirklich nicht frei. */
if (TREFF_FREIGABE_BRETTER.includes(b)) {
let id = null;
try { id = JSON.parse(neu.text)?.eintrag?.id ?? JSON.parse(neu.text)?.id; }
catch { /* steht unten als Fehler */ }
const frei = id
? await roh(`/workspace/api/treff/eintrag/${id}/freigeben`,
{ cookie: kekse.admin, origin: `https://${CREW}` }, {}, "PUT")
: { code: 0 };
ok(frei.code === 200, ` und wird freigegeben (${frei.code})`);
}
}
}
/* =======================================================================
1. DOGFATHER FÜLLT ALLES
======================================================================= */
melde("");
melde("=== 1. Der Vorschlagskasten ===");
const KATALOG = { ...ARBEIT_START, ...TREFF_START };
const gefuellt = [];
{
let summe = 0;
@@ -160,6 +220,22 @@ const gefuellt = [];
melde("");
melde("=== 2. Was die Community sieht ===");
{
/* ==== EIN LEERES BRETT BEWEIST NICHTS (25.09.2026) ============
„highlight" hat als einziges Treff-Brett keinen Katalog -- dort
stehen echte Hoehepunkte und keine Vorlagen, und das ist richtig
so. Die Folge war aber: null Eintraege bei DogFather, null bei
der Community, und die Bedingung `a.n > 0` wurde rot. Die
Pruefung meldete einen Fehler, wo schlicht nichts zu sehen war.
GEMESSEN WERDEN SOLL DIE GLEICHHEIT, nicht der Katalog. Also legt
die Pruefung dort selbst einen Eintrag an -- ueber die echte
Route, mit der auch DogFather schreibt. Damit hat jedes Brett
etwas, und die Frage „sieht die Community dasselbe?" laesst sich
ueberall stellen.
KEIN TESTDATENSATZ IM SINNE DES VERBOTS: Diese Pruefung laeuft
auf ihrer eigenen Wegwerf-Datenbank in einem mktemp-Ordner. */
for (const b of TREFF_BRETTER) {
const a = await liste(b, "admin");
const g = await liste(b, "gast");
+5 -1
View File
@@ -278,7 +278,11 @@ melde("=== 7. Im Browser ===");
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
const kontext = await browser.newContext({ viewport: { width: 390, height: 844 } });
/* MIT FINGER. Ohne `hasTouch` meldet der Browser einen feinen
Zeiger, und keine Regel aus `@media (pointer: coarse)` greift --
dort stehen die 44-Pixel-Beruehrziele. Siehe pruef-fingermass.mjs. */
const kontext = await browser.newContext({ viewport: { width: 390, height: 844 },
hasTouch: true, isMobile: true });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); });
+231
View File
@@ -0,0 +1,231 @@
/* =====================================================================
„AN ALLE" BEI „AN WEN" (24.09.2026)
Filipe: „bei an ween fehlt noch die option alle neben den namen
allen."
DREI FRAGEN, und die dritte ist die, an der es schiefgehen kann:
1. Trifft „alle" wirklich JEDEN -- oder nur den ersten?
2. Bleibt der Duplikat-Schutz erhalten? Er fragt, was EINE Person
schon hat; bei „alle" muss er je Person neu gestellt werden.
Sonst bekaeme nur die erste ihre Pruefung und alle anderen
die Vorlage doppelt -- genau so entstanden am 22.09. aus zwei
Klicks 112 Aufgaben.
3. Kommt jemand darueber an Leute heran, die ihn nichts angehen?
„alle" darf nicht mehr treffen als die Namen daneben.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-aa-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die An-alle-Pruefung");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(240_000, "pruef-an-alle");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const melde = (t) => console.log(t);
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const ks = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", ks, jetzt);
function anlegen(name, rolle, code, aktiv = 1) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,?,?)")
.run(name, rolle, hash, salt, 32768,
createHmac("sha256", ks).update(code).digest("hex"), aktiv, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const modis = [
anlegen("Miss", "modi", "CODE-MODI-0001"),
anlegen("Ghost", "modi", "CODE-MODI-0002"),
anlegen("Marina", "modi", "CODE-MODI-0003"),
];
/* EIN GESPERRTER MODI. Er darf NICHT mitbekommen -- ein Zugang, der
nicht hereinkommt, braucht keine Aufgabenliste. */
const gesperrt = anlegen("Diene", "modi", "CODE-MODI-0004", 0);
/* Und ein Creator: Er steht gar nicht in dieser Auswahl und darf
auch ueber „alle" nichts bekommen. */
const creator = anlegen("Luna", "creator", "CODE-CREA-0001");
d.close();
const CREW = "crew.dogfather-universe.com";
const { request } = await import("node:http");
function roh(pfad, keks, koerper, methode) {
return new Promise((fertig, schief) => {
const daten = koerper ? JSON.stringify(koerper) : null;
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (daten ? "POST" : "GET"),
headers: {
Host: CREW, Origin: `https://${CREW}`,
...(keks ? { Cookie: keks } : {}),
...(daten ? { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(daten) } : {}),
},
}, (antwort) => {
let text = "";
antwort.on("data", (t) => { text += t; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text }));
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
const json = (r) => { try { return JSON.parse(r.text); } catch { return {}; } };
async function anmelden(rolle, code) {
const r = await new Promise((fertig, schief) => {
const daten = JSON.stringify({ rolle, code, alter_ok: true });
const a = request({
host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden",
method: "POST",
headers: { Host: CREW, Origin: `https://${CREW}`,
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) },
}, (antwort) => {
let text = "";
antwort.on("data", (t) => { text += t; });
antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers }));
});
a.on("error", schief);
a.write(daten); a.end();
});
if (r.code !== 200) return null;
return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
const kModi = await anmelden("modi", "CODE-MODI-0001");
ok(!!kDogi && !!kModi, "DogFather und ein Modi sind angemeldet");
/* Wie viele Aufgaben hat wer? Direkt aus der Datenbank -- die Antwort
der Route zu glauben hiesse, sie mit sich selbst zu pruefen. */
function zaehle(personId) {
const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const { n } = db2.prepare(
"SELECT COUNT(*) AS n FROM aufgaben WHERE verantwortlich_id = ? AND vorlage IS NOT NULL")
.get(personId);
db2.close();
return n;
}
/* Welche Kategorie gibt es ueberhaupt? Aus dem Katalog, den die Seite
auch bekommt -- eine erfundene Gruppe waere eine Pruefung, die
nichts trifft. */
const katalog = json(await roh("/workspace/api/vorlagen", kDogi));
const ersteGruppe = (katalog.katalog?.gruppen || [])[0]?.schluessel
|| (katalog.katalog?.aufgaben || [])[0]?.gruppe || "";
ok(!!ersteGruppe, `es gibt eine Kategorie zum Verteilen (${ersteGruppe})`);
melde("\n=== 1. alle trifft jeden ===");
{
const vorher = modis.map(zaehle);
ok(vorher.every((x) => x === 0), `vorher hat niemand etwas (${vorher.join(", ")})`);
const a = await roh("/workspace/api/vorlagen/uebernehmen", kDogi,
{ art: "katalog_alle", gruppe: ersteGruppe, stufe: "", person_id: "alle" });
ok(a.code === 200, `die Anfrage geht durch (${a.code})`);
const r = json(a);
ok((r.angelegt || 0) > 0, `es wurde etwas angelegt (${r.angelegt})`);
const nachher = modis.map(zaehle);
ok(nachher.every((x) => x > 0),
`JEDER der drei Modis hat jetzt Aufgaben (${nachher.join(", ")})`);
ok(new Set(nachher).size === 1,
"und alle gleich viele -- sonst hat die Schleife jemanden ausgelassen");
ok((r.angelegt || 0) === nachher[0] * modis.length,
`die Zahl in der Antwort passt zur Datenbank (${r.angelegt} = ${nachher[0]} x ${modis.length})`);
}
melde("\n=== 2. Der Duplikat-Schutz gilt je Person ===");
{
const vorher = modis.map(zaehle);
const a = await roh("/workspace/api/vorlagen/uebernehmen", kDogi,
{ art: "katalog_alle", gruppe: ersteGruppe, stufe: "", person_id: "alle" });
ok(a.code === 200, `ein zweiter Klick geht durch (${a.code})`);
const r = json(a);
ok((r.angelegt || 0) === 0,
`und legt NICHTS mehr an (${r.angelegt}) -- genau das war der Fehler vom 22.09.`);
/* `uebersprungen` ist eine ZAHL, kein Feld -- nachgesehen in
workspace-vorlagen.js (`uebersprungen: uebersprungen.length`).
Mein erster Versuch rief `.length` darauf auf und bekam
`undefined`; ein Messfehler, der wie eine fehlende Antwort
aussah. */
ok(Number(r.uebersprungen) > 0,
`stattdessen sagt die Antwort, wie viele schon lagen (${r.uebersprungen})`);
ok(Number(r.uebersprungen) === 36,
`und zwar alle 36 -- 12 Vorlagen mal 3 Modis (${r.uebersprungen})`);
const nachher = modis.map(zaehle);
ok(nachher.join() === vorher.join(),
`niemand hat etwas doppelt (${nachher.join(", ")})`);
}
melde("\n=== 3. alle trifft nicht mehr als die Namen ===");
{
ok(zaehle(gesperrt) === 0,
`ein GESPERRTER Modi bekommt nichts (${zaehle(gesperrt)})`);
ok(zaehle(creator) === 0,
`ein Creator erst recht nicht (${zaehle(creator)})`);
/* Und wer gar nicht verteilen darf, kommt auch ueber „alle" nicht
weiter -- sonst waere der neue Wert ein Weg an der Rechtefrage
vorbei. */
const alsModi = await roh("/workspace/api/vorlagen/uebernehmen", kModi,
{ art: "katalog_alle", gruppe: ersteGruppe, stufe: "", person_id: "alle" });
ok(alsModi.code >= 400,
`ein Modi kann nicht an alle verteilen (${alsModi.code})`);
}
melde("\n=== 4. Gegenproben ===");
{
/* EINE EINZELNE PERSON GEHT WEITERHIN -- sonst hiesse „alle
funktioniert" nur, dass der alte Weg kaputt ist. */
const einzeln = await roh("/workspace/api/vorlagen/uebernehmen", kDogi,
{ art: "katalog", nr: 0, gruppe: ersteGruppe, person_id: modis[0] });
ok(einzeln.code === 200 || einzeln.code === 400,
`der Weg an EINE Person antwortet noch (${einzeln.code})`);
/* Ein erfundener Wert ist kein "alle". */
const quatsch = await roh("/workspace/api/vorlagen/uebernehmen", kDogi,
{ art: "katalog_alle", gruppe: ersteGruppe, stufe: "", person_id: "alle-zusammen" });
ok(quatsch.code >= 400,
`alle-zusammen ist nicht alle (${quatsch.code})`);
/* Und ohne Person weiterhin die alte Absage. */
const ohne = await roh("/workspace/api/vorlagen/uebernehmen", kDogi,
{ art: "katalog_alle", gruppe: ersteGruppe, stufe: "" });
ok(ohne.code === 400, `ohne Person immer noch 400 (${ohne.code})`);
}
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
process.exit(fehler ? 1 : 0);
+76 -16
View File
@@ -63,6 +63,24 @@ import { fileURLToPath } from "node:url";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
const lies = (p) => readFileSync(join(REPO, p), "utf8");
/* ---- NUR DER CODE, OHNE DIE ERZAEHLUNG DRUMHERUM (03.10.2026) -------
Diese Datei prueft Quelltext mit Mustern. Das ist schnell und
braucht keinen Server -- hat aber eine Luecke, die heute
aufgefallen ist: Die Pruefung "und zwar an derselben Bedingung wie
die Nachtruhe" war gruen, OBWOHL die Zeile aus dem Code
verschwunden war. Sie stand nur noch in einem Kommentar, der die
alte Fassung zitiert -- und ein Muster sieht den Unterschied
nicht.
Das ist die dritte Sorte gruener Haken: nicht uebersprungen, nicht
falsch geprueft, sondern am Beleg vorbei. Deshalb werden
Kommentare vorher entfernt. Was hier geprueft wird, muss laufender
Code sein. */
const nurCode = (t) => t
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:])\/\/.*/g, "$1");
let ok = 0, fehl = 0;
const pruef = (name, bedingung, zusatz = "") => {
if (bedingung) { ok++; console.log(` OK ${name}`); }
@@ -104,29 +122,47 @@ pruef("und für alles andere bleibt es ruhig",
/* =====================================================================
2. DER VERSAND — Dringlichkeit und Haltbarkeit
===================================================================== */
const krypto = lies("server/workspace-push-krypto.js");
const versand = lies("server/workspace-push.js");
const krypto = nurCode(lies("server/workspace-push-krypto.js"));
const versand = nurCode(lies("server/workspace-push.js"));
pruef("der Transport kennt überhaupt eine Dringlichkeit",
/dringend\s*=\s*false\s*\}\s*=\s*\{\}/.test(krypto),
/eilig\s*=\s*false/.test(krypto),
"vorher stand Urgency fest auf \"normal\"");
pruef("ein dringender Push geht mit Urgency: high hinaus",
/Urgency:\s*dringend\s*\?\s*"high"\s*:\s*"normal"/.test(krypto));
pruef("ein eiliger Push geht mit Urgency: high hinaus",
/Urgency:\s*eilig\s*\?\s*"high"\s*:\s*"normal"/.test(krypto));
pruef("und verfällt, wenn der Anruf vorbei ist",
/TTL:\s*String\(dringend\s*\?\s*Math\.min\(ttl,\s*\d+\)/.test(krypto),
/TTL:\s*String\(kurzlebig\s*\?\s*Math\.min\(ttl,\s*\d+\)/.test(krypto),
"sonst ploppt ein Anruf von vor einer Stunde nachträglich auf");
pruef("der Anruf wird als dringend verschickt",
/schicken\(a,\s*inhalt,\s*paar,\s*ABSENDER,\s*\{\s*dringend\s*\}\)/.test(versand));
pruef("der Anruf wird eilig UND kurzlebig verschickt",
/schicken\(a,\s*inhalt,\s*paar,\s*ABSENDER,\s*\{\s*eilig,\s*kurzlebig\s*\}\)/.test(versand));
/* DIESELBE BEDINGUNG WIE BEI DER RUHEZEIT. „Darf das nachts stören?"
und „darf das warten?" haben dieselbe Antwort -- zwei getrennte
Bedingungen wären die, die beim nächsten Nachschärfen auseinander
laufen. */
pruef("und zwar an derselben Bedingung wie die Nachtruhe",
/const dringend = art === "test" \|\| art === "anruf";/.test(versand));
/* ---- ZWEI FRAGEN, ZWEI FELDER (geändert 03.10.2026) -----------------
Hier stand bis heute das Gegenteil: „Darf das nachts stören?" und
„darf das warten?" hätten dieselbe Antwort, zwei getrennte
Bedingungen wären die, die auseinanderlaufen. Die Vorhersage war
richtig, nur in die andere Richtung — sie laufen auseinander, WEIL
es zwei Fragen sind:
Chat um 3 Uhr nachts -> nicht stören (Ruhezeit greift)
Chat um 14 Uhr -> nicht warten (eilig)
Genau diese Gleichsetzung hat Dienes Meldung verursacht: Weil der
Chat nicht nachts stören darf, bekam er auch tagsüber
`Urgency: normal` — und Android legt das beiseite, bis jemand die
App öffnet. Das Zusammenlegen war die Ursache, nicht der Schutz.
Auseinanderlaufen können die beiden trotzdem nicht: Sie stehen als
`eilig` und `ruhe` in DERSELBEN Zeile derselben Artenliste. */
pruef("der Anruf ist eilig und kurzlebig, beides in einer Zeile",
/\{\s*schluessel:\s*"anruf",\s*eilig:\s*true,[^}]*ruhe:\s*"nie"/.test(versand));
pruef("und die Dringlichkeit wird aus der Artenliste abgeleitet",
/const eilig = eiligRegel\(art\);/.test(versand),
"nicht aus einer zweiten Aufzählung daneben");
/* =====================================================================
3. DER WEG DORTHIN — wird beim Anruf überhaupt geweckt?
@@ -152,10 +188,34 @@ pruef("und die Art „anruf\" geht bis zur Benachrichtigung durch",
Genau davor warnt die Hausregel vom 01.09.: Ein grüner Haken sagt
nur, dass die Bedingung erfüllt war -- nicht, dass sie das Richtige
geprüft hat. */
pruef("wer die Seite offen hat, bekommt KEINE zusätzliche Meldung",
/\(zuschauer\.get\(personId\) \|\| new Set\(\)\)\.size\) continue;/.test(chat),
/* ==== UND SEIT DEM 30.09.2026 IST ES EINE ANDERE FRAGE ============
Diene im Support: „Die Benachrichtigungen werden nicht angezeigt,
wenn neue Nachrichten reinkommen. Erst, wenn man die App öffnet."
Nachgestellt (pruef-abzeichen, Abschnitt 8): Der Server hat die
Meldung unterdrückt, solange eine VERBINDUNG offen war. Ein Handy
mit der App im Hintergrund hält sie weiter — also galt Diene als
anwesend, während sein Bildschirm schwarz war.
Die Absicht der Zeile darüber bleibt richtig und wird weiter
geprüft: Wer WIRKLICH hinsieht, bekommt keine zweite Meldung.
Gefragt wird nur nicht mehr nach der Verbindung, sondern nach
`siehtZu()` — und das verlangt zusätzlich eine frische Sichtmeldung
der Seite.
UND DIESE PRÜFUNG HAT DEN FEHLER MITGETRAGEN. Sie hielt den
WORTLAUT fest, nicht das Verhalten — genau das, wovor ihr eigener
Kommentar drei Zeilen weiter oben warnt. Deshalb steht hier jetzt
BEIDES: dass gefragt wird, und dass die Frage die richtige ist. */
pruef("wer wirklich hinsieht, bekommt KEINE zusätzliche Meldung",
/if \(siehtZu\(personId\)\) continue;/.test(chat),
"sonst meldet es sich doppelt — der schnellste Weg, dass jemand alles abschaltet");
pruef("und „hinsehen\" heißt mehr als „eine Verbindung offen haben\"",
/function siehtZu[\s\S]{0,400}?sichtbarSeit\.get\(id\)[\s\S]{0,200}?SICHT_FRIST/.test(chat),
"sonst wäre es wieder die alte Frage, nur mit neuem Namen");
/* =====================================================================
DIE GEGENPROBEN
+122 -52
View File
@@ -16,7 +16,7 @@
Aufrufen mit: node server/pruef-anruf.mjs
===================================================================== */
import { mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, unlinkSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
@@ -60,7 +60,24 @@ const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t);
const unklar = (t) => { offen++; console.log(" -- " + t); };
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
/* ==== ZWEI ADRESSEN, ZWEI HAEUSER (25.09.2026) ==============
Seit dem 24.09. filtert der Server heraus, wer nicht zum
Haus der Adresse gehoert. Diese Pruefung meldete DogFather
auf der AGENTUR-Adresse an und liess ihn dann ein Gespraech
mit der rechten Hand aufmachen -- die es dort nicht gibt.
Antwort: 403, und das war richtig. Dreissig Folgefehler
haengen daran, alle aus derselben Wurzel.
TEAM DOGI TELEFONIERT AUF crew., die Agentur auf workspace.
DogFather ist in beiden Haeusern -- welches gilt, entscheidet
die Adresse, ueber die er sich anmeldet, nicht seine Rolle.
DIE AGENTUR-ADRESSE BLEIBT IM SPIEL, und zwar dort, wo sie
hingehoert: beim Creator Tili. Seine Gegenprobe ist dadurch
schaerfer als vorher -- ein Fremder aus dem ANDEREN AGENTUR
bekommt 404 auf einen Team-Dogi-Anruf. */
const AGENTUR = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
@@ -111,10 +128,10 @@ async function anmelden(rolle, code, host) {
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
const kFremd = await anmelden("creator", "CODE-TILI-0001", HAUS);
const kFremd = await anmelden("creator", "CODE-TILI-0001", AGENTUR);
ok(!!kDogi && !!kHand && !!kModi && !!kFremd, "vier Leute sind angemeldet");
/* =======================================================================
@@ -136,23 +153,23 @@ let raumId = null;
{
/* Die Route heisst `direkt`, nicht `mit` -- nachgesehen statt
geraten. */
const r = await roh("/workspace/api/chat/direkt", HAUS, { cookie: kDogi },
const r = await roh("/workspace/api/chat/direkt", CREW, { cookie: kDogi },
{ mit: idHand });
raumId = json(r).raum?.id || json(r).raum_id || json(r).id;
ok(!!raumId, `ein Gespraech entsteht (${r.code}, Raum ${raumId})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi });
const r = await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kDogi });
ok(r.code === 200 && json(r).laeuft === false,
`am Anfang laeuft kein Anruf (${json(r).laeuft})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/start`, HAUS,
const r = await roh(`/workspace/api/anruf/${raumId}/start`, CREW,
{ cookie: kDogi }, { video: false });
ok(r.code === 200, `anrufen geht (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kDogi }));
ok(l.laeuft === true, "und danach laeuft einer");
ok(l.wer?.length === 1 && l.wer[0].id === idDogi,
`mit genau einem Teilnehmer (${l.wer?.map((w) => w.name).join(", ")})`);
@@ -176,19 +193,19 @@ let raumId = null;
melde("");
melde("=== 3. Die Signalisierung ===");
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, CREW, { cookie: kDogi },
{ an: idHand, inhalt: { angebot: { type: "offer", sdp: "v=0" } } });
ok(r.code === 200, `ein Angebot geht durch (${r.code})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, CREW, { cookie: kDogi },
{ an: idFremd, inhalt: { angebot: {} } });
ok(r.code === 404,
`an jemanden AUSSERHALB des Raums geht nichts (${r.code}) -- sonst waere das `
+ "ein Weg, jedem Angemeldeten JSON zu schicken");
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, CREW, { cookie: kDogi },
{ an: idHand });
ok(r.code === 400, `ohne Inhalt wird abgewiesen (${r.code})`);
}
@@ -209,18 +226,18 @@ for (const [pfad, methode, koerper] of [
["/ende", "POST", {}],
["/signal", "POST", { an: idDogi, inhalt: { weg: {} } }],
]) {
const r = await roh(`/workspace/api/anruf/${raumId}${pfad}`, HAUS,
const r = await roh(`/workspace/api/anruf/${raumId}${pfad}`, AGENTUR,
{ cookie: kFremd }, koerper, methode);
ok(r.code === 404,
`${(pfad || "/lage").padEnd(8)} ist fuer Fremde zu -- und zwar 404, `
+ `nicht „kein Zugriff" (${r.code})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, {});
const r = await roh(`/workspace/api/anruf/${raumId}`, CREW, {});
ok(r.code === 401, `ohne Anmeldung gar nichts (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/999999/start", HAUS,
const r = await roh("/workspace/api/anruf/999999/start", CREW,
{ cookie: kDogi }, { video: false });
ok(r.code === 404, `ein erfundener Raum wird abgewiesen (${r.code})`);
}
@@ -233,11 +250,11 @@ melde("=== 5. Auflegen ===");
{
const r = await roh(`/workspace/api/anruf/${raumId}/ende`, CREW, { cookie: kHand }, {}, "POST");
ok(r.code === 200, `die rechte Hand legt auf (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kDogi }));
ok(l.laeuft === true && l.wer?.length === 1,
`der Anruf laeuft weiter, solange noch jemand drin ist (${l.wer?.length})`);
await roh(`/workspace/api/anruf/${raumId}/ende`, HAUS, { cookie: kDogi }, {}, "POST");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
await roh(`/workspace/api/anruf/${raumId}/ende`, CREW, { cookie: kDogi }, {}, "POST");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kDogi }));
ok(l2.laeuft === false, "wenn der Letzte geht, ist er vorbei");
}
@@ -334,22 +351,22 @@ melde("=== 5. Auflegen ===");
melde("");
melde("=== 5g. Anrufer legt auf, bevor jemand rangeht ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
const raum = json(await roh("/workspace/api/chat/raeume", CREW,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
const vorher = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const vorher = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || []).length;
await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS, { cookie: kDogi }, { video: false });
await roh(`/workspace/api/anruf/${raum.id}/start`, CREW, { cookie: kDogi }, { video: false });
/* Kurz warten wie im echten Leben -- und auflegen, ohne dass
jemand rangegangen ist. */
await new Promise((r) => setTimeout(r, 800));
const ende = await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
const ende = await roh(`/workspace/api/anruf/${raum.id}/ende`, CREW, { cookie: kDogi }, {});
ok(ende.code === 200, `aufgelegt (${ende.code})`);
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || [];
ok(nachher.length === vorher + 1,
`es steht trotzdem eine Zeile im Gespraech (${vorher} -> ${nachher.length})`);
@@ -364,7 +381,7 @@ melde("=== 5g. Anrufer legt auf, bevor jemand rangeht ===");
zweite Zeile schreiben -- der Anruf ist ja schon geloescht. */
await new Promise((r) => setTimeout(r, 300));
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
const nochmal = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const nochmal = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || []).length;
ok(nochmal === nachher.length,
`und bleibt bei einer (${nochmal})`);
@@ -374,24 +391,24 @@ melde("=== 5g. Anrufer legt auf, bevor jemand rangeht ===");
melde("");
melde("=== 5f. Ein gefuehrter Anruf ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
const raum = json(await roh("/workspace/api/chat/raeume", CREW,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
const vorher = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const vorher = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || []).length;
await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS, { cookie: kDogi }, { video: false });
await roh(`/workspace/api/anruf/${raum.id}/start`, CREW, { cookie: kDogi }, { video: false });
const dabei = await roh(`/workspace/api/anruf/${raum.id}/dabei`, CREW, { cookie: kHand }, {});
ok(dabei.code === 200, `die rechte Hand geht ran (${dabei.code})`);
/* Kurz reden, dann auflegen -- beide. */
await new Promise((r) => setTimeout(r, 1500));
await roh(`/workspace/api/anruf/${raum.id}/ende`, CREW, { cookie: kHand }, {});
await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
await roh(`/workspace/api/anruf/${raum.id}/ende`, CREW, { cookie: kDogi }, {});
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || [];
ok(nachher.length === vorher + 1,
`es steht eine Zeile im Gespraech (${vorher} -> ${nachher.length})`);
@@ -414,15 +431,15 @@ melde("=== 5f. Ein gefuehrter Anruf ===");
melde("");
melde("=== 5e. Ein verpasster Anruf ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
const raum = json(await roh("/workspace/api/chat/raeume", CREW,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
const vorher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const vorher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten?.length ?? 0;
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS,
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, CREW,
{ cookie: kDogi }, { video: false });
ok(start.code === 200 || start.code === 201, `Anruf gestartet (${start.code})`);
@@ -432,7 +449,7 @@ melde("=== 5e. Ein verpasster Anruf ===");
await new Promise((r) => setTimeout(r, 10600));
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || [];
ok(nachher.length === vorher + 1,
`es steht genau EINE neue Zeile im Gespraech (${vorher} -> ${nachher.length})`);
@@ -447,7 +464,7 @@ melde("=== 5e. Ein verpasster Anruf ===");
eine weitere Zeile. */
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
const nochmal = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
const nochmal = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ cookie: kDogi })).nachrichten || [];
ok(nochmal.length === nachher.length,
`und bleibt bei einer, auch nach weiteren Abfragen (${nochmal.length})`);
@@ -485,7 +502,7 @@ melde("=== 5d. Was in der Benachrichtigung steht ===");
melde("");
melde("=== 5c. Wer erst durch die Benachrichtigung kommt ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
const raum = json(await roh("/workspace/api/chat/raeume", CREW,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
@@ -496,7 +513,7 @@ melde("=== 5c. Wer erst durch die Benachrichtigung kommt ===");
ok(Array.isArray(vorher.anrufe) && vorher.anrufe.length === 0,
`vorher klingelt nichts (${vorher.anrufe?.length})`);
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS,
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, CREW,
{ cookie: kDogi }, { video: false });
ok(start.code === 200 || start.code === 201, `DogFather ruft an (${start.code})`);
@@ -512,17 +529,17 @@ melde("=== 5c. Wer erst durch die Benachrichtigung kommt ===");
/* DER ANRUFER SELBST DARF NICHT KLINGELN. Er hat den Anruf
gestartet; ein zweites Fenster von ihm wuerde sonst mitlaeuten. */
const beimAnrufer = json(await roh("/workspace/api/anruf/offen", HAUS, { cookie: kDogi }));
const beimAnrufer = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kDogi }));
ok(beimAnrufer.anrufe?.length === 0,
`beim Anrufer selbst klingelt nichts (${beimAnrufer.anrufe?.length})`);
/* UND WER NICHT IN DEN RAUM GEHOERT, ERFAEHRT NICHTS. Dieselbe
Frage wie ueberall sonst -- eine neue Route ist eine neue Tuer. */
const fremd = json(await roh("/workspace/api/anruf/offen", HAUS, { cookie: kFremd }));
const fremd = json(await roh("/workspace/api/anruf/offen", AGENTUR, { cookie: kFremd }));
ok(fremd.anrufe?.length === 0,
`ein Fremder erfaehrt nichts davon (${fremd.anrufe?.length})`);
await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
await roh(`/workspace/api/anruf/${raum.id}/ende`, CREW, { cookie: kDogi }, {});
const danach = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(danach.anrufe?.length === 0,
`nach dem Auflegen klingelt es nicht mehr (${danach.anrufe?.length})`);
@@ -532,7 +549,7 @@ melde("=== 5c. Wer erst durch die Benachrichtigung kommt ===");
melde("");
melde("=== 6. Die Verbindungsadressen ===");
{
const r = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
const r = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi }));
ok(Array.isArray(r.adressen), `die Liste kommt (${r.adressen?.length} Eintraege)`);
ok(r.nur_gleiches_netz === true,
"und sie sagt ehrlich, dass es ohne Eintrag nur im selben Netz geht");
@@ -543,7 +560,7 @@ melde("=== 6. Die Verbindungsadressen ===");
ok(r.code === 404, `die rechte Hand darf sie nicht setzen (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
const r = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi }, {
adressen: [
{ urls: "stun:turn.dogfather-universe.com:3478" },
{ urls: "turn:turn.dogfather-universe.com:3478", username: "a", credential: "b" },
@@ -566,9 +583,9 @@ melde("=== 6. Die Verbindungsadressen ===");
Eine Pruefung, die der naechsten den Boden verstellt, macht deren
Ergebnis unlesbar -- genau das ist heute schon einmal passiert
(die Anfragen, Abschnitt 8b). */
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi },
{ adressen: [] }, "PUT");
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
const zurueck = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0,
"danach ist die Liste wieder leer -- der naechste Abschnitt faengt sauber an");
}
@@ -688,7 +705,7 @@ melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
/* --- Und jetzt ueber die Schnittstelle, wie der Browser sie sieht - */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi }, {
adressen: [{ urls: "turn:turn.dogfather-universe.com:3478" }],
}, "PUT");
const a = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi });
@@ -738,7 +755,7 @@ melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
/* Jeder bekommt EIGENE Zugangsdaten -- im coturn-Protokoll steht
damit die Nummer, nicht ein Sammelkonto. */
const rDogi = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
const rDogi = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi }));
const dogiTurn = (rDogi.adressen || []).find(
(e) => /^turns?:/.test(String(e.urls || "")));
ok(!!dogiTurn?.username && dogiTurn.username !== ersterTurn?.username,
@@ -753,10 +770,10 @@ melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
/* --- Aufraeumen: der naechste Abschnitt faengt sauber an ---------- */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi },
{ adressen: [] }, "PUT");
try { unlinkSync(datei); } catch { /* schon weg */ }
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
const zurueck = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0 && !zurueck.turn_grund,
"danach ist wieder alles leer -- keine erfundene Adresse bleibt stehen");
}
@@ -875,7 +892,7 @@ melde("=== 7. Im Browser ===");
ok(toene > 0, `es klingelt hoerbar -- ${toene} Toene erzeugt`);
/* Und der Server weiss davon. */
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kDogi }));
ok(l.laeuft === true, "der Server kennt den Anruf");
await seite.screenshot({ path: "server/pruef-anruf.png", fullPage: false });
@@ -885,7 +902,7 @@ melde("=== 7. Im Browser ===");
await seite.waitForTimeout(900);
ok(await seite.evaluate(() => document.getElementById("anruf")?.hidden === true),
"nach dem Auflegen ist der Kasten weg");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kDogi }));
ok(l2.laeuft === false, "und der Server auch");
ok(konsole.length === 0,
@@ -931,7 +948,7 @@ melde("=== 8. Zu dritt ===");
unklar("kein Playwright -- der Gruppenanruf wurde nicht ausprobiert");
} else {
/* Ein Gruppenraum mit allen dreien. */
const g = await roh("/workspace/api/chat/gruppe", HAUS, { cookie: kDogi },
const g = await roh("/workspace/api/chat/gruppe", CREW, { cookie: kDogi },
{ name: "Testrunde", mit: [idHand, idModi] });
const gruppeId = json(g).raum?.id || json(g).raum_id || json(g).id;
ok(!!gruppeId, `ein Gruppengespraech entsteht (${g.code}, Raum ${gruppeId})`);
@@ -986,7 +1003,7 @@ melde("=== 8. Zu dritt ===");
await a.se.waitForTimeout(6000);
/* Der Server: drei drin. */
const lage = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
const lage = json(await roh(`/workspace/api/anruf/${gruppeId}`, CREW, { cookie: kDogi }));
ok(lage.wer?.length === 3,
`der Server zaehlt drei Teilnehmer (${lage.wer?.map((w) => w.name).join(", ")})`);
@@ -1172,7 +1189,7 @@ melde("=== 8. Zu dritt ===");
/* Einer legt auf -- fuer die anderen laeuft es weiter. */
await c.se.click("#anruf-auflegen");
await c.se.waitForTimeout(2000);
const lage2 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
const lage2 = json(await roh(`/workspace/api/anruf/${gruppeId}`, CREW, { cookie: kDogi }));
ok(lage2.laeuft === true && lage2.wer?.length === 2,
`einer geht, zwei telefonieren weiter (${lage2.wer?.length})`);
const zA = await zaehlen(a);
@@ -1197,7 +1214,7 @@ melde("=== 8. Zu dritt ===");
await a.se.click("#anruf-auflegen");
await b.se.click("#anruf-auflegen");
await a.se.waitForTimeout(1200);
const lage3 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
const lage3 = json(await roh(`/workspace/api/anruf/${gruppeId}`, CREW, { cookie: kDogi }));
ok(lage3.laeuft === false, "wenn alle auflegen, ist der Anruf vorbei");
await a.k.close(); await b.k.close(); await c.k.close();
@@ -1207,6 +1224,59 @@ melde("=== 8. Zu dritt ===");
}
}
/* =====================================================================
EIN turn: OHNE ZUGANGSDATEN DARF DEN BROWSER NICHT ERREICHEN
(25.09.2026)
Am selben Tag beim Bau des Podcast-Studios im echten Browser
nachgemessen:
Failed to construct 'RTCPeerConnection': Both username and
credential are required when the URL scheme is "turn" or "turns".
`new RTCPeerConnection(...)` WIRFT. Die Folge ist nicht "kein
Vermittlungsserver", sondern KEIN ANRUF -- auch nicht der, der direkt
zustande gekommen waere. Aus einem Randproblem (etwa jeder Fuenfte
braucht Vermittlung) wuerde ein Totalausfall fuer alle, ausgeloest
davon, dass eine Datei auf dem Server unlesbar ist.
Der Server laesst solche Eintraege bewusst stehen, damit der Grund
nicht verschwindet -- das ist dort richtig. Aussortiert wird im
Browser, in `workspace/assets/js/anruf.js`.
DIESE PRUEFUNG LIEST KEINEN TEXT, SONDERN FUEHRT DEN ECHTEN AUSDRUCK
AUS. Eine Prüfung, die nur nachsieht, ob irgendwo das Wort "filter"
steht, waere auch dann gruen, wenn der Filter das Falsche tut.
===================================================================== */
{
const quelle = readFileSync(new URL("../workspace/assets/js/anruf.js", import.meta.url), "utf8");
const t = /adressen = \(d\.adressen \|\| \[\]\)(\.filter\([\s\S]*?\n {8}\}\);)/.exec(quelle);
ok(!!t, "in anruf.js wird die Adressliste ueberhaupt gefiltert");
if (t) {
/* Genau der Ausdruck aus der ausgelieferten Datei, ausgefuehrt. */
const filtern = new Function("d", "return (d.adressen || [])" + t[1] + ";");
const rein = {
adressen: [
{ urls: "stun:1.2.3.4:3478" },
{ urls: "turn:1.2.3.4:3478?transport=udp", username: "1:a", credential: "x" },
{ urls: "turn:1.2.3.4:3478?transport=tcp" },
{ urls: "turns:1.2.3.4:5349", username: "1:a" },
],
};
const raus = filtern(rein);
ok(raus.length === 2, `es bleiben genau die zwei brauchbaren uebrig (${raus.length})`);
ok(raus.some((a) => a.urls.startsWith("stun:")),
"STUN bleibt drin -- es kennt gar keine Anmeldung und reicht meistens");
ok(raus.every((a) => !/^turns?:/.test(a.urls) || (a.username && a.credential)),
"kein turn: ohne Benutzer UND Passwort geht hinaus");
/* GEGENPROBE: Wuerde der Filter alles durchlassen, waere die Zeile
oben auch gruen. Hier wird bewiesen, dass er wirklich etwas
wegnimmt. */
ok(raus.length < rein.adressen.length,
"Gegenprobe: der Filter nimmt tatsaechlich etwas weg");
}
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`
+406
View File
@@ -0,0 +1,406 @@
/* =====================================================================
pruef-arbeitsschloss.mjs — hält das Schloss, was es verspricht?
WARUM ES DAS SCHLOSS GIBT (01.10.2026): Zwei Claude-Sitzungen
arbeiteten gleichzeitig in DogiHompage, ohne voneinander zu wissen.
Beide haben `git add -A` benutzt, beide den Stempellauf. Zweimal
ist es nur gutgegangen.
WARUM ES DIESE PRÜFUNG GIBT: Ein Schloss ist eine Sicherung, und
eine Sicherung, die man nie ausgelöst hat, ist eine Hoffnung. Die
teuren Fehler liegen bei einem Schloss alle in derselben Richtung —
es blockiert zu viel:
* Es blockiert das EIGENE Arbeiten -> wird abgeschafft.
* Es blockiert nach einem Absturz dauerhaft -> wird umgangen.
* Es blockiert, wenn es selbst kaputt ist -> wird entfernt.
Deshalb prüft diese Datei NICHT nur, dass es zuschlägt, sondern
vor allem, dass es in genau diesen drei Fällen DURCHLÄSST.
---------------------------------------------------------------------
ALLES IN EINEM WEGWERF-VERZEICHNIS
Die Prüfung legt ein eigenes kleines git-Verzeichnis an, kopiert
das Schloss und den Haken hinein und spielt dort. Das echte
`.arbeitsschloss` wird nicht angefasst — sonst nähme eine Prüfung
jemandem sein Schloss weg, und das wäre genau der Schaden, den sie
verhindern soll.
Aufrufen mit: node server/pruef-arbeitsschloss.mjs
===================================================================== */
import { mkdtempSync, rmSync, mkdirSync, copyFileSync, writeFileSync,
readFileSync, readdirSync, existsSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync, spawnSync } from "node:child_process";
let ok_ = 0, fehl = 0, offen = 0;
const ok = (b, text) => { if (b) { ok_++; console.log(" ok " + text); }
else { fehl++; console.log(" FEHL " + text); } };
const unklar = (text) => { offen++; console.log(" -- " + text); };
const HIER = dirname(fileURLToPath(import.meta.url));
const WURZEL = join(HIER, "..");
const SCHLOSS = join(WURZEL, "tools", "arbeitsschloss.mjs");
const HAKEN = join(WURZEL, "tools", "git-haken", "pre-commit");
/* ---------------------------------------------------------------------
1. Steht überhaupt alles da?
--------------------------------------------------------------------- */
console.log("\n=== 1. Die Teile ===");
ok(existsSync(SCHLOSS), "tools/arbeitsschloss.mjs ist da");
ok(existsSync(HAKEN), "tools/git-haken/pre-commit ist da");
/* DAS SCHLOSS DARF NICHT IM VERLAUF LANDEN. Committet wäre es eine
Behauptung von gestern: Nach einem Klon stünde dort, dass jemand
arbeitet, der längst fertig ist — und dann blockiert es, bis es
verfällt. */
{
const ignoriert = readFileSync(join(WURZEL, ".gitignore"), "utf8");
ok(/^\.arbeitsschloss$/m.test(ignoriert),
".arbeitsschloss steht in .gitignore (sonst waere es ein Schloss von gestern)");
}
/* ==== DER HAKEN BRAUCHT LINUX-ZEILENENDEN =========================
Er hat KEINE Dateiendung, also greift keine der Regeln in
.gitattributes von sich aus. Ohne eine eigene Zeile dort wandelt
git ihn beim Auschecken auf Windows in CRLF -- und auf Linux ist
`#!/bin/sh` mit einem CR dahinter ein Programmname mit einem
unsichtbaren Zeichen am Ende („bad interpreter").
NACHGEMESSEN AM 01.10.2026, damit hier keine Behauptung steht:
Auf Windows blockiert auch ein CRLF-Haken richtig (Rueckgabe 1,
null Commits). Die Regel ist also Vorsorge und keine Reparatur --
aber ein Haken, der still nicht laeuft, ist genau die Sicherung,
die aussieht, als waere sie da.
UND EINE LEHRE AUS DEM MESSEN SELBST: Meine erste Messung
meldete 73 CR-Bytes, wo keines war. Gemessen hatte ich mit
einer Rohrkette aus `tr` und `od`, und `tr` nimmt in dieser
Shell die Maskierung fuer das Wagenruecklauf-Zeichen nicht als
Zeichen -- gezaehlt wurden am Ende Zeilenenden.
Byteweise wird deshalb hier gemessen, nicht in einer
Rohrkette: `readFileSync` ohne Zeichensatz gibt Bytes, und
13 ist 13.
*/
{
const roh = readFileSync(HAKEN);
ok(!roh.includes(13),
`der Haken hat reine LF-Zeilenenden (${roh.filter((b) => b === 13).length} CR)`);
const regeln = readFileSync(join(WURZEL, ".gitattributes"), "utf8");
ok(/tools\/git-haken\/\*\s+text\s+eol=lf/.test(regeln),
".gitattributes haelt den Haken auf LF (er hat keine Endung)");
}
/* DER HAKEN MUSS AUCH GEFUNDEN WERDEN. `.git/hooks` wird nicht
versioniert; ohne `core.hooksPath` liegt der Haken im Baum und tut
nichts. Das ist die Sorte Sicherung, die aussieht, als wäre sie da. */
{
let pfad = "";
try {
pfad = execFileSync("git", ["config", "--get", "core.hooksPath"],
{ cwd: WURZEL, encoding: "utf8" }).trim();
} catch { pfad = ""; }
ok(pfad === "tools/git-haken",
`core.hooksPath zeigt auf den Haken ("${pfad || "nicht gesetzt"}")`
+ (pfad === "tools/git-haken" ? "" : " — node tools/arbeitsschloss.mjs einrichten"));
}
/* DIE STEMPELWERKZEUGE NEHMEN ES SELBST. Abgeleitet aus dem
Quelltext, nicht angenommen: Ein Schloss, das man von Hand nehmen
muss, wird vergessen — und dann umgangen. */
for (const w of ["workspace-stempel.mjs", "seiten-stempel.mjs"]) {
const t = readFileSync(join(WURZEL, "tools", w), "utf8");
ok(/schlossKurz\(/.test(t) && /arbeitsschloss\.mjs/.test(t),
`tools/${w} nimmt das Schloss selbst`);
}
/* ---------------------------------------------------------------------
2. Wer bin ich?
--------------------------------------------------------------------- */
console.log("\n=== 2. Die Kennung ===");
const { werIch } = await import("file://" + SCHLOSS.replace(/\\/g, "/"));
{
const alt = process.env.ARBEITER;
delete process.env.ARBEITER;
const ohneName = werIch();
ok(ohneName.woher === "Sitzungskennung" && /^claude-[0-9a-f]{8}$/.test(ohneName.name),
`ohne ARBEITER kommt die Sitzungskennung ("${ohneName.name}")`);
/* DAS IST DER PUNKT, AN DEM RUNONE HÄNGT: Dort fällt es auf den
Systembenutzer zurück, und zwei Sitzungen desselben Benutzers
sehen damit gleich aus — die Sicherung griff ausgerechnet
zwischen den zwei Fällen nicht, für die sie gebaut wurde. */
ok(ohneName.name !== process.env.USERNAME,
"und nicht der Benutzername — zwei Sitzungen desselben Benutzers"
+ " muessen sich unterscheiden");
process.env.ARBEITER = "probe-name";
ok(werIch().name === "probe-name", "ein ausdrueckliches ARBEITER gewinnt");
if (alt === undefined) delete process.env.ARBEITER; else process.env.ARBEITER = alt;
}
/* ---------------------------------------------------------------------
3. Das Verhalten — im Wegwerf-Verzeichnis
--------------------------------------------------------------------- */
console.log("\n=== 3. Nehmen, sehen, freigeben ===");
const spiel = mkdtempSync(join(tmpdir(), "schlossprobe-"));
mkdirSync(join(spiel, "tools", "git-haken"), { recursive: true });
copyFileSync(SCHLOSS, join(spiel, "tools", "arbeitsschloss.mjs"));
copyFileSync(HAKEN, join(spiel, "tools", "git-haken", "pre-commit"));
try { chmodSync(join(spiel, "tools", "git-haken", "pre-commit"), 0o755); } catch { /* egal */ }
const SCHLOSSDATEI = join(spiel, ".arbeitsschloss");
const ICH = "claude-probe01";
const ANDERER = "claude-probe02";
/** Ruft das Schloss im Spielverzeichnis auf. */
function rufen(befehl, { wer = ICH, grund = "", verfall } = {}) {
const e = spawnSync(process.execPath,
[join(spiel, "tools", "arbeitsschloss.mjs"), befehl, grund],
{ cwd: spiel, encoding: "utf8",
env: { ...process.env, ARBEITER: wer,
...(verfall ? { SCHLOSS_VERFALL: String(verfall) } : {}) } });
return { stand: e.status, aus: (e.stdout || "") + (e.stderr || "") };
}
const schlossSetzen = (wer, vorMinuten = 0, was = "etwas") => writeFileSync(SCHLOSSDATEI,
`wer=${wer}\nseit=${new Date(Date.now() - vorMinuten * 60000).toISOString()}\n`
+ `was=${was}\nrechner=probe\n`);
ok(rufen("ansehen").aus.trim() === "frei", "ein leeres Verzeichnis ist frei");
{
const r = rufen("nehmen", { grund: "Probe" });
ok(r.stand === 0 && existsSync(SCHLOSSDATEI), `nehmen legt es an (${r.stand})`);
ok(/Sitzungskennung|ARBEITER/.test(r.aus), "und sagt, woher die Kennung kommt");
}
ok(rufen("ansehen").stand === 1, "danach meldet ansehen: belegt (Stand 1)");
ok(rufen("nehmen").stand === 0, "zweimal nehmen vom selben ist in Ordnung");
/* ---- DER FALL, DER ALLES ENTSCHEIDET -------------------------------
`fremd` muss zwischen „ich halte es" und „ein anderer haelt es"
unterscheiden. RunOnes erste Fassung konnte das nicht und hat damit
genau das richtige Verhalten bestraft. */
ok(rufen("fremd").stand === 0, "fremd: mein EIGENES Schloss blockiert mich nicht");
schlossSetzen(ANDERER);
ok(rufen("fremd").stand === 1, "fremd: ein FREMDES blockiert (Stand 1)");
ok(rufen("nehmen").stand === 1, "nehmen prallt an einem fremden ab");
{
const r = rufen("freigeben");
ok(r.stand === 1 && /gehört/.test(r.aus),
"und ein fremdes gibt man nicht einfach frei");
}
/* ---- ES VERFÄLLT. Sonst blockiert ein Absturz dauerhaft. ---------- */
schlossSetzen(ANDERER, 180);
ok(rufen("fremd").stand === 0, "ein verfallenes fremdes Schloss blockiert NICHT mehr");
{
const r = rufen("nehmen");
ok(r.stand === 0 && /verfallen/.test(r.aus),
"es wird uebernommen — und dass da jemand war, steht dabei");
}
schlossSetzen(ANDERER, 180);
ok(rufen("fremd", { verfall: 400 }).stand === 1,
"Gegenprobe: mit laengerer Frist gilt dasselbe Schloss wieder (SCHLOSS_VERFALL)");
/* ---- UNLESBAR IST NICHT BELEGT ------------------------------------
Ein kaputtes Schloss darf die Arbeit nicht anhalten. */
writeFileSync(SCHLOSSDATEI, "das ist kein Schloss\n");
{
const r = rufen("fremd");
ok(r.stand === 0, `ein unbrauchbarer Inhalt blockiert nicht (${r.stand})`);
}
writeFileSync(SCHLOSSDATEI, `wer=${ANDERER}\nseit=voelliger-unsinn\nwas=x\n`);
ok(rufen("fremd").stand === 0,
"und ein unlesbarer Zeitstempel gilt als verfallen, nicht als eben genommen");
/* ---- Freigeben ---------------------------------------------------- */
schlossSetzen(ICH);
ok(rufen("freigeben").stand === 0 && !existsSync(SCHLOSSDATEI),
"das eigene gibt man frei, und danach ist die Datei weg");
ok(rufen("freigeben").stand === 0, "zweimal freigeben ist kein Fehler");
/* ---------------------------------------------------------------------
4. Der git-Haken — am echten git, nicht an der Behauptung
--------------------------------------------------------------------- */
console.log("\n=== 4. Der Haken am echten git ===");
{
const git = (...a) => spawnSync("git", a, { cwd: spiel, encoding: "utf8",
env: { ...process.env, ARBEITER: ICH } });
git("init", "-q");
git("config", "user.email", "[email protected]");
git("config", "user.name", "Probe");
git("config", "core.hooksPath", "tools/git-haken");
writeFileSync(join(spiel, "a.txt"), "eins\n");
git("add", "-A");
const erst = git("commit", "-q", "-m", "erster");
ok(erst.status === 0, `ohne Schloss laesst der Haken committen (${erst.status})`);
writeFileSync(join(spiel, "a.txt"), "zwei\n");
schlossSetzen(ICH);
git("add", "-A");
const eigen = git("commit", "-q", "-m", "mit eigenem Schloss");
ok(eigen.status === 0,
`mit dem EIGENEN Schloss ebenfalls (${eigen.status}) — sonst waere ordentliches`
+ " Abschliessen bestraft");
writeFileSync(join(spiel, "a.txt"), "drei\n");
schlossSetzen(ANDERER, 0, "arbeitet gerade");
git("add", "-A");
const fremd = git("commit", "-q", "-m", "darf nicht entstehen");
const text = (fremd.stdout || "") + (fremd.stderr || "");
ok(fremd.status === 1, `mit einem FREMDEN Schloss bricht der Commit ab (${fremd.status})`);
ok(/arbeitet gerade jemand anders/.test(text), "und sagt, warum");
ok(/claude-probe02/.test(text), "und WER es ist");
/* UND DIE ZAHL DER COMMITS BEWEIST ES. Eine Meldung allein könnte
auch bei einem erfolgreichen Commit erscheinen. */
const anzahl = Number(spawnSync("git", ["rev-list", "--count", "HEAD"],
{ cwd: spiel, encoding: "utf8" }).stdout.trim());
ok(anzahl === 2, `es stehen genau zwei Commits da, nicht drei (${anzahl})`);
/* DER NOTAUSGANG MUSS GEHEN. Ein Riegel ohne Notausgang wird
ausgebaut, nicht benutzt. */
const zwang = spawnSync("git", ["commit", "-q", "-m", "mit Zwang"],
{ cwd: spiel, encoding: "utf8",
env: { ...process.env, ARBEITER: ICH, SCHLOSS_ZWANG: "ja" } });
ok(zwang.status === 0, `SCHLOSS_ZWANG=ja kommt vorbei (${zwang.status})`);
/* UND OHNE DAS SCHLOSS-WERKZEUG DARF ER NICHT BLOCKIEREN: Wer den
Haken in ein Verzeichnis ohne Schloss mitnimmt, soll arbeiten
koennen. */
rmSync(join(spiel, "tools", "arbeitsschloss.mjs"));
schlossSetzen(ANDERER);
writeFileSync(join(spiel, "a.txt"), "vier\n");
git("add", "-A");
const ohneWerkzeug = git("commit", "-q", "-m", "ohne Werkzeug");
ok(ohneWerkzeug.status === 0,
`ohne das Schloss-Werkzeug laesst der Haken durch (${ohneWerkzeug.status})`);
}
/* ---------------------------------------------------------------------
5. Die Standwache — hat sich der Stand WAEHREND des Laufs geaendert?
Das Schloss schuetzt den Commit und die Stempellaeufe. Es schuetzt
NICHT die Pruefläufe: Wer misst, waehrend ein anderer speichert,
misst einen Stand, den es nicht mehr gibt. Am 06./07.09.2026 sind
so fuenf Gesamtlaeufe wertlos geworden, rund zwei Stunden
Wartezeit ohne einen einzigen Befund.
DIE FRAGE IST EINE MESSUNG, KEINE ZUSTAENDIGKEIT. „Haelt jemand
das Schloss" waere die falsche: Sie blockiert zu viel (zwei
Stunden Arbeit am Schloss = zwei Stunden ohne Prueflauf) und zu
wenig (Filipe am Editor hat kein Schloss). Gefragt wird deshalb:
Ist der Stand am Ende noch derselbe?
--------------------------------------------------------------------- */
console.log("\n=== 5. Die Standwache ===");
{
const WACHE = join(WURZEL, "server", "helfer-standwache.mjs");
ok(existsSync(WACHE), "server/helfer-standwache.mjs ist da");
/* AN DER NOTBREMSE UND NICHT IN 179 DATEIEN. Abgeleitet aus dem
Quelltext: Wer eine Notbremse hat, bekommt die Wache mit. */
const bremse = readFileSync(join(WURZEL, "server", "helfer-notbremse.mjs"), "utf8");
ok(/standWache\(/.test(bremse) && /helfer-standwache\.mjs/.test(bremse),
"die Notbremse ruft die Standwache — eine Stelle fuer alle Pruefungen");
{
const alle = readdirSync(join(WURZEL, "server"))
.filter((n) => /^(pruef|mess)-.*\.mjs$/.test(n));
const mit = alle.filter((n) =>
/notbremse\(/.test(readFileSync(join(WURZEL, "server", n), "utf8")));
ok(mit.length > 150,
`${mit.length} von ${alle.length} Pruef- und Messdateien haben damit eine Standwache`);
}
/* ---- Im Wegwerf-Verzeichnis, mit einem gefaelschten Baum --------
Moeglich, weil die Wache ihre Wurzel aus ihrem EIGENEN Pfad
ableitet. Eine festgeschriebene Wurzel waere nicht pruefbar,
ohne am echten Haus zu wackeln. */
const spiel2 = mkdtempSync(join(tmpdir(), "standprobe-"));
mkdirSync(join(spiel2, "server"), { recursive: true });
mkdirSync(join(spiel2, "workspace"), { recursive: true });
copyFileSync(WACHE, join(spiel2, "server", "helfer-standwache.mjs"));
writeFileSync(join(spiel2, "workspace", "a.css"), "a{color:red}\n");
const w = await import("file://"
+ join(spiel2, "server", "helfer-standwache.mjs").replace(/\\/g, "/"));
const eins = w.standNehmen();
/* ZWEI, NICHT EINE: workspace/a.css UND die Kopie der Wache selbst,
die im server-Ordner liegt. Der server-Ordner zaehlt mit, und das
ist richtig -- eine geaenderte Pruefdatei verschiebt den Stand
genauso wie eine geaenderte Seite. (Ich hatte hier 1 erwartet;
die Wache hatte recht, nicht ich.) */
ok(eins.anzahl === 2,
`der gefaelschte Baum hat zwei zaehlende Dateien (${eins.anzahl})`
+ " -- die Seite und die Wache selbst");
ok(w.standPruefen(eins, "Probe") === true,
"ohne Aenderung meldet sie nichts");
/* ZWEIMAL HINTEREINANDER GLEICH. Ohne das waere jede Meldung
wertlos — eine Wache, die bei sich selbst anschlaegt, ist eine
Warnung, die immer kommt. */
ok(JSON.stringify({ ...w.standNehmen(), genommen: 0 })
=== JSON.stringify({ ...eins, genommen: 0 }),
"zwei Aufnahmen hintereinander sind gleich");
/* ---- UND JETZT SPEICHERT JEMAND ------------------------------- */
writeFileSync(join(spiel2, "workspace", "a.css"), "a{color:blue;font-size:2rem}\n");
ok(w.standPruefen(eins, "Probe") === false,
"eine geaenderte Datei faellt auf");
const zwei = w.standNehmen();
writeFileSync(join(spiel2, "workspace", "b.js"), "// neu\n");
ok(w.standPruefen(zwei, "Probe") === false, "eine NEUE Datei ebenfalls");
/* ---- BILDER ZAEHLEN NICHT ------------------------------------
Die Messdateien legen selbst welche ab (`server/mess-*.png`).
Eine Wache, die bei jedem Bildschirmfoto anschlaegt, wird
weggeklickt und nimmt die echte Meldung mit. */
const drei = w.standNehmen();
writeFileSync(join(spiel2, "workspace", "bild.png"), "nicht wirklich ein Bild");
ok(w.standPruefen(drei, "Probe") === true,
"ein neues Bild faellt NICHT auf — Messungen legen selbst welche ab");
/* ---- DER RUECKGABEWERT IST 3, NICHT 1 ------------------------
„Nicht in Ordnung" waere eine Aussage ueber den Code, und die
hat so ein Lauf nicht gemacht. 3 ist der dritte Ausgang.
Geprueft am echten Verhalten: ein eigener Prozess. */
{
const script = join(spiel2, "lauf.mjs");
writeFileSync(script,
'import { standWache } from "./server/helfer-standwache.mjs";\n'
+ 'import { writeFileSync } from "node:fs";\n'
+ 'import { join, dirname } from "node:path";\n'
+ 'import { fileURLToPath } from "node:url";\n'
+ 'standWache("Probelauf");\n'
+ 'const hier = dirname(fileURLToPath(import.meta.url));\n'
+ 'writeFileSync(join(hier, "workspace", "c.css"), "c{}\\n");\n');
const e = spawnSync(process.execPath, [script], { cwd: spiel2, encoding: "utf8" });
ok(e.status === 3,
`ein Lauf, bei dem sich der Stand aendert, endet mit 3 (${e.status})`);
ok(/NICHT NACHSEHBAR/.test(e.stderr || ""), "und sagt NICHT NACHSEHBAR");
ok(/KEIN BEFUND AM CODE/.test(e.stderr || ""),
"und ausdruecklich, dass das kein Befund am Code ist");
/* GEGENPROBE: Ohne Aenderung bleibt es bei 0. Ohne diese Zeile
waere „endet mit 3" auch dann wahr, wenn sie IMMER 3 meldet. */
writeFileSync(script,
'import { standWache } from "./server/helfer-standwache.mjs";\n'
+ 'standWache("Probelauf ohne Aenderung");\n');
const e2 = spawnSync(process.execPath, [script], { cwd: spiel2, encoding: "utf8" });
ok(e2.status === 0, `ein Lauf ohne Aenderung endet mit 0 (${e2.status})`);
}
try { rmSync(spiel2, { recursive: true, force: true }); } catch { /* Rest */ }
}
try { rmSync(spiel, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
console.log(`\n${ok_} Pruefungen, ${fehl} Fehler, ${offen} nicht nachsehbar`);
console.log(fehl ? "NICHT IN ORDNUNG\n" : "ALLES IN ORDNUNG\n");
process.exit(fehl ? 1 : 0);
+226 -3
View File
@@ -75,6 +75,9 @@ const keks = async (rolle, code) => {
};
const kScout = await keks("scout", "CODE-SCOU-0001");
const kTili = await keks("creator", "CODE-ANNA-0001");
/* DogFather darf als Einziger „dauerhaft" setzen -- deshalb braucht
diese Pruefung seit dem 02.10.2026 auch seinen Zugang. */
const kDogi = await keks("admin", "CODE-DOGI-0001");
const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } });
const senden = (pfad, k, art, koerper) => ruf(pfad, k, {
method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) });
@@ -234,6 +237,119 @@ console.log("\n=== Von der Vorlage zur Aufgabe");
`alle ${sollen} einer Stufe auf einmal (${vorher} → ${nachher})`);
}
/* ---------- 2b. Frist, „dauerhaft" und eine Anmerkung -------------------- */
/* =========================================================================
VanVan im Support, Meldung #6, zweite Haelfte: „Bei den Vorlagen laesst
sich weder die Frist anpassen noch ‚dauerhaft' einstellen, und eine
Anmerkung fehlt auch."
GEPRUEFT WIRD AM SERVER, nicht am Fenster. Das Fenster bietet die drei
Felder an; was ankommt, ist trotzdem eine Zeichenkette aus dem Netz.
========================================================================= */
console.log("\n=== Frist, dauerhaft und Anmerkung");
{
const in20 = (() => {
const d = new Date(Date.now() + 20 * 86400000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
})();
/* ---- Eine eigene Frist --------------------------------------------- */
const mitFrist = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "technik", stufe: "fort", nr: 0,
creator_id: idTili, frist: in20 });
const nachFrist = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.technik.fort[0].titel);
ok(mitFrist.ok && nachFrist?.frist === in20,
`eine eigene Frist kommt durch (${nachFrist?.frist}, gewollt ${in20})`);
/* GEGENPROBE: Die Vorlage selbst sieht eine ANDERE Frist vor. Ohne
diesen Vergleich waere die Zeile oben auch dann gruen, wenn der
Server die Angabe verwirft und zufaellig dasselbe herauskommt. */
ok(V.technik.fort[0].tage !== 20,
` und die Vorlage selbst saehe ${V.technik.fort[0].tage} Tage vor — `
+ "die Angabe hat also wirklich gewirkt");
/* ---- Eine Anmerkung wird zur ersten Notiz --------------------------- */
const satz = "Bitte zuerst mit Rieke absprechen.";
const mitNotiz = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "technik", stufe: "fort", nr: 1,
creator_id: idTili, anmerkung: satz });
const mitNotizA = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.technik.fort[1].titel);
ok(mitNotiz.ok && mitNotizA?.notizen === 1,
`die Anmerkung wird eine Notiz an der Aufgabe (${mitNotizA?.notizen})`);
const nz = await (await ruf(`/workspace/api/aufgaben/${mitNotizA?.id}/notizen`, kTili)).json();
ok((nz.notizen || []).length === 1 && nz.notizen[0].text === satz,
` und zwar woertlich („${(nz.notizen || [])[0]?.text}")`);
/* SIE TRAEGT, VON WEM SIE STAMMT -- in die Beschreibung geschrieben
waere das verloren, und beim naechsten Mal haette dieselbe Vorlage
einen anderen Text. */
ok(!!(nz.notizen || [])[0]?.von,
` mit dem Namen dessen, der sie geschrieben hat (${(nz.notizen || [])[0]?.von})`);
/* ---- OHNE ANGABE AENDERT SICH NICHTS -------------------------------- */
const ohne = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "technik", stufe: "fort", nr: 2,
creator_id: idTili });
const ohneA = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.technik.fort[2].titel);
ok(ohne.ok && ohneA?.notizen === 0 && !ohneA?.dauerhaft && !!ohneA?.frist,
`ohne Angabe bleibt alles wie bisher (${ohneA?.notizen} Notizen, `
+ `dauerhaft ${ohneA?.dauerhaft || 0}, Frist ${ohneA?.frist})`);
/* ---- „Dauerhaft" darf nur, wer verteilen darf ------------------------ */
const alsScout = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "fort", nr: 0,
creator_id: idTili, dauerhaft: true });
ok(alsScout.status === 403,
`ein Scout kann keine dauerhafte Aufgabe anlegen (${alsScout.status})`);
/* UND ES ENTSTEHT AUCH KEINE OHNE DEN HAKEN. Eine Absage, die
trotzdem etwas anlegt, waere schlimmer als gar keine. */
const nixDa = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.filter((x) => x.titel === V.live.fort[0].titel).length;
ok(nixDa === 0, ` und die Absage legt auch nichts an (${nixDa})`);
const alsChef = await senden("/workspace/api/vorlagen/uebernehmen", kDogi, "POST",
{ art: "aufgabe", bereich: "live", stufe: "fort", nr: 0,
creator_id: idTili, dauerhaft: true, frist: in20 });
const dauerA = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.live.fort[0].titel);
ok(alsChef.ok && !!dauerA?.dauerhaft,
`DogFather kann es (${alsChef.status}, dauerhaft ${dauerA?.dauerhaft})`);
/* UND DANN GIBT ES KEINE FRIST -- obwohl eine mitgeschickt wurde.
Eine dauerhafte Aufgabe mit Frist waere ab morgen fuer immer
ueberfaellig, und eine Warnung, die immer kommt, ist keine mehr.
Dieselbe Regel steht im Aenderungsweg; haette sie hier gefehlt,
gaebe es zwei Antworten auf dieselbe Frage. */
ok(alsChef.ok && !dauerA?.frist,
` und sie hat KEINE Frist, auch wenn eine mitkam (${dauerA?.frist ?? "keine"})`);
/* ---- Was der Server nicht annimmt ----------------------------------- */
const krumm = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0,
creator_id: idTili, frist: "morgen" });
ok(krumm.status === 400, `„morgen" ist kein Datum (${krumm.status})`);
const unmoeglich = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0,
creator_id: idTili, frist: "2026-13-45" });
ok(unmoeglich.status === 400, `den 45. des 13. Monats gibt es nicht (${unmoeglich.status})`);
const vorbei = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0,
creator_id: idTili, frist: "2020-01-01" });
ok(vorbei.status === 400,
`eine Frist in der Vergangenheit wird abgelehnt (${vorbei.status}) — sonst waere `
+ "die Aufgabe in dem Moment rot, in dem sie entsteht");
/* GEGENPROBE ZU DEN DREI ABSAGEN: Dieselbe Vorlage OHNE Frist geht
durch. Ohne sie koennte die Route alles ablehnen und waere dreimal
gruen. */
const dochGut = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0, creator_id: idTili });
ok(dochGut.ok, `Gegenprobe: dieselbe Vorlage ohne Frist geht durch (${dochGut.status})`);
}
/* ---------- 3. Gegenproben ------------------------------------------------ */
console.log("\n=== Gegenproben");
{
@@ -359,14 +475,35 @@ console.log("\n=== Auf der Aufgabenseite");
const nachher = await seite.textContent("#vorlagenbrett .vorlagenbrett__titel");
ok(vorher !== nachher, `ein anderer Bereich zeigt andere Aufgaben ("${vorher}" → "${nachher}")`);
/* Und der Weg bis zum Brett. */
const zaehle = () => seite.evaluate(() => document.querySelectorAll("#brett .karte").length);
/* Und der Weg bis zum Brett.
GEZAEHLT WERDEN AUFGABEN, NICHT KARTEN (berichtigt 23.09.2026).
Seit dem Brett-Umbau vom selben Tag fasst das Brett gleiche
Aufgaben zu EINER Karte zusammen. Eine neue Aufgabe erhoeht die
Kartenzahl also nur dann, wenn es noch keine gleichnamige gibt --
und die Pruefung legt kurz vorher sieben Vorlagen derselben Stufe
an. Sie meldete "9 -> 9" und behauptete damit, das Uebernehmen sei
kaputt; es war die Zaehlweise. Die Aufgabe war jede Zeit da, drei
Zeilen weiter wird sie sogar als "schon übernommen" wiedererkannt.
Dieselbe Stelle gab es ein zweites Mal in pruef-modi-katalog. Dort
ist sie am 23.09. repariert worden -- hier nicht, weil ich nach
dem ersten Fund nicht weitergesucht habe. Jede Karte weist ihre
Aufgaben aus: data-id (eine) oder data-ids (mehrere). */
const zaehle = () => seite.evaluate(() =>
[...document.querySelectorAll("#brett .karte")]
.reduce((n, k) => n + (k.dataset.ids || k.dataset.id || "")
.split(",").filter(Boolean).length, 0));
const zaehleKarten = () => seite.evaluate(() =>
document.querySelectorAll("#brett .karte").length);
const brettVorher = await zaehle();
await seite.click("#vorlagenbrett .vorlagenbrett__fuss .knopf");
await seite.waitForTimeout(2500);
const brettNachher = await zaehle();
ok(brettNachher === brettVorher + 1,
`Übernehmen legt eine Aufgabe auf dem Brett an (${brettVorher} → ${brettNachher})`);
`Übernehmen legt eine Aufgabe auf dem Brett an (${brettVorher} → ${brettNachher}`
+ ` Aufgaben in ${await zaehleKarten()} Karten)`);
/* ---- WAS SCHON GEHOLT WURDE, STEHT DA (09.09.2026) -----------------
Vorher konnte man dieselbe Vorlage zweimal uebernehmen und bekam
@@ -395,6 +532,92 @@ console.log("\n=== Auf der Aufgabenseite");
`die übrigen ${m.gesamt - m.markiert} sind nicht markiert`);
}
/* ---- ANPASSEN: FRIST UND ANMERKUNG AUS DEM FENSTER (02.10.2026) ---
VanVan im Support, Meldung #6: „Bei den Vorlagen laesst sich weder
die Frist anpassen noch ‚dauerhaft' einstellen, und eine Anmerkung
fehlt auch."
DIE SERVERSEITE STEHT WEITER OBEN -- hier geht es um den Weg, den
SIE nimmt: Knopf, Fenster, Felder, und ob am Ende wirklich die
Frist dransteht, die sie eingetragen hat. Ein Server, der die
Angabe annimmt, hilft niemandem, dessen Fenster sie nicht
mitschickt. */
{
const anpassen = seite.locator("#vorlagenbrett .vorlagenbrett__anpassen").first();
ok(await anpassen.count() > 0, "jede Karte hat einen „Anpassen …\"-Knopf");
await anpassen.click();
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 8000 });
const felder = await seite.evaluate(() => {
const dlg = document.querySelector("dialog.nachfrage");
const dz = dlg.querySelector('[data-teil="datumzeile"]');
const hz = dlg.querySelector('[data-teil="hakenzeile"]');
const gz = dlg.querySelector('[data-teil="grundzeile"]');
return {
datum: !!dz && !dz.hidden,
datumWert: dlg.querySelector("#nachfrage-datum")?.value || "",
datumMin: dlg.querySelector("#nachfrage-datum")?.min || "",
haken: !!hz && !hz.hidden,
grund: !!gz && !gz.hidden,
};
});
ok(felder.datum && felder.grund,
`das Fenster hat ein Datum und ein Anmerkungsfeld (${felder.datum}, ${felder.grund})`);
/* DIE VORGABE IST DIE FRIST DER VORLAGE -- ein leeres Feld hiesse
„rechne selbst, in wie vielen Tagen", und genau das Rechnen
nimmt die Vorlage einem ab. */
ok(/^\d{4}-\d{2}-\d{2}$/.test(felder.datumWert) && felder.datumWert > felder.datumMin,
`mit der Frist der Vorlage als Vorgabe (${felder.datumWert}, frühestens `
+ `${felder.datumMin})`);
/* EIN CREATOR SIEHT DEN HAKEN NICHT. „Dauerhaft" darf nur, wer
verteilen darf -- und ein Knopf, der eine Absage holt, ist
schlimmer als keiner. Der Server lehnt es ohnehin ab (oben
gemessen); hier wird geprueft, dass er gar nicht erst angeboten
wird. */
ok(felder.haken === false,
`und ein Creator bekommt den „dauerhaft\"-Haken NICHT (${felder.haken})`);
const in9 = await seite.evaluate(() => {
const d = new Date(Date.now() + 9 * 86400000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
});
await seite.fill("dialog.nachfrage #nachfrage-datum", in9);
await seite.fill("dialog.nachfrage #nachfrage-grund", "Erst nach dem Umzug anfangen.");
const vorAn = await zaehle();
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(2500);
const nachAn = await zaehle();
ok(nachAn === vorAn + 1,
`„Anpassen" legt genauso eine Aufgabe an (${vorAn} → ${nachAn})`);
/* UND JETZT DIE EIGENTLICHE FRAGE: Steht die Frist dran, die sie
eingetragen hat -- und die Anmerkung? Gefragt wird die
Schnittstelle, nicht die Karte: Was auf dem Brett steht, ist
eine Anzeige; was in der Aufgabe steht, ist die Tatsache. */
const alleA = (await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [];
const frisch = alleA.filter((x) => x.frist === in9);
ok(frisch.length === 1,
`und sie trägt die eingetragene Frist (${frisch.length} Aufgabe mit ${in9})`);
ok(frisch[0]?.notizen === 1,
` samt der Anmerkung als Notiz (${frisch[0]?.notizen})`);
const nz2 = await (await ruf(`/workspace/api/aufgaben/${frisch[0]?.id}/notizen`, kTili)).json();
ok(/nach dem Umzug/.test((nz2.notizen || [])[0]?.text || ""),
` mit ihrem Wortlaut („${(nz2.notizen || [])[0]?.text}")`);
/* GEGENPROBE: Abbrechen legt NICHTS an. Ohne sie koennte das
Fenster beim Wegklicken trotzdem verteilen -- und davon merkt
man erst etwas, wenn die Aufgabe irgendwo auftaucht. */
await anpassen.click();
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 8000 });
const vorAb = await zaehle();
await seite.click('dialog.nachfrage [data-teil="nein"]');
await seite.waitForTimeout(1200);
ok(await zaehle() === vorAb,
`Gegenprobe: Abbrechen legt nichts an (${vorAb} → ${await zaehle()})`);
}
/* Und der Zustand des Blocks überlebt ein Neuladen -- wer Vorlagen
holt, holt meistens mehrere. */
await seite.reload({ waitUntil: "domcontentloaded" });
+1 -1
View File
@@ -234,7 +234,7 @@ const { chromium } = await import(
const browser = await chromium.launch();
async function seiteAls(rolle, code, breite = 1280) {
const kontext = await browser.newContext({ viewport: { width: breite, height: 1100 } });
const kontext = await browser.newContext({ viewport: { width: breite, height: 1100 }, hasTouch: breite <= 860 });
const konsole = [];
const seite = await kontext.newPage();
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+2 -1
View File
@@ -45,6 +45,7 @@ const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
import { heuteLokal } from "./helfer-tag.mjs";
await import("./index.js");
notbremse(180_000, "pruef-auskunft");
await new Promise((r) => setTimeout(r, 700));
@@ -91,7 +92,7 @@ let idDogi, idRieke, idGast;
treff_entfernt.autor_id (ebenfalls ohne). */
d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ " VALUES ('treff','hallo','KESSI-EINTRAG','Hallo zusammen',?,?,?)")
.run(jetzt.slice(0, 10), jetzt, idGast);
.run(heuteLokal(), jetzt, idGast);
const raum = (() => {
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe','Testraum',?,?)")
.run(jetzt, idDogi);
+19 -4
View File
@@ -990,9 +990,21 @@ melde("=== 8. Die Analyse im Browser ===");
Lesen sieht -- beide Regeln wirken einzeln vernuenftig. Nur eine
Messung findet ihn wieder, wenn jemand an einem der beiden
Abstaende dreht. */
const enge = await seite.setViewportSize({ width: 430, height: 900 })
.then(() => seite.waitForTimeout(500))
.then(() => seite.evaluate(() => {
/* EIN EIGENER KONTEXT MIT FINGER (29.09.2026).
Hier stand `seite.setViewportSize({ width: 430 })`. Das aendert
nur die Groesse: `hasTouch` gehoert zum KONTEXT und laesst sich
danach nicht mehr setzen. Ohne ihn meldet der Browser
`pointer: fine`, und KEINE Regel aus `@media (pointer: coarse)`
greift. Gerade hier zaehlt das: Hoehere Beruehrziele stehen
enger beieinander, und ob Text auf Text liegt, haengt daran. */
const engKontext = await browser.newContext({
viewport: { width: 430, height: 900 }, hasTouch: true, isMobile: true });
await engKontext.addCookies(await kontext.cookies());
const engSeite = await engKontext.newPage();
const enge = await engSeite.goto(seite.url(), { waitUntil: "networkidle" })
.then(() => engSeite.waitForTimeout(500))
.then(() => engSeite.evaluate(() => {
const kandidaten = [...document.querySelectorAll("body *")].filter((e) => {
const r = e.getBoundingClientRect();
return r.width > 20 && r.height > 6
@@ -1022,7 +1034,10 @@ melde("=== 8. Die Analyse im Browser ===");
enge.treffer.length
? `Text liegt auf Text: ${enge.treffer.slice(0, 2).join("; ")}`
: `bei 430 px liegt kein Text auf Text (${enge.geprueft} Absätze verglichen)`);
await seite.setViewportSize({ width: 1280, height: 1000 });
/* Das enge Fenster war ein eigener Kontext und geht hier zu.
`seite` war nie schmal -- das fruehere Zurueckstellen auf 1280
ist damit hinfaellig. */
await engKontext.close();
if (process.env.SCHIRM) {
/* Zielt seit dem 22.09.2026 auf das Gesamtresuemee -- den Block
darunter gibt es nicht mehr. Ohne diese Zeile brach der Lauf
+199
View File
@@ -0,0 +1,199 @@
/* =====================================================================
pruef-bewegung.mjs — keine endlose Bewegung auf etwas, das niemand
sieht.
---------------------------------------------------------------------
WARUM ES DIESE PRÜFUNG GIBT
Am 30.09.2026 scheiterte `pruef-browser` in WebKit daran, dass der
Anmeldeknopf nie „ruhig" wurde. Gemessen wanderte sein Kasten pro
Bild um ein Fünftel Pixel, und zwei Bewegungen liefen dabei. Eine
davon war:
.knopf__laden { opacity: 0; animation: dreh .8s linear infinite; }
.knopf[disabled] .knopf__laden { opacity: 1; }
Nur die Deckkraft schaltete um. Die Drehung lief ab dem Laden der
Seite, auf JEDEM Knopf, für immer — nicht zu sehen und trotzdem
jedes Bild neu gerechnet.
Dieselbe Suche fand einen zweiten: Auf der öffentlichen Website lief
an jedem Navigationspunkt eine elf Sekunden lange Aurora, unsichtbar
bis zum Überfahren.
Beides fällt niemandem auf, weil nichts abstürzt und nichts falsch
aussieht. Es kostet nur Rechenzeit und Akku — auf genau den Seiten,
die am häufigsten offen sind.
---------------------------------------------------------------------
WAS GEPRÜFT WIRD
1. KEINE ENDLOSE BEWEGUNG IM SELBEN BLOCK wie `opacity: 0`,
`display: none` oder `visibility: hidden`.
Die Bewegung gehört dorthin, wo das Ding sichtbar wird — also
in die Regel mit dem Zustand (`[disabled]`, `:hover`, `.offen`).
2. JEDE STILVORLAGE mit endloser Bewegung kennt die Hausregel
`prefers-reduced-motion`. Gemessen am 30.09.: alle 42 tun es.
Die Zeile hält diesen Stand fest, statt ihn dem Zufall zu
überlassen.
---------------------------------------------------------------------
DREI AUSGÄNGE
Findet sie keine Stilvorlagen, ist das „konnte nicht nachsehen"
(Rückgabe 2) und nicht „alles in Ordnung". Eine Prüfung, die ihre
Voraussetzung nicht hat, muss das sagen.
Aufrufen mit: node server/pruef-bewegung.mjs
===================================================================== */
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HIER = dirname(fileURLToPath(import.meta.url));
const REPO = join(HIER, "..");
/* BEIDE HÄUSER UND DIE ÖFFENTLICHE SEITE. Der erste Fund lag im
Workspace, der zweite auf der Website — eine Prüfung, die nur einen
der beiden Orte kennt, hätte die Hälfte gefunden. */
const ORTE = [
join(REPO, "workspace", "assets", "css"),
join(REPO, "assets", "css"),
];
let ok = 0, fehl = 0;
const pruef = (name, bedingung, zusatz = "") => {
if (bedingung) { ok++; console.log(` ok ${name}`); }
else { fehl++; console.log(` FEHL ${name}${zusatz ? "\n " + zusatz : ""}`); }
};
/* Kommentare weg -- sonst zählt jeder Satz, der das Wort erklärt.
Genau diese Falle hat am 29.09. eine andere Prüfung dreimal
getroffen: Sie las ihre eigenen Beispieltexte als Befunde. */
const ohneKommentar = (t) => t.replace(/\/\*[\s\S]*?\*\//g, "");
/** Alle Regelblöcke einer Stilvorlage: Selektor + Körper.
*
* Verschachtelte Blöcke (`@media`) fallen dabei auseinander — das ist
* hier richtig: Gefragt ist, was IN einem Regelblock zusammensteht,
* nicht wo er liegt. */
const REGEL = /([^{}]+)\{([^{}]*)\}/g;
const UNSICHTBAR = [
[/opacity:\s*0\s*(?:;|$)/m, "opacity: 0"],
[/display:\s*none/, "display: none"],
[/visibility:\s*hidden/, "visibility: hidden"],
];
const ENDLOS = /\binfinite\b/;
function dateien() {
const liste = [];
for (const ort of ORTE) {
if (!existsSync(ort)) continue;
for (const f of readdirSync(ort)) {
if (f.endsWith(".css")) liste.push(join(ort, f));
}
}
return liste;
}
/** Findet in einem Stilblatt-Text die Blöcke, die beides haben. */
export function blindeBewegung(text) {
const treffer = [];
const t = ohneKommentar(text);
let m;
REGEL.lastIndex = 0;
while ((m = REGEL.exec(t)) !== null) {
const sel = m[1].trim().replace(/\s+/g, " ");
const koerper = m[2];
if (!ENDLOS.test(koerper)) continue;
for (const [muster, wort] of UNSICHTBAR) {
if (muster.test(koerper)) { treffer.push({ sel, wort }); break; }
}
}
return treffer;
}
console.log("\n=== Endlose Bewegung auf etwas Unsichtbarem ===");
const alle = dateien();
if (alle.length < 10) {
/* DER DRITTE AUSGANG. Keine Stilvorlagen heißt „konnte nicht
nachsehen" — nicht „alles in Ordnung". */
console.log(` KONNTE NICHT NACHSEHEN: nur ${alle.length} Stilvorlagen gefunden.`);
process.exit(2);
}
console.log(` ${alle.length} Stilvorlagen in beiden Häusern und auf der Website.`);
const funde = [];
let mitEndlos = 0;
const ohneRuhe = [];
for (const p of alle) {
const text = readFileSync(p, "utf8");
const t = ohneKommentar(text);
if (ENDLOS.test(t)) {
mitEndlos++;
if (!/prefers-reduced-motion/.test(t)) ohneRuhe.push(p.split(/[\\/]/).pop());
}
for (const f of blindeBewegung(text)) {
funde.push(`${p.split(/[\\/]/).pop()}: ${f.sel.slice(0, 54)} (${f.wort})`);
}
}
/* DIE ZAHL GEHÖRT IN DIE BEDINGUNG. Fände die Prüfung gar keine
endlose Bewegung, wäre „keine blinde" trivial wahr — und bliebe es,
wenn jemand die Suche kaputt macht. */
pruef(`${mitEndlos} Stilvorlagen haben überhaupt eine endlose Bewegung`,
mitEndlos >= 5, `nur ${mitEndlos} — sucht die Prüfung noch richtig?`);
pruef("keine endlose Bewegung auf etwas Unsichtbarem",
funde.length === 0, funde.slice(0, 8).join("\n "));
pruef("jede davon kennt die Bewegungsarmut-Regel",
ohneRuhe.length === 0,
`ohne prefers-reduced-motion: ${ohneRuhe.join(", ")}`);
/* ---------------------------------------------------------------------
DIE GEGENPROBEN — jede muss anschlagen
---------------------------------------------------------------------
Eine Prüfung, die immer bestätigt, bestätigt nichts. Die Proben
stehen hier als Text und nicht als Datei: Sie sollen genau die
Fälle abbilden, um die es geht, und nicht versehentlich mitgelesen
werden.
------------------------------------------------------------------- */
console.log("\n --- Gegenproben (jede MUSS anschlagen) ---");
const probeSchlecht = ".kreisel { opacity: 0; animation: dreh .8s linear infinite; }";
pruef("Gegenprobe: unsichtbar + endlos wird gefunden",
blindeBewegung(probeSchlecht).length === 1);
const probeGut = ".kreisel { opacity: 1; animation: dreh .8s linear infinite; }";
pruef("Gegenprobe: sichtbar + endlos ist in Ordnung",
blindeBewegung(probeGut).length === 0);
const probeStill = ".kreisel { opacity: 0; }";
pruef("Gegenprobe: unsichtbar ohne Bewegung ist in Ordnung",
blindeBewegung(probeStill).length === 0);
const probeGetrennt = ".kreisel { opacity: 0; }\n"
+ ".knopf[disabled] .kreisel { opacity: 1; animation: dreh .8s linear infinite; }";
pruef("Gegenprobe: richtig getrennt gebaut wird NICHT gemeldet",
blindeBewegung(probeGetrennt).length === 0,
"genau so gehört es: die Bewegung in der Regel, die es sichtbar macht");
const probeVerborgen = ".menue { display: none; animation: schweben 3s infinite; }";
pruef("Gegenprobe: auch `display: none` zählt",
blindeBewegung(probeVerborgen).length === 1);
const probeKommentar = "/* .kreisel { opacity: 0; animation: x 1s infinite; } */\n"
+ ".echt { color: red; }";
pruef("Gegenprobe: ein Beispiel IM KOMMENTAR zählt nicht",
blindeBewegung(probeKommentar).length === 0,
"sonst liest die Prüfung ihre eigenen Erklärungen als Befunde");
console.log(`\n${ok + fehl} Pruefungen, ${fehl} Fehler`);
console.log(fehl === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehl === 0 ? 0 : 1);
+838 -5
View File
@@ -45,6 +45,7 @@ import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const PORT = await eigenerPort(import.meta, "pruef-bewerbung-aufgaben");
const ordner = mkdtempSync(join(tmpdir(), "ws-bew-"));
@@ -460,10 +461,7 @@ melde("=== 6. Am echten Bildschirm ===");
const schluessel = join(ordner, "pruef.key");
const zertifikat = join(ordner, "pruef.crt");
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", schluessel, "-out", zertifikat, "-days", "2",
"-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
{ stdio: "ignore" });
zertifikatBauen(schluessel, zertifikat, CREW);
const HTTPS_PORT = PORT + 1;
const vorbau = httpsServer({
@@ -496,7 +494,7 @@ melde("=== 6. Am echten Bildschirm ===");
const meckern = [];
async function seiteAls(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } });
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
const s2 = await ctx.newPage();
s2.on("pageerror", (e) => meckern.push(`${rolle}: ${String(e).slice(0, 110)}`));
s2.on("console", (m) => {
@@ -520,6 +518,13 @@ melde("=== 6. Am echten Bildschirm ===");
if (!k) return null;
return {
knoepfe: [...k.querySelectorAll(".z-karte button")].map((b) => b.textContent.trim()),
/* DIE SCHRITTKNOEPFE DER KARTE (02.10.2026). Sie liegen NEBEN
dem Zuteilungsblock, nicht darin -- „kann sie etwas tun?"
laesst sich am Block allein nicht beantworten. Genau diese
Luecke hat die Pruefung hier am 02.10. rot werden lassen,
und zwar zu Recht: Die zwei Knoepfe im Block waren weg, der
richtige stand daneben und wurde nicht angesehen. */
schritte: [...k.querySelectorAll(".schritt")].map((b) => b.textContent.trim()),
bewerbungen: k.querySelectorAll(".z-bewerbung").length,
text: (k.querySelector(".z-karte")?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 200),
};
@@ -582,6 +587,719 @@ melde("=== 6. Am echten Bildschirm ===");
`und keinen Block zum Entscheiden (${b?.bewerbungen})`);
} finally { await m2.ctx.close(); }
/* =================================================================
UND JETZT WIRD ANGENOMMEN -- VanVans Meldung (30.09.2026)
„Wenn ein Modi sich auf eine Aufgabe beworben hat und die von uns
angenommen wurde, dann steht beim Modi unter der Aufgabe immer
noch ich bewerbe mich."
DIESER SCHRITT HAT HIER GEFEHLT. Die Pruefung ging den ganzen
Weg bis zur Bewerbung und hoerte dann auf. Serverseitig ist das
Annehmen weiter oben geprueft („Fridas Stand ist angenommen");
was der Mensch danach auf seinem Brett LIEST, hat nie jemand
nachgesehen.
================================================================= */
const annahme = await roh(`/workspace/api/aufgaben/${frischId}/bewerbung/annehmen`, {
methode: "POST", keks: hand.keks,
body: { person_id: idModi, kommentar: "Gern, mach das." } });
ok(annahme.code === 200 && annahme.json?.zustand === "angenommen",
`die rechte Hand nimmt Fridas Bewerbung an (${annahme.code}/${annahme.json?.zustand})`);
const m3 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const b = await knoepfeAn(m3.s, "Fuer den Bildschirm");
ok(!!b, "Frida sieht die Aufgabe weiterhin");
/* DER BEFUND. Ein Knopf, der nach der Zusage weiter „Ich
bewerbe mich" sagt, behauptet, es sei nichts entschieden --
und wer ihn drueckt, bekommt eine Fehlermeldung auf etwas,
das laengst geklappt hat. */
ok(!!b && !b.knoepfe.some((t) => /bewerbe mich/i.test(t)),
`und NICHT mehr „Ich bewerbe mich" (${b?.knoepfe.join(" | ")})`);
ok(!!b && !b.knoepfe.some((t) => /zurückziehen/i.test(t)),
"auch nichts mehr zum Zurueckziehen -- es gibt keine offene Bewerbung mehr");
/* UND DAS EIGENTLICHE: Steht da, dass sie die Aufgabe HAT?
„Kein falscher Knopf" allein waere eine leere Karte. */
ok(!!b && /(du machst|gehört dir|angenommen|übernommen|deine aufgabe)/i.test(b.text),
`sondern dass sie die Aufgabe hat ("${(b?.text || "").slice(0, 90)}…")`);
} finally { await m3.ctx.close(); }
/* =================================================================
DERSELBE WEG, ABER UEBER DAS VORLAGENBRETT -- VanVans Fall
Der Abschnitt darueber geht ueber das Aufgabenbrett
(`/api/aufgaben/:id/bewerben`). Dort war alles in Ordnung.
VanVans Weg ist der andere: Sie bewirbt sich auf eine VORLAGE.
Wird die angenommen, entsteht eine NEUE Aufgabe -- und die
bekam bis zum 30.09.2026 keine Zeile in `aufgaben_zuteilung`.
WAS DAS BEDEUTET HAT. Das Brett liest die Zuteilung, nicht
`verantwortlich_id`. Ohne Zeile sagt es „niemand zugeteilt" und
bietet an, sich auf die eigene Aufgabe zu bewerben. Und weil
„Ich fange an" und „Fertig" an derselben Zeile haengen, fehlten
sie: eine Karte, auf der man das Falsche tun kann und das
Richtige nicht.
Gemessen an den echten Daten hatten 8 von 11 Aufgaben mit einem
Verantwortlichen keine Zuteilungszeile.
================================================================= */
const vorl = await roh("/workspace/api/vorlagen", { keks: modi.keks });
/* DIE VORLAGEN STEHEN UNTER `katalog.aufgaben`. Beim ersten
Anlauf habe ich auf `gruppen` geraten und einen leeren Treffer
bekommen -- die Pruefung meldete daraufhin „konnte nicht
nachsehen", und das war die richtige Antwort auf eine falsche
Frage. */
/* DIE LETZTE UND NICHT DIE ERSTE. Ein spaeterer Abschnitt (Zeile
~999) bewirbt sich ebenfalls, und zwar auf die ERSTE Vorlage.
Nahm ich sie auch, bekam er 409 („liegt schon bei dir") -- und
zehn Pruefungen wurden rot, ohne dass am Programm etwas falsch
war. Ein Messfehler, der wie ein Befund aussieht, und einer,
den ich selbst eingebaut hatte. */
const katalogAufgaben = vorl.json?.katalog?.aufgaben || [];
const v = katalogAufgaben.at(-1);
if (!v) {
ok(false, "KONNTE NICHT NACHSEHEN: keine Vorlage gefunden "
+ `(${Object.keys(vorl.json || {}).join(", ")})`);
} else {
const vb = await roh("/workspace/api/vorlagen/bewerben", {
methode: "POST", keks: modi.keks,
body: { vorlage: v.schluessel, text: "Das mache ich gern." } });
ok(vb.code === 201 || vb.code === 200,
`Frida bewirbt sich auf die Vorlage „${v.titel}" (${vb.code})`);
const offen2 = await roh("/workspace/api/vorlagen/bewerbungen", { keks: hand.keks });
const bw = (offen2.json?.bewerbungen || []).find((x) => x.vorlage === v.schluessel);
ok(!!bw, `die rechte Hand sieht die Bewerbung (${offen2.json?.bewerbungen?.length})`);
const vann = await roh("/workspace/api/vorlagen/bewerbung/annehmen", {
methode: "POST", keks: hand.keks,
body: { id: bw?.id, kommentar: "Gern, mach das." } });
ok(vann.code === 200, `und nimmt sie an (${vann.code})`);
/* ---- DER KERN: was steht jetzt auf Fridas Brett? ---------- */
const nach = await roh("/workspace/api/aufgaben", { keks: modi.keks });
const liste = nach.json?.aufgaben || nach.json?.liste || [];
const neue = liste.find((x) => x.vorlage === v.schluessel);
ok(!!neue, `die Aufgabe steht auf ihrem Brett (${liste.length} insgesamt)`);
ok(!!neue && !!neue.meine_zuteilung,
`und sie hat eine Zuteilungszeile (${JSON.stringify(neue?.meine_zuteilung?.zustand)})`);
ok(neue?.meine_zuteilung?.zustand === "angenommen",
`im Zustand „angenommen" -- sie hat darum gebeten und sie bekommen `
+ `(${neue?.meine_zuteilung?.zustand})`);
ok(neue?.ich_beworben === false,
`und es liegt keine offene Bewerbung mehr vor (${neue?.ich_beworben})`);
const m4 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
const b = await knoepfeAn(m4.s, v.titel);
ok(!!b, "Frida sieht die neue Aufgabe mit ihrem Zuteilungsblock");
/* VANVANS BEFUND. Ein Knopf, der nach der Zusage weiter „Ich
bewerbe mich" sagt, behauptet, es sei nichts entschieden. */
ok(!!b && !b.knoepfe.some((t) => /bewerbe mich/i.test(t)),
`und NICHT „Ich bewerbe mich" (${b?.knoepfe.join(" | ")})`);
/* UND DIE ANDERE HAELFTE: Sie kann auch wirklich etwas tun.
„Kein falscher Knopf" allein waere eine Karte ohne Griff.
==== DER GRIFF IST SEIT DEM 02.10.2026 EIN ANDERER ========
Hier stand `„Ich fange an"`. Den Knopf gibt es nicht mehr --
VanVan hat gemeldet, dass er nur den halben Zustand setzte:
„Wenn man auf ich fange an drückt steht dort in Bearbeitung
… Die Aufgabe bleibt aber im status offen stehen."
Die Absicht dieser Zeile bleibt woertlich dieselbe: Kann
sie etwas tun? Nur ist es jetzt der STATUSKNOPF, der auch
bewegt, was alle anderen lesen. Dass eine Modi ihn
ueberhaupt bekommt, ist ebenfalls neu -- vorher sah sie
keinen einzigen (gemessen in pruef-zuteilung). */
ok(!!b && b.schritte.some((t) => /starten|Freigabe|erledigt/i.test(t)),
`sondern den Statusknopf (${b?.schritte.join(" | ") || "KEINER"})`);
ok(!!b && !b.knoepfe.some((t) => /fange an/i.test(t))
&& !b.knoepfe.some((t) => /^Fertig$/i.test(t)),
`und die zwei halben Knoepfe sind weg (${b?.knoepfe.join(" | ") || "keine"})`);
} finally { await m4.ctx.close(); }
/* ===============================================================
UND JETZT EINE ABSAGE -- VanVans vierte Meldung (30.09.2026)
„dann bekommt der Modi keine Benachrichtigung darüber, dass
die Aufgabe abgelehnt wurde und sieht somit auch eine
eventuelle Begründung nicht.
Außerdem haben auch wir nirgendwo eine Übersicht, bei wem wir
welche Aufgaben schon abgelehnt haben."
Bei einer ZUSAGE entsteht eine Aufgabe, und an ihr steht die
Antwort. Bei einer ABSAGE entsteht nichts -- die Begruendung
wird in `vorlagen_bewerbungen.entscheid_text` geschrieben und
danach von niemandem mehr ausgeliefert:
`vorlagenBewerbungenFuer` fragt `WHERE zustand = 'beworben'`.
Der Satz wird also verlangt, geschrieben und weggesperrt.
=============================================================== */
const v2 = katalogAufgaben.at(-2);
const vb2 = await roh("/workspace/api/vorlagen/bewerben", {
methode: "POST", keks: modi.keks,
body: { vorlage: v2.schluessel, text: "Die würde ich auch gern machen." } });
ok(vb2.code === 201 || vb2.code === 200,
`Frida bewirbt sich auf „${v2.titel}" (${vb2.code})`);
const offen3 = await roh("/workspace/api/vorlagen/bewerbungen", { keks: hand.keks });
const bw2 = (offen3.json?.bewerbungen || []).find((x) => x.vorlage === v2.schluessel);
const GRUND = "Diese Woche nicht — du hast schon drei offene.";
const vab = await roh("/workspace/api/vorlagen/bewerbung/ablehnen", {
methode: "POST", keks: hand.keks, body: { id: bw2?.id, kommentar: GRUND } });
ok(vab.code === 200, `die rechte Hand lehnt ab, mit Begruendung (${vab.code})`);
/* ---- 1. SIEHT FRIDA DIE ANTWORT? ------------------------- */
const beiFrida = await roh("/workspace/api/vorlagen", { keks: modi.keks });
/* `antworten` UND NICHT `bewerbungen`. Das alte Feld heisst im
Browser „wartet auf Antwort" -- beantwortete Zeilen darin
haetten bei einer Absage genau das angezeigt. */
const ihre = beiFrida.json?.antworten || [];
const abgelehnt = ihre.find((x) => x.vorlage === v2.schluessel);
ok(!!abgelehnt,
`Frida sieht ihre beantwortete Bewerbung (${ihre.length} insgesamt)`);
ok(abgelehnt?.zustand === "abgelehnt",
`und zwar als abgelehnt (${abgelehnt?.zustand})`);
ok(abgelehnt?.entscheid_text === GRUND,
`mit der Begruendung, die geschrieben wurde (${JSON.stringify(abgelehnt?.entscheid_text)})`);
/* ---- 2. SIEHT DIE LEITUNG, WAS SIE ABGELEHNT HAT? -------- */
const beiHand = await roh("/workspace/api/vorlagen", { keks: hand.keks });
const absagen = beiHand.json?.absagen;
const verlauf = absagen?.liste || [];
ok(verlauf.length >= 1,
`die Leitung sieht die Absagen der letzten ${absagen?.seit_tagen} Tage `
+ `(${verlauf.length})`);
const eintrag = verlauf.find((x) => x.kennung === v2.schluessel);
ok(!!eintrag && eintrag.person_name === "Frida",
`mit Namen und Aufgabe (${eintrag?.person_name} / ${eintrag?.titel})`);
ok(eintrag?.entscheid_text === GRUND,
`und der eigenen Begruendung (${JSON.stringify(eintrag?.entscheid_text)})`);
/* VANVANS EIGENTLICHE FRAGE: „Falls sich jemand immer wieder auf
Aufgaben bewirbt die immer wieder abgelehnt werden, wäre das
sinnvoll." Eine Liste beantwortet das nicht, eine Zahl je
Mensch schon. */
const frida = (absagen?.je_mensch || []).find((x) => x.name === "Frida");
ok(!!frida && frida.anzahl >= 1,
`und wie oft es je Mensch war (Frida: ${frida?.anzahl})`);
/* BEIDE WEGE STEHEN DRIN. Weiter oben hat DogFather zwei
Bewerbungen auf AUFGABEN abgelehnt. Eine Uebersicht, die nur
die Vorlagen zeigt, waere eine Auskunft, die stimmt und
taeuscht. */
ok(verlauf.some((x) => x.quelle === "aufgabe")
&& verlauf.some((x) => x.quelle === "vorlage"),
`aus beiden Wegen (${[...new Set(verlauf.map((x) => x.quelle))].join(", ")})`);
/* GEGENPROBE: Frida selbst bekommt die Uebersicht NICHT -- sie
entscheidet nicht, und eine Liste der Absagen an andere geht
sie nichts an. */
ok(beiFrida.json?.absagen === null,
`ein Modi bekommt die Uebersicht nicht (${JSON.stringify(beiFrida.json?.absagen)})`);
/* ---- UND AM ECHTEN BILDSCHIRM -----------------------------
„Der Server liefert es" waere ein gruener Haken ueber etwas,
das niemand sieht. Das Vorlagenbrett steht auf der
Aufgabenseite -- also aufmachen und nachlesen. */
const liesBrett = (seite) => seite.evaluate(() => {
const a = document.querySelector(".vorlagenbrett__antwort");
const u = document.querySelector(".vorlagenbrett__absagen");
return {
antwort: a ? (a.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
zeilen: a ? a.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
absagen: u ? (u.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
zugeklappt: u ? !u.open : null,
leute: u ? [...u.querySelectorAll(".vorlagenbrett__absagen-mensch")]
.map((x) => x.textContent.trim()) : [],
};
});
/* AUF „EURE AUFGABEN" UND NICHT AUF DEM AUFGABENBRETT. Dort
bewirbt man sich, dort steht der Vorlagenkatalog
(`zweig: 'team'` in entwicklung.js). Das Aufgabenbrett zeigt
den anderen Zweig (`creator`, den Aufbauplan) -- beim ersten
Anlauf habe ich dort gemessen und nichts gefunden. Ein
Messfehler, der wie ein Befund aussieht. */
const m5 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
await m5.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const w = await liesBrett(m5.s);
ok(w.zeilen >= 1, `Frida sieht die Antwort auf ihrem Brett (${w.zeilen} Zeilen)`);
/* OHNE RUECKSICHT AUF GROSS UND KLEIN. `innerText` liefert den
Text SO, WIE ER DASTEHT -- und das Schild traegt
`text-transform: uppercase`. Die Zeile suchte „Diesmal
nicht" und fand „DIESMAL NICHT": rot, obwohl alles stimmte.
`textContent` haette den Rohtext gegeben, aber `innerText`
ist hier richtig: Gemessen wird, was ein Mensch liest. */
ok(/diesmal nicht/i.test(w.antwort || ""),
`und dass es diesmal nichts wurde („${(w.antwort || "").slice(0, 60)}…")`);
ok(!!w.antwort && w.antwort.includes("schon drei offene"),
"mitsamt der Begruendung");
/* GEGENPROBE: Die Uebersicht der Absagen sieht sie NICHT. */
ok(w.absagen === null,
`und die Uebersicht der Absagen nicht (${JSON.stringify(w.absagen)})`);
/* UND EIN BILD DAVON -- am Handy, denn dort liest ein Modi. */
const kasten2 = await m5.s.$(".vorlagenbrett__antwort");
if (kasten2) {
await kasten2.screenshot({ path: "server/pruef-bewerbung-antwort.png" });
console.log(" -- Bild: server/pruef-bewerbung-antwort.png");
}
/* ==== „VERSTANDEN" RAEUMT AUF (03.10.2026) ==============
VanVan im Support, Meldung #15: „Die Modis können die
Antworten auf die Bewerbungen im Bereich eure Aufgaben noch
nicht einklappen. Das wird mit der Zeit unübersichtlich."
GEMESSEN WIRD DER GANZE WEG, nicht nur die Route: Knopf
druecken, Zeile rutscht in die zugeklappte Gruppe, Gruppe
laesst sich oeffnen, Text ist noch da -- und nach dem
Neuladen ist sie immer noch gelesen. Der letzte Punkt ist
der eigentliche: Haette ich es im Browserspeicher gemerkt,
waere dieselbe Antwort auf dem naechsten Geraet wieder neu. */
const stand = () => m5.s.evaluate(() => {
const neuB = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
const altB = document.querySelector('.vorlagenbrett__antwort[data-alt="ja"]');
return {
neuZeilen: neuB ? neuB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
altKopf: altB
? (altB.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "").trim()
: null,
altZeilen: altB ? altB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
knopf: !!document.querySelector(".vorlagenbrett__antwort-weg"),
};
});
const vorher = await stand();
ok(vorher.neuZeilen >= 1 && vorher.knopf === true,
`die ungelesene Antwort steht offen da, mit „Verstanden" (${vorher.neuZeilen})`);
/* ==== HOECHSTENS DREI AUF EINMAL (03.10.2026) =============
GEMESSEN AM ECHTEN BESTAND, nicht geschaetzt: Ein Modi im
Haus hat NEUN beantwortete Bewerbungen liegen, alle
ungelesen. Auf dem Handy war das Band damit 922 px hoch --
bei 844 px Fenster, also hoeher als der ganze Bildschirm
und 49 % der Seite. Das ist woertlich VanVans Satz: „Das
wird mit der Zeit unuebersichtlich."
Mit dem Deckel sind es 566 px, und ein Griff auf „Alle
verstanden" macht daraus 46 px.
DIE ZAHL DREI GEHOERT GEPRUEFT, nicht nur in den
Quelltext. Deshalb bekommt Frida hier vier weitere
beantwortete Bewerbungen -- erst ab vier greift der
Deckel, und ohne ihn waere diese Pruefung gruen, ohne
etwas gesehen zu haben. */
{
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = new Date().toISOString();
for (let i = 0; i < 4; i++) {
dx.prepare(`INSERT INTO vorlagen_bewerbungen
(vorlage, kategorie, person_id, text, zustand, entscheid_text,
entschieden_von, entschieden_am, erstellt)
VALUES (?,?,?,?,?,?,?,?,?)`)
.run(`deckel-probe-${i}`, "chat", idModi, "Die auch gern.",
"angenommen", null, idHand, nun, nun);
}
dx.close();
}
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const gedeckelt = await m5.s.evaluate(() => {
const band = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
return {
zeilen: band ? band.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
hoch: band ? Math.round(band.getBoundingClientRect().height) : 0,
knopf: band
? (band.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "")
.replace(/\s+/g, " ").trim()
: "",
fenster: window.innerHeight,
};
});
ok(gedeckelt.zeilen === 3,
`mit fuenf Ungelesenen stehen drei offen (${gedeckelt.zeilen})`);
ok(/und \d+ weitere/.test(gedeckelt.knopf),
` und der Rest ist einen Griff entfernt („${gedeckelt.knopf}")`);
/* DER EIGENTLICHE PUNKT IST DIE HOEHE. „Drei Zeilen" waere
eine Zahl; dass das Band unter den Bildschirm passt, ist
die Aussage. */
ok(gedeckelt.hoch > 0 && gedeckelt.hoch < gedeckelt.fenster,
` das Band passt in den Bildschirm (${gedeckelt.hoch} px bei `
+ `${gedeckelt.fenster} px)`);
/* AUFKLAPPEN ZEIGT ALLE -- wer nachsehen will, kann. */
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(900);
const aufgeklappt = await m5.s.evaluate(() => {
const band = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
return {
zeilen: band ? band.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
knopf: band
? (band.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "")
.replace(/\s+/g, " ").trim()
: "",
};
});
ok(aufgeklappt.zeilen > 3 && /Weniger/.test(aufgeklappt.knopf),
`aufgeklappt stehen alle da (${aufgeklappt.zeilen}, „${aufgeklappt.knopf}")`);
/* UND WIEDER ZU -- der Knopf geht in beide Richtungen. Ohne
diese Zeile koennte er ein Einwegschalter sein. */
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(900);
ok(await m5.s.evaluate(() => document.querySelectorAll(
'.vorlagenbrett__antwort:not([data-alt]) .vorlagenbrett__antwort-zeile').length) === 3,
"und wieder zu — der Knopf geht in beide Richtungen");
/* DIE VIER PROBEZEILEN WIEDER WEG. Die Zeilen weiter unten
zaehlen „1 aeltere Antwort" und „2 aeltere Antworten" --
mit den Proben waeren es sechs, und sie wuerden rot, ohne
dass etwas kaputt ist. Eine Pruefung, die den Stand fuer
die naechste verschiebt, ist schlimmer als keine. */
{
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
dx.prepare("DELETE FROM vorlagen_bewerbungen WHERE vorlage LIKE 'deckel-probe-%'")
.run();
dx.close();
}
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
ok(await m5.s.evaluate(() => document.querySelectorAll(
'.vorlagenbrett__antwort:not([data-alt]) .vorlagenbrett__antwort-zeile').length) === 2,
"und die Probezeilen sind wieder weg — der Stand ist wie vorher");
ok(vorher.altKopf === null,
`und es gibt noch keine aeltere Gruppe (${vorher.altKopf})`);
await m5.s.click(".vorlagenbrett__antwort-weg");
await m5.s.waitForTimeout(1200);
const danach = await stand();
/* EINE WENIGER, NICHT KEINE. Mein erster Anlauf erwartete hier
null -- Frida hat aber ZWEI Antworten (eine Zusage von
weiter oben und diese Absage), und „Verstanden" gilt je
Zeile. Die Pruefung hatte recht, die Erwartung war falsch;
dass der Knopf nur seine eigene Zeile nimmt, ist genau das
gewollte Verhalten und steht jetzt hier als Aussage. */
ok(danach.neuZeilen === vorher.neuZeilen - 1,
`nach „Verstanden" ist genau diese eine Zeile weg `
+ `(${vorher.neuZeilen} → ${danach.neuZeilen})`);
/* SIE IST NICHT WEG, SIE IST NUR ZU. Eine Absage samt
Begruendung zu loeschen, weil jemand sie einmal gelesen
hat, waere das Gegenteil von dem, was am 30.09. gebaut
wurde. */
ok(/1 ältere Antwort/.test(danach.altKopf || ""),
`sondern hinter einer zugeklappten Zeile („${danach.altKopf}")`);
ok(danach.altZeilen === 0,
` und zugeklappt wird sie gar nicht erst gebaut (${danach.altZeilen})`);
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(800);
const auf = await m5.s.evaluate(() => {
const altB = document.querySelector('.vorlagenbrett__antwort[data-alt="ja"]');
return {
zeilen: altB ? altB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
text: altB ? (altB.innerText || "").replace(/\s+/g, " ").trim() : "",
};
});
ok(auf.zeilen === 1, `aufgeklappt steht sie wieder da (${auf.zeilen})`);
ok(/schon drei offene/.test(auf.text),
"mitsamt der Begruendung — nachlesen geht weiter");
/* ---- UND DER KNOPF FUER ALLE AUF EINMAL ----------------
Wer nach dem Urlaub sieben Antworten vorfindet, soll nicht
siebenmal tippen. Er steht erst ab zwei da -- bei einer
waere er dasselbe wie der daneben, nur laenger. */
await m5.s.click(".vorlagenbrett__antwort-kopf"); /* wieder zu */
await m5.s.waitForTimeout(500);
const vorAlle = await m5.s.evaluate(() =>
!!document.querySelector(".vorlagenbrett__antwort-alle"));
if (vorAlle) {
await m5.s.click(".vorlagenbrett__antwort-alle");
await m5.s.waitForTimeout(1200);
} else {
/* Nur noch eine offen -- dann nimmt sie ihr eigener Knopf. */
await m5.s.click(".vorlagenbrett__antwort-weg");
await m5.s.waitForTimeout(1200);
}
const leer = await stand();
ok(leer.neuZeilen === 0,
`danach steht oben gar nichts mehr (${leer.neuZeilen})`);
ok(/2 ältere Antworten/.test(leer.altKopf || ""),
`und beide stehen in der zugeklappten Gruppe („${leer.altKopf}")`);
/* UND DER SERVER HAT ES SICH GEMERKT. */
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const nachLaden = await stand();
ok(nachLaden.neuZeilen === 0 && /2 ältere Antworten/.test(nachLaden.altKopf || ""),
`nach dem Neuladen gelten sie weiter als gelesen („${nachLaden.altKopf}")`);
/* UND SIE IST WIEDER ZU. Das Aufklappen ist eine Frage des
Augenblicks, kein Zustand, den man mitschleppt. */
ok(nachLaden.altZeilen === 0,
`und wieder zugeklappt (${nachLaden.altZeilen} Zeilen)`);
} finally { await m5.ctx.close(); }
/* GEGENPROBE AM SERVER: Eine fremde Antwort laesst sich nicht
als gelesen melden -- und eine, auf die noch niemand
geantwortet hat, auch nicht. Ohne diese beiden waere
„gesehen" ein Knopf, mit dem man in fremden Zeilen schreibt. */
{
const fremd = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: hand.keks, body: { id: bw2?.id } });
ok(fremd.code === 404,
`eine fremde Antwort kann niemand als gelesen melden (${fremd.code})`);
const erfunden = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: modi.keks, body: { id: 999999 } });
ok(erfunden.code === 404, `eine erfundene Nummer auch nicht (${erfunden.code})`);
/* UND ZWEIMAL „VERSTANDEN" AENDERT NICHTS MEHR. */
const nochmal = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: modi.keks, body: { id: bw2?.id } });
ok(nochmal.code === 200 && nochmal.json?.wieviele === 0,
`ein zweites „Verstanden" zaehlt nicht noch einmal (${nochmal.json?.wieviele})`);
}
const h2 = await seiteAls("hand", "CODE-HAND-0001", 1280);
try {
await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await h2.s.waitForTimeout(1800);
/* DAS BRETT AUFKLAPPEN. Die Uebersicht ist ein
Nachschlagewerk und liegt im Koerper des Vorlagenbretts --
der wird zugeklappt gar nicht erst gebaut. Die ANTWORT auf
die eigene Bewerbung steht dagegen davor: Sie ist eine
Nachricht, und eine Nachricht, die man aufklappen muss,
ist keine. */
await h2.s.evaluate(() => {
const k = document.querySelector("#vorlagenbrett .vorlagenbrett__kopf");
if (k && k.getAttribute("aria-expanded") !== "true") k.click();
});
await h2.s.waitForTimeout(1200);
const w = await liesBrett(h2.s);
ok(!!w.absagen, `die rechte Hand sieht die Uebersicht („${(w.absagen || "").slice(0, 50)}…")`);
/* ZUGEKLAPPT. Eine Liste von Absagen, die immer offen steht,
ist eine Sammlung von Nein-Sagen ueber Menschen, mit denen
man morgen wieder arbeitet. */
ok(w.zugeklappt === true, `und zwar zugeklappt (${w.zugeklappt})`);
ok(w.leute.some((t) => /Frida/.test(t)),
`mit der Zahl je Mensch (${w.leute.join(" | ")})`);
/* EIN BILD VON DEM, WAS NEU IST. Zahlen sagen, dass etwas da
ist; ob es auch auszuhalten ist, sieht man nur. Aufgeklappt
-- zugeklappt waere ein Bild von einer Zeile. */
await h2.s.evaluate(() => {
const u = document.querySelector(".vorlagenbrett__absagen");
if (u) u.open = true;
});
await h2.s.waitForTimeout(300);
const kasten = await h2.s.$("#vorlagenbrett");
if (kasten) {
await kasten.screenshot({ path: "server/pruef-bewerbung-absagen.png" });
console.log(" -- Bild: server/pruef-bewerbung-absagen.png");
}
} finally { await h2.ctx.close(); }
}
/* =================================================================
EINE BEWERBUNG AUF EINE ERLEDIGTE AUFGABE IST KEINE (01.10.2026)
Beim Durchsehen der echten Daten gefunden: Zwei Bewerbungen
standen auf „beworben" an Aufgaben, die laengst durch waren.
Bei der Person stand weiter „wartet auf Antwort", bei der
Leitung eine Entscheidung, die es nicht mehr gibt.
DAS MUSTER GIBT ES IM HAUS SCHON -- `uebernahmeAbschliessen`
raeumt genauso auf, wenn jemand anderes eine Pool-Aufgabe
bekommt. Derselbe Fall, ein anderer Ausloeser.
================================================================= */
console.log("");
console.log("=== Bewerbungen auf durchgelaufene Aufgaben ===");
{
/* UEBER DIE LISTE, nicht ueber die Einzelaufgabe. Der erste
Anlauf las `/api/aufgaben/:id` und bekam `undefined` -- die
Antwort dort hat eine andere Form. Die Liste ist in dieser
Datei erprobt, und „erprobt" schlaegt „naheliegend". */
const stand = async (id, wer) => {
const a = await roh("/workspace/api/aufgaben", { keks: hand.keks });
const auf = (a.json?.aufgaben || a.json?.liste || []).find((x) => x.id === id);
return (auf?.zuteilung || []).find((x) => x.person_id === wer) || null;
};
/* ---- Weg 1: die Aufgabe wird ERLEDIGT ---------------------- */
const e1 = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Kurz die Liste sortieren", prioritaet: "niedrig" } });
const eId = e1.json?.aufgabe?.id ?? e1.json?.id;
const b1 = await roh(`/workspace/api/aufgaben/${eId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Kann ich machen." } });
ok(b1.code === 200, `Frida bewirbt sich (${b1.code})`);
ok((await stand(eId, idModi))?.zustand === "beworben",
"und steht auf „beworben\"");
const fertig = await roh(`/workspace/api/aufgaben/${eId}`, {
methode: "PATCH", keks: hand.keks, body: { status: "erledigt" } });
ok(fertig.code === 200, `die rechte Hand erledigt sie (${fertig.code})`);
const nach1 = await stand(eId, idModi);
ok(nach1?.zustand !== "beworben",
`danach wartet Frida nicht mehr (${nach1?.zustand})`);
ok(/inzwischen erledigt/.test(nach1?.entscheid_text || ""),
`und liest, warum (${JSON.stringify(nach1?.entscheid_text)})`);
/* NIEMAND HAT ENTSCHIEDEN. Dadurch faellt die Zeile aus der
Absagen-Uebersicht heraus -- es ist keine Absage an sie. */
ok(!nach1?.entschieden_von,
`niemand hat ihr abgesagt (entschieden_von=${JSON.stringify(nach1?.entschieden_von)})`);
/* ---- Weg 2: die Aufgabe wird ABGEBROCHEN ------------------- */
const e2 = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Etwas, das sich erledigt hat", prioritaet: "niedrig" } });
const aId = e2.json?.aufgabe?.id ?? e2.json?.id;
await roh(`/workspace/api/aufgaben/${aId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Mach ich." } });
const ab = await roh(`/workspace/api/aufgaben/${aId}/abbrechen`, {
methode: "POST", keks: chef.keks,
body: { grund: "Brauchen wir doch nicht." } });
const nach2 = await stand(aId, idModi);
ok(ab.code === 200 && nach2?.zustand !== "beworben",
`beim Abbrechen ebenso (${ab.code}, ${nach2?.zustand})`);
/* DER SATZ IST EIN ANDERER -- „abgebrochen" ist nicht
„erledigt", und wer gewartet hat, soll den Unterschied
lesen koennen. */
ok(/abgebrochen/.test(nach2?.entscheid_text || ""),
`mit dem passenden Satz (${JSON.stringify(nach2?.entscheid_text)})`);
/* ---- GEGENPROBE: eine OFFENE Aufgabe behaelt ihre Bewerbung.
Ohne sie hiesse „faellt weg" womoeglich nur, dass jede
Bewerbung wegfaellt. */
const e3 = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Laeuft noch", prioritaet: "niedrig" } });
const oId = e3.json?.aufgabe?.id ?? e3.json?.id;
await roh(`/workspace/api/aufgaben/${oId}/bewerben`, {
methode: "POST", keks: modi.keks, body: { text: "Gern." } });
const nach3 = await stand(oId, idModi);
ok(nach3?.zustand === "beworben",
`eine offene Aufgabe behaelt ihre Bewerbung (${nach3?.zustand})`);
}
/* =================================================================
DAUERHAFTE AUFGABEN (30.09.2026)
VanVan im Support: „Man kann bei den Aufgaben, wenn man sie
verteilt, ob selbst erstellt oder über die Vorlage noch nicht
festlegen, dass die Aufgabe dauerhaft sein soll und somit nicht
vom Modi in den Status erledigt gesetzt werden kann."
================================================================= */
console.log("");
console.log("=== Dauerhafte Aufgaben ===");
{
/* ORTSZEIT UND NICHT UTC. Meine erste Fassung rechnete
`toISOString().slice(0,10)` -- und `pruef-struktur` hat sie
noch am selben Abend gefunden: Zwischen 00:00 und 02:00 liegt
der UTC-Tag noch auf gestern, das Datum waere um einen Tag
daneben, und die Pruefung schluege nachts falsch an. Genau
die Sorte Zeitbombe, vor der die Hausregel warnt. */
const { tagLokal } = await import("./helfer-tag.mjs");
const morgen = tagLokal(1);
const neu = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: {
titel: "Neue im Chat begruessen", prioritaet: "mittel",
frist: morgen, dauerhaft: true,
zuteilung: [idModi], verteilart: "einzeln",
} });
const dId = neu.json?.aufgabe?.id ?? neu.json?.id;
ok(neu.code === 201 || neu.code === 200,
`die rechte Hand legt eine dauerhafte Aufgabe an (${neu.code})`);
const lesen = async (keks) => {
const l = await roh("/workspace/api/aufgaben", { keks });
return (l.json?.aufgaben || l.json?.liste || []).find((x) => x.id === dId);
};
const a1 = await lesen(hand.keks);
ok(!!a1 && !!a1.dauerhaft, `sie ist als dauerhaft gespeichert (${a1?.dauerhaft})`);
/* DIE FRIST IST WEG, und das ist der Punkt: Eine dauerhafte
Aufgabe mit Frist waere ab morgen fuer immer ueberfaellig. */
ok(a1?.frist === null || a1?.frist === undefined,
`und hat KEINE Frist mehr, obwohl eine mitgeschickt wurde (${JSON.stringify(a1?.frist)})`);
/* DER KERN DER MELDUNG. */
await roh(`/workspace/api/aufgaben/${dId}/annehmen`,
{ methode: "POST", keks: modi.keks });
/* UEBER DEN STATUS, nicht ueber `/mein-stand` (03.10.2026).
Den zweiten Weg gibt es nicht mehr: Er setzte den
Zuteilungszustand, ohne den Aufgabenstatus mitzuziehen --
VanVans Meldung #8. Geprueft wird jetzt die Tuer, die es
noch gibt. */
const fertig = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(fertig.code === 409 && fertig.json?.fehler === "dauerhafte_aufgabe",
`Frida kann sie nicht abhaken (${fertig.code}/${fertig.json?.fehler})`);
/* GEGENPROBE 1: „Ich fange an" geht weiterhin. Auch eine
stehende Aufgabe hat einen Anfang -- gesperrt ist nur das
Abhaken, nicht die Arbeit. */
const anfang = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "arbeit" } });
ok(anfang.code === 200, `anfangen geht trotzdem (${anfang.code})`);
/* GEGENPROBE 2: Bei einer GEWOEHNLICHEN Aufgabe geht das Abhaken.
Ohne sie hiesse „409" womoeglich nur, dass niemand je etwas
abhaken kann. */
const normal = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: hand.keks,
body: { titel: "Einmal die Liste durchsehen", prioritaet: "niedrig",
zuteilung: [idModi], verteilart: "einzeln" } });
const nId = normal.json?.aufgabe?.id ?? normal.json?.id;
await roh(`/workspace/api/aufgaben/${nId}/annehmen`,
{ methode: "POST", keks: modi.keks });
const ok2 = await roh(`/workspace/api/aufgaben/${nId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(ok2.code === 200,
`eine gewoehnliche Aufgabe haekt sie sehr wohl ab (${ok2.code})`);
/* GEGENPROBE 3: EIN MODI LEGT GAR KEINE AUFGABEN AN.
Gemessen, nicht angenommen: Beim ersten Anlauf stand hier
„ein Modi legt sich selbst eine Aufgabe an" -- der Server
antwortete 403. `darfAufgabenAnlegen` laesst ihn nicht, und
das ist aelter als diese Aenderung. Die Zeile prueft jetzt,
was wirklich gilt. */
const selbst = await roh("/workspace/api/aufgaben", {
methode: "POST", keks: modi.keks,
body: { titel: "Meine eigene Notiz", prioritaet: "niedrig", dauerhaft: true } });
ok(selbst.code === 403,
`ein Modi legt ueberhaupt keine Aufgaben an (${selbst.code})`);
/* GEGENPROBE 4: Und er macht auch keine bestehende dauerhaft --
noch nimmt er es zurueck. Das ist der Kern: Koennte er es,
waere die ganze Sperre ein Knopf weiter offen. */
const zurueck = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { dauerhaft: false } });
const a2 = await lesen(hand.keks);
ok(!!a2?.dauerhaft,
`und der Zugeteilte nimmt „dauerhaft" nicht zurueck `
+ `(HTTP ${zurueck.code}, danach ${a2?.dauerhaft})`);
/* UND DIE LEITUNG KANN SIE BEENDEN. Eine Pflicht, die niemand
mehr beenden kann, waere eine Falle statt einer Regel. */
const beenden = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: hand.keks, body: { dauerhaft: false } });
const a3 = await lesen(hand.keks);
ok(beenden.code === 200 && !a3?.dauerhaft,
`die rechte Hand beendet sie (${beenden.code}, danach ${a3?.dauerhaft})`);
const jetztFertig = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(jetztFertig.code === 200,
`und danach laesst sie sich abhaken (${jetztFertig.code})`);
}
/* =================================================================
7. AUFGABEN DORT ANLEGEN, WO DIE PERSON STEHT (A5 und B6)
=================================================================
@@ -843,6 +1561,121 @@ melde("=== 6. Am echten Bildschirm ===");
} finally { await m.ctx.close(); }
}
/* =================================================================
DIE SPALTE „BEWERBUNGEN" IM AUFGABENBRETT (25.09.2026)
=================================================================
Filipe: „ich will in dieser seite auch eine eigene kategorie fuer
die aufgaben wo die modis sich selbst bewerben. ich will dass man
die getrennt sieht. mach es richtig uebersichtlich."
BISHER STANDEN BEWERBUNGEN NUR AUF DEM VORLAGENBRETT, an der
jeweiligen Karte. Wer morgens das Aufgabenbrett oeffnete, sah
nicht, dass jemand auf eine Antwort wartet.
DREI DINGE WERDEN GEMESSEN, und das dritte ist das, was ohne
Pruefung irgendwann kippt:
1. Wer entscheidet, sieht die Spalte MIT den zwei Knoepfen.
2. Der Modi sieht seine eigene -- ohne Knoepfe, mit dem Satz
„wartet auf Antwort". Ein Knopf, der eine Absage holt,
waere schlimmer als keiner.
3. Ohne Bewerbung gibt es die Spalte GAR NICHT. Eine leere
Spalte stuende 360 Tage im Jahr im Weg.
================================================================= */
{
/* ERST DER ZUSTAND OHNE: Ohne diese Messung waere „die Spalte ist
da" auch dann gruen, wenn sie immer da waere. */
const vorher = await seiteAls("admin", "CODE-DOGI-0001", 1400);
let ohne = null;
try {
await vorher.s.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await vorher.s.waitForTimeout(1700);
ohne = await vorher.s.evaluate(() =>
document.querySelectorAll(".spalte--bewerbung").length);
} finally { await vorher.ctx.close(); }
ok(ohne === 0, `ohne Bewerbung gibt es die Spalte nicht (${ohne})`);
/* JETZT BEWIRBT SICH EIN MODI -- ueber die echte Schnittstelle
der Seite, damit Sitzung und Herkunftspruefung mitlaufen. */
const m = await seiteAls("modi", "CODE-MODI-0001", 1400);
let beworben = null, beiModi = null;
try {
await m.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await m.s.waitForTimeout(1500);
beworben = await m.s.evaluate(async () => {
const k = await fetch("/workspace/api/vorlagen", { credentials: "same-origin" });
const d = await k.json();
const v = (d.katalog?.aufgaben || [])[0];
if (!v) return { fehler: "kein Katalog" };
const a = await fetch("/workspace/api/vorlagen/bewerben", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vorlage: v.schluessel, text: "ich habe Freitag Zeit" }),
});
return { code: a.status, titel: v.titel };
});
/* UND DER MODI SIEHT SEINE EIGENE -- auf dem Aufgabenbrett. */
await m.s.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await m.s.waitForTimeout(1800);
beiModi = await m.s.evaluate(() => {
const sp = document.querySelector(".spalte--bewerbung");
if (!sp) return { spalte: false };
return {
spalte: true,
karten: sp.querySelectorAll(".karte--bewerbung").length,
knoepfe: sp.querySelectorAll(".karte--bewerbung button").length,
warte: !!sp.querySelector(".karte__warte"),
titel: sp.querySelector(".karte__titel")?.textContent || "",
wort: sp.querySelector(".karte__bwort")?.textContent || "",
};
});
} finally { await m.ctx.close(); }
ok(beworben?.code === 201,
`ein Modi bewirbt sich (HTTP ${beworben?.code} ${beworben?.fehler || ""})`);
ok(beiModi?.spalte === true && beiModi.karten === 1,
`er sieht sie im Aufgabenbrett in einer eigenen Spalte (${beiModi?.karten})`);
ok(beiModi?.titel === beworben?.titel,
`mit dem TITEL der Aufgabe, nicht dem Schluessel ("${beiModi?.titel}")`);
ok((beiModi?.wort || "").includes("Freitag"),
`und mit seinem eigenen Satz dazu ("${beiModi?.wort}")`);
ok(beiModi?.knoepfe === 0 && beiModi?.warte === true,
`Gegenprobe: er bekommt KEINE Entscheidungsknoepfe (${beiModi?.knoepfe}), `
+ `sondern „wartet auf Antwort" (${beiModi?.warte})`);
/* UND DER, DER ENTSCHEIDET. */
const e2 = await seiteAls("admin", "CODE-DOGI-0001", 1400);
let beiDogi = null;
try {
await e2.s.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await e2.s.waitForTimeout(1800);
beiDogi = await e2.s.evaluate(() => {
const sp = document.querySelector(".spalte--bewerbung");
if (!sp) return { spalte: false };
return {
spalte: true,
karten: sp.querySelectorAll(".karte--bewerbung").length,
knoepfe: [...sp.querySelectorAll(".karte--bewerbung button")]
.map((b) => b.textContent.trim()),
/* Die Spalte muss die ERSTE sein: Was auf eine Antwort
wartet, gehoert an die Stelle, die man zuerst ansieht. */
erste: document.querySelector("#brett .spalte")?.classList
.contains("spalte--bewerbung"),
wer: sp.querySelector(".karte__wer")?.textContent || "",
};
});
} finally { await e2.ctx.close(); }
ok(beiDogi?.spalte === true && beiDogi.karten === 1,
`DogFather sieht dieselbe Bewerbung (${beiDogi?.karten})`);
ok(beiDogi?.erste === true,
`und die Spalte steht ganz vorn (${beiDogi?.erste})`);
ok(JSON.stringify(beiDogi?.knoepfe) === JSON.stringify(["Annehmen", "Ablehnen"]),
`mit beiden Knoepfen (${JSON.stringify(beiDogi?.knoepfe)})`);
ok((beiDogi?.wer || "").includes("möchte das übernehmen"),
`und dem Namen dessen, der wartet ("${beiDogi?.wer}")`);
}
ok(meckern.length === 0, meckern.length
? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}`
: "kein Absturz und keine Fehlermeldung im Browser");
+50 -2
View File
@@ -248,9 +248,36 @@ const messen = () => {
}
return false;
};
/* AUS DEM ROLLKASTEN GESCROLLT IST NICHT VERDECKT (01.10.2026).
Gemeldet war auf 1024, 1920 und 2560 Pixeln immer ein Feld aus
dem Pult „unter" einem Teil der Transportleiste -- und auf 1280
und 1440 nichts. Das Pult rollt in sich (`.pult__innen` hat eine
Hoechsthoehe); was unten herausgerollt ist, liegt rechnerisch
dort, wo die Leiste steht. `elementFromPoint` liefert dann die
Leiste.
Verdeckt ist es nicht -- es ist weggerollt, und man rollt zwei
Zentimeter und ist dran. Dieselbe Sorte Fehlalarm wie die
klebende Leiste und das dekorative SVG oben. */
const imRollkasten = (e, r) => {
for (let z = e.parentElement; z && z !== document.body; z = z.parentElement) {
const st = getComputedStyle(z);
if (!/auto|scroll|hidden/.test(st.overflowY + " " + st.overflowX)) continue;
const k = z.getBoundingClientRect();
/* Mehr als die Haelfte muss drin liegen -- ein Element, das
halb am Rand haengt, ist noch bedienbar. */
const hoch = Math.min(r.bottom, k.bottom) - Math.max(r.top, k.top);
const breit = Math.min(r.right, k.right) - Math.max(r.left, k.left);
if (hoch < r.height / 2 || breit < r.width / 2) return false;
}
return true;
};
for (const e of bedienbar.slice(0, 60)) {
const r = e.getBoundingClientRect();
if (r.bottom < 0 || r.top > window.innerHeight) continue; // ausserhalb des Fensters
if (!imRollkasten(e, r)) continue; // aus seinem Kasten gerollt
/* Fuenf Punkte statt einem: Mitte und die vier Viertelpunkte. */
const punkte = [[0.5, 0.5], [0.25, 0.3], [0.75, 0.3], [0.25, 0.7], [0.75, 0.7]];
let erreichbar = false;
@@ -297,7 +324,26 @@ let seitenGesamt = 0, elementeGesamt = 0;
const probleme = [];
for (const [breite, hoehe, wie] of BREITEN) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
/* MIT FINGER, WENN ES EIN FINGER WAERE (01.10.2026).
Hier stand nur die Fenstergroesse. Ohne `hasTouch` meldet der
Browser `pointer: fine`, und KEINE Regel aus
`@media (pointer: coarse)` greift -- dort stehen im ganzen Haus
die 44-Pixel-Beruehrziele, das Ausblenden der Tastenkuerzel und
seit heute die eingeklappte Reiterleiste. Auf 320, 390 und 430
Pixeln hat diese Pruefung also eine Seite vermessen, die es auf
keinem Telefon gibt, und daraus drei Befunde gemeldet.
Gefunden, weil ich einem dieser Befunde nachgegangen bin statt
ihm zu glauben: mess-quer misst dieselbe Seite auf 390x844 MIT
Finger und findet dort nichts.
860 ist die Grenze des Hauses -- dieselbe wie in mess-reaktion,
mess-chat-optik und pruef-fingermass. */
const finger = breite <= 860;
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
hasTouch: finger, isMobile: finger });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text().slice(0, 70)); });
@@ -347,7 +393,9 @@ ok(elementeGesamt > 20000, `${elementeGesamt} Elemente vermessen`);
console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
{
for (const testBreite of [320, 768, 1920]) {
const kontext = await browser.newContext({ viewport: { width: testBreite, height: 900 } });
const kontext = await browser.newContext({
viewport: { width: testBreite, height: 900 },
hasTouch: testBreite <= 860, isMobile: testBreite <= 860 });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');

Some files were not shown because too many files have changed in this diff Show More