/* =================================================================== Gemeinsames Verhalten der Kopfleiste: Zurück-Knopf, Abmelden, Suche. Liegt bewusst in einer eigenen Datei. Vorher stand das Abmelden in jeder Seitendatei noch einmal -- fünfmal derselbe Block, fünfmal eine Stelle zum Vergessen, wenn sich etwas ändert. =================================================================== */ (() => { 'use strict'; const START = '/workspace/start.html'; /* WEITER UNTEN GEBRAUCHT, IN EINEM ANDEREN BLOCK. Diese Datei hat mehrere getrennte Bloecke; `START` ist hier eingeschlossen und waere dort ein Absturz. Abschreiben waere die zweite Stelle, die beim naechsten Umbau vergessen wird -- also einmal bekannt geben. */ window.__startSeite = START; /* ---------- Wer angemeldet ist ------------------------------------------- Vorher stand in der Kopfleiste "Dogfather · DogFather". Name und Rolle lasen sich gleich, und es sah nach einem Fehler aus. Jetzt drei unterscheidbare Dinge: ein Zeichen mit dem Anfangsbuchstaben, der Name, und die Rolle als eigene Marke in ihrer Farbe. Steht hier und nicht in jeder Seitendatei -- jede von ihnen setzte bisher dieselbe Zeile selbst zusammen, vierzehnmal. */ window.werZeigen = function (ich) { /* DIE AUSKUNFT LIEGT AB JETZT OFFEN (20.09.2026). ---------------------------------------------------------------- `/api/ich` sagt unter anderem, WELCHE SEITEN diesem Menschen offenstehen (`seiten`). Jede Seite holt das ohnehin -- aber bisher behielt es jede fuer sich. Gebraucht wird es an Stellen, die einen WEG NENNEN: „Zugaenge legst du in ‚Personen & Zugaenge' an" ist fuer einen Modi eine Sackgasse, weil er diese Seite nicht oeffnen darf. Wer einen Weg nennt, muss fragen koennen, ob es ihn fuer diesen Menschen gibt. Deshalb hier, an der einen Stelle, durch die jede Seite laeuft. Nicht als zweite Abfrage -- eine zweite Abfrage waere eine zweite Wahrheit. */ try { window.__ich = ich; } catch { /* egal */ } /* DER CHAT-KNOPF GEHOERT NICHT ZU JEDEM (18.09.2026). Er wurde auf JEDER Seite gebaut. Fuer ein Mitglied der Community fuehrte er ins Leere: `chat.html` steht ihm nicht offen, der Klick landete wieder auf der Startseite -- keine Meldung, kein Grund, nichts passiert. Gemessen von pruef-community-sicht: zehn Seiten, zehn tote Wege, und der Knopf steht oben rechts, wo man ihn am ehesten drueckt. HIER UND NICHT IN `aufbauChat`: Dort gibt es die Person nicht -- sie kommt als Parameter in diese Funktion, und zwar je nach Seite frueher oder spaeter als der Knopf gebaut wird. Auf die Reihenfolge zu bauen waere eine Annahme; ein vorhandener Knopf laesst sich dagegen immer entfernen. `=== false` und nicht `!`: Fehlt die Angabe (aeltere Antwort des Servers), bleibt alles wie bisher. Ein fehlendes Feld soll niemandem den Chat wegnehmen. */ if (ich && ich.darf_chat === false) { document.querySelector('.chat-knopf')?.remove(); } const ziel = document.getElementById('wer'); if (!ziel || !ich) return; ziel.textContent = ''; ziel.dataset.rolle = ich.rolle || ''; /* DIE ROLLE AUCH ANS (08.09.2026). Damit gilt die Farbe der angemeldeten Rolle fuer die GANZE Seite und nicht nur fuer dieses eine Zeichen -- die Umschaltung steht in gate.css bei `html[data-rolle=...]`. Warum am und nicht am : Das steht schon, bevor irgendetwas geladen ist. Und warum ueberhaupt an einem so weit oben liegenden Element: Wer die Farbe an jedem einzelnen Baustein umschaltet, vergisst den naechsten, der dazukommt. Hier erbt alles mit, auch was es heute noch gar nicht gibt. */ if (ich.rolle) document.documentElement.dataset.rolle = ich.rolle; /* DER NAME IN DER KOPFLEISTE (10.09.2026). Auf jeder Seite steht dort fest "Creator Workspace · ". Fuer wen das nicht stimmt, schickt der Server einen anderen Namen mit -- WER das ist, steht ausdruecklich nicht in dieser Datei: Sie bekommt jeder, der die Seite oeffnet. Nur der Verweis wird umgeschrieben, nicht die ganze Zeile: Hinter ihm steht noch " · Aufgaben" bzw. der Name der jeweiligen Seite, und der bleibt richtig. MIT GESCHUETZTEM LEERZEICHEN, wie im HTML. Die Kopfleiste rechnet damit, dass dieser Name nicht umbricht -- ein gewoehnliches Leerzeichen liesse ihn auf schmalen Handys in zwei Zeilen fallen und die Leiste mitwachsen. */ if (ich.marke) { const markeText = String(ich.marke).replace(/ /g, ' '); /* ZWEI BAUFORMEN, UND DIE ZWEITE FEHLTE. Auf den Unterseiten steht in der Kopfleiste ein Rueckweg ("Creator Workspace · Aufgaben") -- dort wird der Verweistext ersetzt. Die STARTSEITE hat keinen; dort steht der Name als blosser Text ("Spicy & Dogi · Creator Workspace"). Diese Zeile fehlte, und man sah es sofort: Im Bildschirmfoto betroffenen Startseite stand oben links "SPICY & DOGI · CREATOR WORKSPACE". Reitertitel, Begruessung und Symbol waren laengst richtig -- nur die Leiste, die man als Erstes ansieht, nicht. `textContent` zu setzen leert dabei die Aufteilung in Namensteil und Raute, die weiter unten gebaut wird. Das ist gewollt: Wo nur EIN Name steht, gibt es nichts zu trennen. */ for (const text of document.querySelectorAll('.kopfleiste .marke__text')) { const verweis = text.querySelector('.zurueck'); if (verweis) { verweis.textContent = markeText; continue; } text.textContent = markeText; } /* DER REITER UND DAS ZEICHEN GEHOEREN DAZU (10.09.2026). Die Kopfleiste allein reicht nicht. In jeder Seite steht fest Aufgaben · Spicy & Dogi und daneben das Zeichen des Workspace -- beides sieht man als Erstes, im Browserreiter und, wenn die Seite als App laeuft, im Fensterrahmen und beim Umschalten zwischen Apps. WARUM HIER UND NICHT IN 30 HTML-DATEIEN: Der Name kommt vom Server und haengt an der Person. Er kann also gar nicht fest im HTML stehen -- und 30 Dateien zu pflegen hiesse, die 31. zu vergessen. Der Seitenname bleibt stehen, nur der Teil hinter dem Punkt wechselt. Auf der Startseite steht dort der Name des Bereichs selbst; dann bleibt nur die Marke uebrig, statt sie doppelt zu lesen. */ const marke = String(ich.marke); const stueck = document.title.split(' · '); const seitenname = stueck.slice(0, -1).join(' · ').trim(); document.title = (!seitenname || seitenname === 'Creator Workspace') ? marke : `${seitenname} · ${marke}`; /* Das Zeichen: derselbe Stempel, anderer Name. Ihn aus der vorhandenen Adresse zu ersetzen statt ihn neu zu bauen haelt die Versionsnummer daran -- sonst laedt der Browser ab hier ein Bild ohne Stempel und behaelt es ein Jahr. */ for (const zeichen of document.querySelectorAll( 'link[rel="icon"], link[rel="apple-touch-icon"]')) { const alt = zeichen.getAttribute('href') || ''; if (alt.includes('/workspace-')) { zeichen.setAttribute('href', alt.replace('/workspace-', '/crew-')); } } /* DIE FARBE DER BROWSERLEISTE (10.09.2026). Am Handy faerbt Chrome die Leiste ueber der Seite nach . Der Wert steht als feste Zeile im HTML und ist von CSS nicht erreichbar -- auf dem Handy hatte das zweite Haus damit oben einen Streifen aus dem ersten. Der Wert wird NICHT hier hingeschrieben, sondern aus `--haus-leiste` gelesen: Dann steht er genau einmal da, naemlich im Stilblatt des Hauses, zusammen mit allen anderen Farben. Ein zweiter Ort waere der, an dem es beim naechsten Farbwechsel hakt. Und er wird nur gesetzt, wenn das Haus ihn auch liefert: Auf der Agenturadresse ist die Angabe leer, dann bleibt alles, wie es ist. Kein `else`, keine zweite Wahrheit. */ const leiste = getComputedStyle(document.documentElement) .getPropertyValue('--haus-leiste').trim(); const feld = document.querySelector('meta[name="theme-color"]'); if (leiste && feld) feld.setAttribute('content', leiste); } /* Das eigene Bild, wenn es eines gibt -- sonst der Anfangsbuchstabe. Der Buchstabe bleibt als Unterlage LIEGEN und wird nicht ersetzt: Laedt das Bild nicht (Verbindung weg, Datei geloescht), steht dort weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. */ const zeichen = document.createElement('span'); zeichen.className = 'wer__zeichen'; zeichen.setAttribute('aria-hidden', 'true'); zeichen.textContent = (ich.name || '?').trim().charAt(0).toUpperCase(); if (ich.bild) { const b = document.createElement('img'); b.className = 'wer__bild'; b.src = ich.bild; b.alt = ''; b.addEventListener('error', () => b.remove()); zeichen.append(b); } const name = document.createElement('span'); name.className = 'wer__name'; name.textContent = ich.name || ''; const rolle = document.createElement('span'); rolle.className = 'wer__rolle'; rolle.textContent = ich.rolle_name || ich.rolle || ''; ziel.append(zeichen, name, rolle); /* Vorleseprogramme bekommen einen Satz statt drei Bruchstücke. */ ziel.setAttribute('aria-label', `Angemeldet als ${ich.name} in der Rolle ${ich.rolle_name || ich.rolle}`); teilenKnopf(ich); }; /* ---------- Teilen --------------------------------------------------- Wunsch Filipe (07.09.2026): "einen button oben in der leiste ... zum teilen der seite, auch nur die scouts, manager und dogfather sollen diesen button sehen und benutzen können." WAS GETEILT WIRD, IST DIE ADRESSE DES WORKSPACE -- nicht die Seite, auf der man gerade steht. Das ist kein Detail: Wer einen Link auf `bereich.html?b=schutz` weitergibt, schickt jemanden auf eine Seite, die er entweder gar nicht sehen darf oder die ohne Zusammenhang verwirrt. Geteilt wird der Eingang. WARUM NUR DIE LEITUNG. Ein Creator hat niemanden, dem er den Workspace geben würde -- er ist der Eingeladene, nicht der Einladende. Ein Knopf, den man hat und nie braucht, ist Ballast; einer, den man drückt und der nichts Sinnvolles tut, ist schlimmer. Durchgesetzt wird das an der Rolle aus /api/ich, also an derselben Angabe, mit der der Server auch alles andere entscheidet. ZWEI WEGE, EIN KNOPF. Wo es die Teilen-Funktion des Geräts gibt (Handy, neuere Rechner), wird sie benutzt -- das ist der Weg, den Filipe kennt. Wo nicht, landet die Adresse in der Zwischenablage und der Knopf sagt "Kopiert". Kein Dialog, kein zweiter Klick. Und wo auch das nicht geht (alte Browser, kein sicherer Kontext), erscheint der Knopf gar nicht erst -- ein dritter Ausgang statt einer Schaltfläche, die nichts tut. */ /* Gleiche Menge, gleiche Begruendung wie beim Abbrechen: Leitung plus Scouts, abgeleitet statt abgeschrieben. */ const DARF_TEILEN = new Set([...window.Bereiche.LEITUNG, 'scout']); function teilenKnopf(ich) { const leiste = document.querySelector('.kopfleiste__rechts'); if (!leiste || document.getElementById('kopf-teilen')) return; /* UND WER ZU TEAM DOGI GEHOERT (11.09.2026). Filipe: "dan brauch ich auch noch einen teilen button in der leiste." Auf der Team-Adresse fehlte er -- `DARF_TEILEN` kennt Leitung und Scouts, und die rechte Hand ist keins von beiden. GEFRAGT WIRD NICHT NACH DER ROLLE, sondern nach `ich.marke`. Die setzt der Server genau dann, wenn jemand zu Team Dogi gehoert ODER auf dessen Adresse steht -- und sie steht hier ohnehin schon im Zugriff. Der Rollenname bleibt damit aus dieser Datei heraus, und zwar nicht als Trick, sondern weil die Frage wirklich lautet: "gehoert diese Person hierher?" Teilen gibt ohnehin nur die ADRESSE der Seite weiter -- es verraet nichts, was der Angemeldete nicht ohnehin sieht. */ if (!DARF_TEILEN.has(ich.rolle) && !ich.marke) return; const kannTeilen = typeof navigator.share === 'function'; const kannKopieren = !!navigator.clipboard?.writeText; if (!kannTeilen && !kannKopieren) return; const k = document.createElement('button'); k.type = 'button'; k.id = 'kopf-teilen'; k.className = 'teilen'; /* Ohne den Namen des Workspace: Auf der zweiten Adresse heisst der Bereich anders, und "Adresse dieser Seite" stimmt ueberall. */ k.title = 'Adresse dieser Seite weitergeben'; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'teilen__zeichen'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); /* Drei Knoten, zwei Linien -- das Zeichen für "weitergeben". Als Pfade und nicht als Schriftzeichen: Ein Emoji sieht auf jedem Gerät anders aus und lässt sich nicht einfärben. */ svg.innerHTML = '' + '' + ''; k.append(svg); const text = document.createElement('span'); text.className = 'teilen__text'; text.textContent = 'Teilen'; k.append(text); const ZIEL = location.origin + '/workspace/'; let laeuft = false; k.addEventListener('click', async () => { if (laeuft) return; laeuft = true; try { if (kannTeilen) { /* Der Name kommt aus derselben Quelle wie die Kopfleiste. Steht dort nichts, bleibt es beim gewohnten Namen. */ const wie = document.querySelector('.kopfleiste .marke__text .zurueck'); const name = (wie?.textContent || '').replace(/ /g, ' ').trim() || 'Creator Workspace'; await navigator.share({ title: name, text: `${name} · DogFather Universe`, url: ZIEL, }); } else { await navigator.clipboard.writeText(ZIEL); const vorher = text.textContent; text.textContent = 'Kopiert'; k.dataset.fertig = 'ja'; setTimeout(() => { text.textContent = vorher; delete k.dataset.fertig; }, 2200); } } catch (f) { /* Ein Abbruch durch die Nutzerin ist KEIN Fehler und darf nicht wie einer aussehen -- sie hat den Dialog absichtlich geschlossen. Alles andere wird gemeldet. */ if (f?.name !== 'AbortError') { text.textContent = 'Ging nicht'; setTimeout(() => { text.textContent = 'Teilen'; }, 2200); } } finally { laeuft = false; } }); /* Ganz links in der rechten Gruppe: Teilen ist eine Nebenhandlung und darf dem Abmelden nicht den Platz nehmen. */ leiste.prepend(k); } /* ---------- Auf- und Zuklappen langer Listen ------------------------- Lange Protokoll-Listen sind Nachschlagewerk, kein Startbild: Sie stehen zugeklappt da und zeigen nur die neuesten Zeilen. Steht hier, weil es an mehreren Stellen gebraucht wird -- und weil genau so schon dreimal Regeln in einer Seitendatei gelandet sind, die eine andere Seite gar nicht laedt. `klappe(liste, knopf, text)` erwartet: die Liste (deren direkte Kinder die Zeilen sind), den Knopf und das Textfeld darin. Das Ausblenden macht CSS ueber [data-klapp="zu"] -- ohne Neuaufbau und ohne zweite Abfrage. */ /* Zwei Sichtbarkeitsstufen, beide mit einer Regel im CSS: 4 der Normalfall -- eine Liste zum Nachschlagen 7 wo die Woche die Einheit ist (die Tagesliste der Zahlen) Die Zahl ist der Schluessel zum passenden `data-klapp`-Wert; eine unbekannte Zahl faellt auf 4 zurueck, statt still gar nichts zu verbergen. */ const KLAPP_STUFEN = { 4: 'zu', 7: 'zu7' }; const KLAPP_ZU = 4; /* dieselbe Zahl steht im CSS als nth-child(n+5) */ window.klappe = function (liste, knopf, textfeld, wieviele = KLAPP_ZU) { if (!liste || !knopf) return; const anzahl = liste.children.length; const stufe = KLAPP_STUFEN[wieviele] || 'zu'; const zeigt = KLAPP_STUFEN[wieviele] ? wieviele : KLAPP_ZU; /* Ein Knopf, der nichts zu verbergen hat, verwirrt nur. */ knopf.hidden = anzahl <= zeigt; if (knopf.hidden) { liste.removeAttribute('data-klapp'); return; } /* Liest die Anzahl bei jedem Aufruf frisch aus der Liste, statt sie von oben mitzunehmen. Der Zuhoerer unten wird nur einmal gesetzt und haelt darum fuer immer die Fassung vom ersten Durchlauf fest -- mit einer festgehaltenen Zahl stuende spaeter "Alle 3 zeigen" an einer Liste mit dreissig Zeilen. */ const stand = (offen) => { if (offen) liste.removeAttribute('data-klapp'); else liste.dataset.klapp = stufe; knopf.setAttribute('aria-expanded', offen ? 'true' : 'false'); /* Der Knopf sagt, was er TUT, nicht wo man gerade ist -- und nennt die Zahl, damit man weiss, was dahintersteckt. */ if (textfeld) { textfeld.textContent = offen ? `Nur die letzten ${zeigt}` : `Alle ${liste.children.length} zeigen`; } }; /* Die Liste wird bei jeder Aktualisierung neu aufgebaut, also laeuft auch klappe() mehrfach. Der Zuhoerer wird deshalb nur EINMAL gesetzt -- sonst haengten beim zweiten Mal zwei daran und ein Klick haette doppelt umgeschaltet, also gar nichts bewirkt. */ if (!knopf.dataset.klappBereit) { knopf.dataset.klappBereit = 'ja'; knopf.addEventListener('click', () => stand(liste.dataset.klapp === 'zu')); } stand(false); }; /* ---------- GANZE ABSCHNITTE AUF UND ZU (19.09.2026) ------------------ Filipe: „man soll auf dieser seite jede kategorie auf und zu klappen können mit einem button, übrigens will ich das überall überall wo so eine liste entstehen kann hatte ich dir schon mal gesagt." UNTERSCHIED ZU `klappe()` DARÜBER: Das dort verbirgt die hinteren Zeilen EINER Liste („nur die letzten 4"). Das hier klappt einen ganzen Abschnitt samt Überschrift zu. Zwei verschiedene Fragen, deshalb zwei Funktionen — eine, die beides kann, müsste bei jedem Aufruf erklärt bekommen, was gemeint ist. DAS MUSTER STAMMT VON DER STARTSEITE, wo es seit dem 10.09.2026 funktioniert: ein Knopf als Überschrift, ein Pfeil, der sich dreht, der Zustand in localStorage. Es steht jetzt hier, weil Filipe es „überall" haben will — und weil eine zweite Abschrift die wäre, die beim nächsten Umbau ausschert. (start.js hat seine eigene Fassung noch; sie tut dasselbe und ist geprüft. Wer sie anfasst, stellt sie auf diese hier um.) `schluessel` darf fehlen: Dann merkt sich die Seite nichts und alles steht beim nächsten Besuch offen. Das ist der richtige Rückfall — ein Abschnitt, der zu ist und von dem man nicht mehr weiß, warum, sieht aus wie fehlender Inhalt. */ window.abschnittKlappbar = function (kopf, kasten, schluessel, zuVorgabe = false) { if (!kopf || !kasten) return; /* Der Knopf muss ein Knopf sein. Ein `

` ist für die Tastatur nicht erreichbar und für Vorleseprogramme unsichtbar. */ if (kopf.tagName !== 'BUTTON') kopf.setAttribute('role', 'button'); if (!kopf.hasAttribute('tabindex') && kopf.tagName !== 'BUTTON') kopf.tabIndex = 0; let zu = zuVorgabe; if (schluessel) { try { const gemerkt = localStorage.getItem('abschnitt:' + schluessel); if (gemerkt) zu = gemerkt === 'zu'; } catch { /* privates Fenster: dann eben die Vorgabe */ } } const stand = (istZu) => { kopf.dataset.zu = istZu ? 'ja' : 'nein'; kopf.setAttribute('aria-expanded', istZu ? 'false' : 'true'); kasten.hidden = istZu; }; stand(zu); /* NUR EINMAL EINHÄNGEN. Die Bretter bauen sich bei jeder Aktualisierung neu auf; hinge der Zuhörer zweimal daran, würde ein Klick zweimal umschalten und damit gar nichts tun. */ if (kopf.dataset.klappBereit) return; kopf.dataset.klappBereit = 'ja'; const um = () => { zu = !zu; stand(zu); if (schluessel) { try { localStorage.setItem('abschnitt:' + schluessel, zu ? 'zu' : 'auf'); } catch { /* egal */ } } }; kopf.addEventListener('click', um); if (kopf.tagName !== 'BUTTON') { kopf.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); um(); } }); } }; /** Der Pfeil, der sich beim Zuklappen dreht. * * Als Funktion, damit nicht an jeder Stelle dasselbe SVG steht -- * und damit ein späterer anderer Pfeil an EINER Stelle geändert * wird. */ window.klappPfeil = function () { const p = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); p.setAttribute('class', 'klapp-pfeil'); p.setAttribute('viewBox', '0 0 24 24'); p.setAttribute('aria-hidden', 'true'); const weg = document.createElementNS('http://www.w3.org/2000/svg', 'path'); weg.setAttribute('d', 'm6 9.5 6 6 6-6'); weg.setAttribute('fill', 'none'); weg.setAttribute('stroke', 'currentColor'); weg.setAttribute('stroke-width', '2.2'); weg.setAttribute('stroke-linecap', 'round'); weg.setAttribute('stroke-linejoin', 'round'); p.append(weg); return p; }; /* Gibt es eine sinnvolle vorherige Seite INNERHALB des Workspace? Blindes history.back() reicht nicht: Wer die Adresse direkt eingibt oder gerade von der Anmeldung kommt, landet damit außerhalb des Workspace oder wieder im Anmeldeformular. Deshalb wird zuerst geprüft, woher der Aufruf kam. */ function hatVorherigeSeite() { if (!document.referrer) return false; let u; try { u = new URL(document.referrer); } catch { return false; } if (u.origin !== location.origin) return false; if (!u.pathname.startsWith('/workspace/')) return false; /* Die Anmeldeseite ist kein sinnvolles Ziel -- wer angemeldet ist, würde dort sofort wieder weitergeleitet. */ if (u.pathname === '/workspace/' || u.pathname.endsWith('/index.html')) return false; /* Gleiche Seite heißt: neu geladen, nicht hergekommen. */ if (u.pathname === location.pathname) return false; return true; } /* ---------- Das Licht folgt dem Zeiger --------------------------------- Auf der Startseite gab es das schon, aber nur dort und nur als Lichtfleck. Jetzt auf JEDER Karte im ganzen Workspace -- und mit zwei Schichten: der FLECK ein weicher Schein unter dem Zeiger, in der Farbe des Bereichs der RAND eine helle Stelle, die auf der Kante mitwandert Der Rand ist der Teil, der den Unterschied macht. Er entsteht aus einem Farbverlauf, von dem eine Maske nur den ein Pixel breiten Saum stehen laesst (mask-composite: exclude). Dadurch leuchtet wirklich die KANTE an der Stelle, an der der Zeiger ist -- nicht ein Rechteck, das so tut als ob. Beides steckt in einem eingefuegten . Ein eigenes Element statt ::before/::after, weil die bei fast allen Karten schon fuer etwas anderes gebraucht werden (Akzentstreifen, Wasserzeichen) -- ein Pseudo-Element doppelt zu belegen geht nicht, und der Fehler faellt erst auf, wenn eines der beiden verschwindet. Kosten: EIN Zuhoerer fuer das ganze Dokument, gedrosselt auf einen Bildaufbau. Ohne feinen Zeiger (Handy) und bei "weniger Bewegung" laeuft gar nichts -- dann wird auch kein Element eingefuegt. */ const LICHT_KARTEN = [ '.kachel', '.karte', '.spalte', '.k-anlasskarte', '.k-tag', '.k-wochentag', '.dran__punkt', '.zahl', '.datei', '.person', '.eintrag', '.thema', '.listentag', '.k-listentag', '.steckbrief', '.leer-hinweis', '.block', '.saeule', '.karte-gross', '.kandidat', '.punkt', '.call', '.report', /* Die Konsole der Startseite (07.09.2026). Auf ihr ist das Licht kein Farbschleier, sondern eine SPIEGELUNG auf Metall -- gesteuert von denselben zwei Werten (--mx/--my), gestaltet in heim.css. Echtes Metall zeigt seine Form erst, wenn sich das Licht darauf bewegt; ein Standbild kann das nie leisten. */ '.willkommen', ].join(', '); window.lichtFolgen = function (wurzel = document) { const feinerZeiger = window.matchMedia('(hover: hover) and (pointer: fine)').matches; const ruhig = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!feinerZeiger || ruhig) return; /* Jede Karte bekommt ihr Licht-Element -- einmal. Karten kommen nachtraeglich dazu (Listen werden neu aufgebaut), deshalb wird das beim ersten Ueberfahren nachgeholt statt einmalig beim Laden. */ const vorbereiten = (karte) => { if (karte.dataset.lichtBereit) return; karte.dataset.lichtBereit = 'ja'; const l = document.createElement('span'); l.className = 'licht'; l.setAttribute('aria-hidden', 'true'); /* Als ERSTES Kind: Bei gleichem Stapelwert gewinnt das spaetere Element -- so liegt aller Inhalt ueber dem Licht. */ karte.prepend(l); }; let warten = false; let letzte = null; /* Die Karte, auf der das Licht gerade AN ist. Genau eine -- mehr kann es nicht geben, es gibt ja nur einen Zeiger. */ let aktuelle = null; const aus = (karte) => { if (!karte) return; karte.removeAttribute('data-licht'); karte.style.removeProperty('--mx'); karte.style.removeProperty('--my'); if (aktuelle === karte) aktuelle = null; }; wurzel.addEventListener('pointermove', (e) => { const karte = e.target.closest?.(LICHT_KARTEN); if (!karte) { /* Der Zeiger ist zwischen den Karten -- das Licht der letzten geht aus. Ohne das blieb es haengen, sobald man eine Karte ueber eine Luecke verliess statt ueber eine Nachbarkarte. */ aus(aktuelle); letzte = null; return; } /* Direkt von einer Karte auf die naechste: Die alte ausschalten, bevor die neue angeht. */ if (aktuelle && aktuelle !== karte) aus(aktuelle); letzte = { karte, x: e.clientX, y: e.clientY }; if (warten) return; warten = true; requestAnimationFrame(() => { warten = false; /* WETTLAUF. Zwischen dem Anmelden dieses Bildaufbaus und seinem Ablauf kann der Zeiger die Karte laengst verlassen haben -- dann hat pointerout schon aufgeraeumt, und diese Zeilen schalteten das Licht gleich wieder AN. Genau so blieb es haengen. Deshalb wird hier noch einmal geprueft, ob ueberhaupt noch etwas zu beleuchten ist. */ if (!letzte) return; const { karte: k, x, y } = letzte; vorbereiten(k); const r = k.getBoundingClientRect(); k.style.setProperty('--mx', ((x - r.left) / r.width * 100).toFixed(1) + '%'); k.style.setProperty('--my', ((y - r.top) / r.height * 100).toFixed(1) + '%'); k.dataset.licht = 'an'; aktuelle = k; }); }, { passive: true }); /* Beim Verlassen zurueck in die Ruhelage. Ohne das bliebe der Fleck dort stehen, wo der Zeiger die Karte verlassen hat -- und die Karten saehen nach einer Weile alle unterschiedlich beleuchtet aus, ohne dass jemand wuesste, warum. */ wurzel.addEventListener('pointerout', (e) => { const karte = e.target.closest?.(LICHT_KARTEN); if (!karte) return; /* pointerout feuert auch beim Wechsel zwischen Kindern INNERHALB derselben Karte. Nur wenn der Zeiger die Karte wirklich verlaesst, wird geloescht -- sonst flackerte das Licht bei jeder Bewegung ueber ein Wort hinweg. */ if (karte.contains(e.relatedTarget)) return; /* NUR DEN EIGENEN Bildaufbau entwerten (07.09.2026). Hier stand `letzte = null` ohne Bedingung -- und das hat beim Wechsel von einer Karte auf die naechste das Licht GANZ ausgeschaltet. Der Grund ist die Reihenfolge der Ereignisse: `pointermove` auf der neuen Karte kommt zuerst und meldet einen Bildaufbau an; `pointerout` der alten Karte kommt danach und hat ihn geloescht, obwohl er gar nicht ihr gehoerte. Der Bildaufbau lief, fand `letzte === null` und tat nichts. Gemessen mit pruef-start-ansicht: "beim Wechsel leuchtet weiterhin nur eine (0)" -- null statt einer. Ein Fehler, den man von Hand nur bemerkt, wenn man zufaellig schnell genug von Kachel zu Kachel faehrt. */ if (letzte && letzte.karte === karte) letzte = null; aus(karte); }, { capture: true, passive: true }); /* Drei weitere Wege, auf denen der Zeiger verschwindet, ohne dass pointerout die Karte meldet -- jeder davon hat das Licht haengen lassen: Fenster verlassen der Zeiger geht ueber den Rand hinaus Tab gewechselt es kommt gar kein Ereignis mehr gescrollt die Karte wandert unter dem stehenden Zeiger weg; mausbewegt wurde nichts Beim Scrollen wird nicht blind geloescht: Es wird nachgesehen, ob die zuletzt beleuchtete Karte noch unter dem Zeiger liegt. */ let zeigerX = 0, zeigerY = 0; wurzel.addEventListener('pointermove', (e) => { zeigerX = e.clientX; zeigerY = e.clientY; }, { passive: true }); const pruefen = () => { if (!aktuelle) return; const unten = document.elementFromPoint(zeigerX, zeigerY); if (!unten || !aktuelle.contains(unten)) { letzte = null; aus(aktuelle); } }; window.addEventListener('scroll', pruefen, { passive: true, capture: true }); window.addEventListener('blur', () => { letzte = null; aus(aktuelle); }); document.addEventListener('visibilitychange', () => { if (document.hidden) { letzte = null; aus(aktuelle); } }); document.addEventListener('pointerleave', () => { letzte = null; aus(aktuelle); }); }; /* ---------- Jede Seite trägt Zeichen und Farbe ihrer Kachel ------------- Auf der Startseite hat jeder Bereich sein eigenes Zeichen und seinen eigenen Farbton. Bisher endete das an der Kachel: Wer sie anklickte, landete auf einer Seite, der man nicht mehr ansah, woher sie kam -- dreizehn Seiten, alle in demselben Blau, alle ohne Zeichen. Jetzt wird die Kachel weitergereicht. Der Kopf der Seite bekommt dasselbe Zeichen groß daneben, denselben Farbton für Strich, Zeichen und Titel, und dasselbe Zeichen noch einmal riesig und fast unsichtbar als Wasserzeichen -- genau die Behandlung, die auf der Startseite aus siebzehn Kästen siebzehn unterscheidbare Bereiche gemacht hat. Die Zuordnung kommt aus assets/js/bereiche.js, also aus derselben Liste wie die Kachel selbst. Wer dort eine Farbe ändert, ändert Kachel UND Seite -- sie können gar nicht auseinanderlaufen. */ /* ==== GEFUNDEN AM 19.09.2026: DIE HAELFTE DER SEITEN BEKAM NICHTS ===== Der Umbau von heute Nacht heisst "Jede Seite traegt die Farbe ihrer Kachel". Nachgemessen traf das auf 18 von 34 Seiten zu. Elf Seiten mit echter Kachel gingen leer aus: befinden, bewerben, bewerbungen, entwicklung, hilfe, rechte, talente, teamlage, teilen, treff-moderation, treff-regeln, unsere-seiten Es sind ausgerechnet die von Team Dogi -- also die, die Filipe taeglich benutzt. DER GRUND: `B.zuSeite()` sucht nur in `Bereiche.GRUPPEN`, und dort stehen ausschliesslich die Kacheln der AGENTUR. Die Kacheln von Team Dogi und vom Treff liefert der SERVER (`ich.bereiche`, `ich.bereiche_zusatz`) -- die kannte diese Stelle nicht. Auffallen konnte das nicht: Eine Seite ohne Farbe sieht aus wie eine Seite, die eben keine hat. Dieselbe Sorte Fehler wie bei `chat.html` am 09.09. -- im Quelltext sieht die Zeile richtig aus, und nur das Nachzaehlen findet sie. JETZT ZWEIMAL, UND DAS IST ABSICHT: 1. sofort beim Laden aus GRUPPEN -- die Agenturseiten haben ihre Farbe damit ohne ein einziges Flackern, wie bisher. 2. noch einmal, sobald der Server gesagt hat, wer man ist. Nur wenn beim ersten Mal nichts gefunden wurde. Zweimal zeichnen verhindert `kopf.dataset.gezeichnet`. ================================================================= */ function seiteEinfaerben(zusatz) { const B = window.Bereiche; /* Der Kalender hat einen eigenen Kopf (.k-kopf), weil dort der Zeitraum die Überschrift ist. Er soll sein Zeichen trotzdem bekommen -- sonst wäre ausgerechnet die aufwendigste Seite die einzige ohne. */ if (!B) return; /* Erst die Kacheln, die der Server geschickt hat -- sie sind die genauere Auskunft: Wer diese Seite oeffnen darf, hat sie auch als Kachel bekommen. Danach die Agenturliste aus dem Browser. */ const bereich = ausListe(zusatz) || B.zuSeite(location.pathname, location.search); if (!bereich) return false; /* ==== DER SATZ VON DER KACHEL KOMMT MIT (20.09.2026) ============== Gemessen: 25 der 33 Team-Dogi-Seiten tragen eine Unterzeile, die sagt, wofuer die Seite da ist. Fuenf nicht -- obwohl ihre Kachel den Satz laengst hat. Bei dreien war das richtig so: Auf `aufgaben.html` heisst die Ueberschrift „Offen. In Arbeit. Zur Freigabe. Erledigt.", auf `teamlage.html` „Was auf dich wartet" -- der Satz stuende dort ein zweites Mal. Nachgesehen, nicht gerechnet. Bleiben zwei, die wirklich nichts sagen. Sie bekommen den Satz NICHT als abgeschriebenen Text ins HTML, sondern eine leere Zeile mit `data-aus-kachel`. Gefuellt wird sie von hier, aus der Kachel. Damit gibt es den Satz genau einmal: Wer die Kachel aendert, aendert die Seite mit, und sie koennen nicht auseinanderlaufen. Eine Seite, die ihre Ueberschrift selbst erklaert, setzt die Zeile einfach nicht -- dann passiert hier nichts. */ const unterzeile = document.querySelector('.unterzeile[data-aus-kachel]'); if (unterzeile && !unterzeile.textContent.trim() && bereich.unter) { unterzeile.textContent = bereich.unter; } /* ==== ERST DIE SEITE, DANN DIE PLAKETTE ========================== DIE REIHENFOLGE WAR DER FEHLER (behoben 09.09.2026, screen11). Hier stand ganz oben: const kopf = document.querySelector('.kopf-zeile, .k-kopf'); if (!B || !kopf) return; Damit hing ALLES an der Frage, ob es eine Kopfzeile gibt, an die man eine Plakette haengen kann -- auch der Farbton der Seite und ihr Hintergrundbild, die damit nichts zu tun haben. `chat.html` hat weder `.kopf-zeile` noch `.k-kopf` (es hat einen eigenen Chat-Kopf). Die Seite ist damit an dieser Zeile ausgestiegen und hat WEDER ihren Ton NOCH ihre Buehne bekommen -- obwohl in bereiche.js seit jeher `szene: 'lounge'` fuer sie steht. Eine Zuordnung, die es gibt und die nie ankam. Aufgefallen ist es beim Nachmessen der Hintergrundhelligkeit aller 18 Seiten: chat.html meldete als eine von dreien `data-buehne` = nichts. Im Quelltext sieht die Zeile richtig aus, und auf der Seite sieht ein fehlender Hintergrund nach Absicht aus -- deshalb hat es Jahre keiner gesehen. Jetzt stehen Ton und Buehne VOR der Plakette. Sie brauchen nur den Bereich; die Plakette braucht zusaetzlich einen Kopf, und wenn es den nicht gibt, faellt eben nur sie aus. */ /* Der Ton hängt am body: Damit erreicht er auch Bauteile, die weit unten auf der Seite stehen (Karten, Knöpfe, Zähler) -- nicht nur den Kopf. Die 17 Töne selbst stehen in start.css und hängen nur am Merkmal data-ton, nicht an einer Klasse. */ document.body.dataset.ton = String(bereich.ton); /* Und ihre eigene Bühne. Neun Szenen aus Filipes Bildern, jede Seite bekommt die, die zu ihr passt -- die Werkstatt bei den Aufgaben, die Nachtstadt beim Kalender, die Sitzecke bei den Calls, der Wald beim Scouting. Welche wohin gehört, steht in bereiche.js; das Bild selbst wählt start.css über dieses Merkmal aus. */ if (bereich.szene) document.body.dataset.buehne = bereich.szene; /* Der Kopf ist bisher ein
mit Text. Er bekommt links eine Plakette mit dem Zeichen -- eingefügt VOR dem vorhandenen Text, ohne ihn anzufassen. Dadurch funktioniert das auf allen dreizehn Seiten, ohne dreizehn HTML-Dateien zu ändern. */ const kopf = document.querySelector('.kopf-zeile, .k-kopf'); /* `true` und nicht `false`: Ton und Buehne SIND gesetzt. Nur die Plakette faellt aus, weil es keinen Kopf gibt -- das ist kein Grund, es beim zweiten Anlauf noch einmal zu versuchen. */ if (!kopf || kopf.dataset.gezeichnet === 'ja') return true; const erstes = kopf.firstElementChild; if (!erstes) return true; const plakette = document.createElement('span'); plakette.className = 'kopf-zeichen'; plakette.setAttribute('aria-hidden', 'true'); plakette.append(B.zeichenBauen(bereich.zeichen, 'kopf-zeichen__svg')); const reihe = document.createElement('div'); reihe.className = 'kopf-zeile__marke'; reihe.append(plakette); kopf.insertBefore(reihe, erstes); reihe.append(erstes); /* Das Wasserzeichen: dasselbe Zeichen riesig und bei sehr geringer Deckkraft hinter dem Kopf. Es liegt im Kopf selbst, nicht auf der Seite -- sonst würde es beim Blättern mitwandern und stören. */ const schatten = document.createElement('span'); schatten.className = 'kopf-wasserzeichen'; schatten.setAttribute('aria-hidden', 'true'); schatten.append(B.zeichenBauen(bereich.zeichen, 'kopf-wasserzeichen__svg')); kopf.append(schatten); kopf.dataset.gezeichnet = 'ja'; return true; } /** Die Kachel zu dieser Seite in einer Liste vom Server suchen. * * UEBER DAS ZIEL UND NICHT UEBER DEN NAMEN. Namen aendern sich -- * am 19.09. hiess eine Kachel morgens noch "Entwicklung" und * mittags "Eure Aufgaben". Das Ziel ist die Angabe, die bleibt. */ function ausListe(listen) { if (!Array.isArray(listen) || !listen.length) return null; const datei = String(location.pathname).split('/').pop() || 'start.html'; const gesucht = datei + (location.search || ''); let treffer = null; for (const k of listen) { if (!k || !k.ziel) continue; if (k.ziel === gesucht) return k; if (!treffer && String(k.ziel).split('?')[0] === datei) treffer = k; } return treffer; } const schonEingefaerbt = seiteEinfaerben(null); /* Der zweite Anlauf. `werZeigen` ruft jede Seite auf, sobald der Server geantwortet hat -- es ist die einzige Stelle, an der die Kacheln von Team Dogi im Browser ankommen. */ if (!schonEingefaerbt) { const vorherWer = window.werZeigen; window.werZeigen = function (ich) { try { if (typeof vorherWer === 'function') vorherWer.apply(this, arguments); } finally { seiteEinfaerben([ ...(ich?.bereiche || []), ...(ich?.bereiche_zusatz || []), ]); } }; } /* ---------- Der Schriftzug oben links ----------------------------------- Er stand als eine Zeile in einer Farbe da: "DOGFATHER UNIVERSE · CREATOR WORKSPACE". Alles gleich laut, und der Mittelpunkt war ein Satzzeichen wie jedes andere. Jetzt bekommt er einen Rang: Der vordere Teil ist der Name und steht kräftig, der hintere sagt, wo man gerade ist, und tritt zurück. Der Trenner ist kein Zeichen mehr, sondern eine kleine Raute in der Hausfarbe. Das passiert hier zentral statt in sechzehn HTML-Dateien -- und ohne den Text neu zu schreiben: Nur die vorhandenen Textknoten werden aufgeteilt, Verweise (der Zurück-Link auf den Unterseiten) bleiben unberührt. Ändert jemand die Beschriftung im HTML, wirkt das hier weiterhin. */ for (const marke of document.querySelectorAll('.kopfleiste .marke__text')) { if (marke.dataset.rang) continue; for (const knoten of [...marke.childNodes]) { if (knoten.nodeType !== 3 || !knoten.textContent.includes('·')) continue; const stueck = document.createDocumentFragment(); knoten.textContent.split('·').forEach((teil, i) => { if (i) { const raute = document.createElement('span'); raute.className = 'marke__raute'; raute.setAttribute('aria-hidden', 'true'); stueck.append(raute); } if (!teil.trim()) return; const s = document.createElement('span'); s.className = 'marke__teil'; /* Der geschützte Leerraum im Quelltext soll erhalten bleiben -- "Creator Workspace" darf nicht umbrechen. */ s.textContent = teil.replace(/^[\s ]+|[\s ]+$/g, ''); stueck.append(s); }); knoten.replaceWith(stueck); } /* Der erste Teil ist der Name (oder auf Unterseiten der Rückweg), alles danach sagt nur noch, wo man ist. Genommen werden ALLE direkten Kinder außer der Raute -- nicht nur die eben erzeugten. Auf bereich.html steht der Bereichsname in einem eigenen span, das erst später von bereich.js gefüllt wird; eine Liste, die nur die selbst gebauten Stücke kennt, hätte ihn stumm übergangen. */ const teile = [...marke.children].filter((e) => !e.classList.contains('marke__raute')); teile.forEach((e, i) => e.classList.add(i === 0 ? 'marke__haupt' : 'marke__leise')); marke.dataset.rang = 'ja'; } /* Das Licht auf dem ganzen Dokument scharfstellen -- ein Zuhoerer fuer alle Karten, auch fuer die, die erst spaeter dazukommen. */ window.lichtFolgen(document); /* Die Startseite hat den Knopf gar nicht erst im Dokument -- dort gibt es kein "davor". Deshalb genügt hier die Prüfung, ob er existiert. */ const knopf = document.getElementById('zurueck'); if (knopf) { const beschriftung = knopf.querySelector('.zurueck-knopf__text'); if (hatVorherigeSeite()) { knopf.hidden = false; knopf.addEventListener('click', () => { /* history.back() statt eines festen Ziels: Es führt wirklich dorthin zurück, wo man war -- samt Bildlaufposition. Nur wenn es keine Historie gibt, wird ersatzweise umgeleitet. */ if (history.length > 1) history.back(); else location.assign(START); }); } else { /* Kein Verlauf, aber wir sind in einem Unterbereich: dann wenigstens ein Weg zur Übersicht. */ knopf.hidden = false; if (beschriftung) beschriftung.textContent = 'Übersicht'; knopf.addEventListener('click', () => location.assign(START)); } } const abmelden = document.getElementById('abmelden'); /* EIN ZEICHEN NEBEN DAS WORT (09.09.2026). ------------------------------------------------------------------ Filipe, mit einem Bildschirmfoto der Kopfleiste vom Handy: "ich will dass es ueberall perfektionniert wird ... es soll alles in einer reihe sein." Auf seinem Bild stand "Abmelden" allein in einer zweiten Zeile. Nachgemessen bei 390 px: Die rechte Gruppe hat dort 275 px Platz und braucht 393 -- der Umbruch war die richtige Reaktion auf ein Platzproblem, nicht der Fehler. Der Fehler war der Platzbedarf. Das Wort allein ist 93 px breit. Als Zeichen sind es 36. Es verschwindet aber nur auf schmalen Bildschirmen (siehe start.css); am Rechner steht weiterhin das Wort da, weil dort Platz ist und ein Wort immer eindeutiger ist als ein Bild. Der Aufbau steht HIER und nicht in neunzehn HTML-Dateien: kopf.js laeuft auf jeder Seite, und eine Aenderung an neunzehn Stellen ist achtzehn Gelegenheiten, eine zu vergessen. */ if (abmelden && !abmelden.dataset.zeichenDa) { abmelden.dataset.zeichenDa = 'ja'; const wort = (abmelden.textContent || 'Abmelden').trim(); abmelden.textContent = ''; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'abmelden__zeichen'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path'); /* Tuer mit Pfeil hinaus -- das gebraeuchlichste Zeichen dafuer. */ pfad.setAttribute('d', 'M15 4.5H6.5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2H15M12.5 12h7m0 0-3-3m3 3-3 3'); svg.append(pfad); const text = document.createElement('span'); text.className = 'abmelden__text'; text.textContent = wort; abmelden.append(svg, text); /* Die Beschriftung bleibt auch dann, wenn das Wort nicht zu sehen ist -- sonst waere der Knopf fuer ein Vorleseprogramm leer. */ abmelden.setAttribute('aria-label', wort); if (!abmelden.title) abmelden.title = wort; } if (abmelden) { abmelden.addEventListener('click', async () => { /* RUECKFRAGE, UND ZWAR EINE MIT INHALT (19.09.2026). ------------------------------------------------------------ Der Knopf sitzt am Handy als 44-Pixel-Zeichen direkt neben Glocke und Suche. Ein Fehlgriff war bis heute sofort wirksam -- und er ist teurer, als er aussieht: Zum Wiederanmelden braucht man den persoenlichen Zugangscode. Den bekommt jeder EINMAL. Wer ihn nicht aufgeschrieben hat, kommt ohne einen neuen Code von DogFather nicht zurueck. Deshalb steht genau das im Kasten -- nicht "Sicher?". Und daneben die Auskunft, dass Zuklappen reicht: Seit dem 20.09.2026 bleibt man angemeldet, bis man sich selbst abmeldet -- abmelden muss man sich zum Weggehen also gar nicht, und man SOLLTE es auch nicht, weil man sonst Anrufe und Benachrichtigungen verpasst. Genau das steht jetzt im Kasten. */ if (!await frageNach({ titel: 'Abmelden?', was: 'Zum Wiederanmelden brauchst du deinen persönlichen ' + 'Zugangscode.', bleibt: 'Wenn du nur kurz weg willst: Einfach zumachen reicht – ' + 'du bleibst angemeldet, bis du dich selbst abmeldest. ' + 'Solange bekommst du Anrufe und Nachrichten.', ja: 'Abmelden', })) return; abmelden.disabled = true; try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin', }); } catch { /* Auch ohne Antwort weiterleiten -- das Cookie läuft ohnehin ab. */ } location.assign('/workspace/'); }); } /* ================================================================ Suche über alle Bereiche (Konzept, Phase 4). Baut sich selbst in die Kopfleiste ein, statt in neun HTML-Dateien zu stehen. Eine Suche, die man nur auf einer Extraseite findet, benutzt niemand -- sie muss überall sein, wo man gerade ist. Mit "/" zu öffnen, mit Esc zu schließen. Das Ergebnis wird erst nach einer kurzen Pause geholt, damit nicht jeder Tastendruck eine Abfrage auslöst. ================================================================ */ const kopfRechts = document.querySelector('.kopfleiste__rechts'); if (kopfRechts) aufbauSuche(kopfRechts); /* =================================================================== DIE KOPFLEISTE BLEIBT IN EINER REIHE (25.09.2026) =================================================================== Filipe am 09.09.2026, mit Bildschirmfoto: „es soll alles in einer reihe sein." Auf seinem Bild stand „Abmelden" allein in einer zweiten Zeile. SEITHER IST DIE LEISTE GEWACHSEN: Sicht, Teilen, Installieren, Chat, Suchen, Name, Glocke, Abmelden -- acht Dinge. Gemessen auf 412 px stehen rechts 330 px zur Verfuegung; acht Beruehrziele zu 44 px brauchen mit Abstaenden 350. Die Leiste brach also wieder um, auf 360, 390 UND 412 px, bei jeder Rolle. ZWEI WEGE WAEREN FALSCH GEWESEN: - Die Knoepfe kleiner machen. Unter 44 px trifft man sie nicht mehr; das ist die Hausgrenze, und sie ist gemessen. - Eine Schwelle setzen („unter 480 px kommen drei ins Menue"). Das ist eine Rechnung von gestern -- sie stimmt, bis jemand den neunten Knopf hinzufuegt. Genau dieser Fehler steht in start.css dreimal als Warnung. DESHALB WIRD GEMESSEN, NICHT GERECHNET: Nach dem Aufbau wird nachgesehen, ob alle Teile dieselbe Oberkante haben. Tun sie es nicht, wandert der naechste Kandidat ins Menue -- und dann wird wieder gemessen. Das gilt fuer jede Breite, jede Rolle und jede Anzahl von Knoepfen, auch fuer die, die es noch nicht gibt. WER ZUERST GEHT: Teilen, Installieren, Abmelden, Suchen. Das ist die Reihenfolge nach Haeufigkeit von hinten -- Installieren braucht man einmal, Teilen selten, Abmelden selten und mit Bedacht. Glocke, Chat und der Sicht-Umschalter bleiben draussen: Sie tragen Zahlen, die man SEHEN muss, ohne ein Menue aufzumachen. UND ES GEHT ZURUECK: Dreht man das Handy ins Querformat, ist wieder Platz, und die Knoepfe kommen an ihre Stelle. Deshalb wird bei jeder Groessenaenderung neu geordnet (entprellt, damit das Drehen nicht hundert Messungen ausloest). =================================================================== */ function kopfMenue(leiste) { if (!leiste || leiste.dataset.mehrBereit === 'ja') return; leiste.dataset.mehrBereit = 'ja'; /* Die Reihenfolge, in der die Leiste gebaut wurde, wird EINMAL festgehalten. Ohne sie kaeme ein zurueckgeholter Knopf ans Ende statt an seinen Platz -- und die Leiste saehe nach jedem Drehen anders aus. */ [...leiste.children].forEach((k, i) => { if (!k.dataset.kopfPlatz) k.dataset.kopfPlatz = String(i); }); const knopf = document.createElement('button'); knopf.type = 'button'; knopf.className = 'kopf-mehr'; knopf.id = 'kopf-mehr'; knopf.hidden = true; knopf.setAttribute('aria-expanded', 'false'); knopf.setAttribute('aria-haspopup', 'true'); knopf.setAttribute('aria-label', 'Mehr'); knopf.title = 'Mehr'; const punkte = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); punkte.setAttribute('viewBox', '0 0 24 24'); punkte.setAttribute('aria-hidden', 'true'); punkte.setAttribute('class', 'kopf-mehr__zeichen'); for (const x of [6, 12, 18]) { const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); c.setAttribute('cx', String(x)); c.setAttribute('cy', '12'); c.setAttribute('r', '2'); punkte.append(c); } knopf.append(punkte); const fach = document.createElement('div'); fach.className = 'kopf-mehr__fach'; fach.id = 'kopf-mehr-fach'; fach.hidden = true; knopf.setAttribute('aria-controls', fach.id); leiste.append(knopf); leiste.append(fach); const zu = () => { fach.hidden = true; knopf.setAttribute('aria-expanded', 'false'); }; const auf = () => { fach.hidden = false; knopf.setAttribute('aria-expanded', 'true'); fach.querySelector('button, a')?.focus(); }; knopf.addEventListener('click', (e) => { e.stopPropagation(); if (fach.hidden) auf(); else zu(); }); /* Ein Menue, das offen bleibt, wenn man daneben tippt, ist eine Falle -- man kommt nicht mehr an das heran, was darunter liegt. `knopf.contains(e.target)` UND NICHT `e.target !== knopf`: Im Knopf steckt das SVG mit den drei Punkten, und bei einem Klick darauf ist ZIEL das , nicht der Knopf. Mit dem Vergleich auf Gleichheit hat dieser Zuhoerer das Fach in derselben Millisekunde wieder zugemacht, in der es aufging -- die Messung meldete „FACH GEHT NICHT AUF", und im Quelltext sah alles richtig aus. */ document.addEventListener('click', (e) => { if (!fach.hidden && !fach.contains(e.target) && !knopf.contains(e.target)) zu(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') zu(); }); /* Wer im Menue etwas anklickt, will das Menue nicht mehr sehen. */ fach.addEventListener('click', (e) => { if (e.target !== fach) zu(); }); /* Welche Teile duerfen wandern -- und in welcher Reihenfolge. Gesucht wird ueber Klassen, nicht ueber die Stelle in der Liste: Wer morgen einen Knopf dazwischenschiebt, soll nichts anfassen muessen. */ const KANDIDATEN = ['.teilen', '.install-knopf', '.abmelden', '.suche-knopf']; const einreihig = () => { const teile = [...leiste.children] .filter((k) => k !== fach && !k.hidden && k.getBoundingClientRect().height > 0); if (teile.length < 2) return true; const oben = teile.map((k) => k.getBoundingClientRect().top); /* 12 px Nachsicht: Ein Knopf von 44 px und ein Bild von 28 px sind mittig ausgerichtet und haben verschiedene Oberkanten -- das ist dieselbe Zeile. Eine zweite liegt 40 px tiefer. Dieselbe Zahl benutzt pruef-handy. */ return Math.max(...oben) - Math.min(...oben) <= 12; }; const zurueck = () => { for (const k of [...fach.children]) { const platz = Number(k.dataset.kopfPlatz); const nach = [...leiste.children].find( (x) => x !== fach && x !== knopf && Number(x.dataset.kopfPlatz) > platz); if (nach) leiste.insertBefore(k, nach); else leiste.insertBefore(k, knopf); } }; let amOrdnen = false; let beobachter = null; const ordnen = () => { if (amOrdnen) return; amOrdnen = true; /* ==== DER BEOBACHTER DARF SICH NICHT SELBST SEHEN ============ `ordnen()` raeumt die Leiste um -- es haengt Knoepfe aus und ein. Genau darauf horcht der MutationObserver unten, und seine Meldungen kommen ASYNCHRON, also oft erst, wenn `amOrdnen` schon wieder false ist. Ergebnis war eine Schleife: ordnen -> Aenderung -> ordnen -> Aenderung. Man sah davon nichts, ausser dass das Menue sich nicht oeffnen liess -- jeder Durchlauf beginnt mit `zu()`. Deshalb wird er fuer die Dauer des Umraeumens abgemeldet. Nicht „ignoriert": Ein Beobachter, der laeuft und dessen Meldungen man wegwirft, ist eine Zeitbombe mit Zufallszuendung. */ if (beobachter) beobachter.disconnect(); try { zu(); zurueck(); knopf.hidden = true; if (einreihig()) return; knopf.hidden = false; for (const wahl of KANDIDATEN) { if (einreihig()) break; const k = leiste.querySelector(':scope > ' + wahl); if (!k || k.hidden) continue; fach.append(k); } /* Hat das Verschieben nichts gebracht (etwa weil die Leiste auch mit einem Knopf nicht passt), bleibt das Menue trotzdem stehen -- die Teile darin sind dann wenigstens erreichbar. Ein leeres Menue verschwindet. */ if (!fach.children.length) knopf.hidden = true; } finally { amOrdnen = false; if (beobachter) beobachter.observe(leiste, { childList: true }); } }; /* Zweimal ordnen: einmal jetzt, einmal nach dem Laden der Schriften und der spaet eingehaengten Knoepfe (Installieren kommt ueber ein `load`-Ereignis). Ohne den zweiten Lauf stuende die Leiste richtig da, bis der letzte Knopf dazukommt. */ ordnen(); window.addEventListener('load', ordnen, { once: true }); let wecker = null; window.addEventListener('resize', () => { clearTimeout(wecker); wecker = setTimeout(ordnen, 150); }); /* Auch wenn spaeter etwas dazukommt (die Glocke meldet sich erst, wenn der Browser geantwortet hat). Ein Beobachter ist hier richtig und eine Zeitschaltung waere geraten. */ if (window.MutationObserver) { let ruht = false; beobachter = new MutationObserver(() => { if (amOrdnen || ruht) return; ruht = true; setTimeout(() => { ruht = false; ordnen(); }, 60); }); beobachter.observe(leiste, { childList: true }); } } if (kopfRechts) kopfMenue(kopfRechts); if (kopfRechts) aufbauChat(kopfRechts); /* DER AUFRUF DARF NICHT VON DER REIHENFOLGE DER SKRIPTZEILEN ABHAENGEN. Auf chat.html und leistung.html steht `kopf.js` OHNE `defer` -- ein solches Skript laeuft sofort beim Einlesen, also immer vor jedem aufgeschobenen. `installieren.js` gaebe es dort zu diesem Zeitpunkt noch nicht, und `?.()` verschluckt das lautlos: kein Knopf, keine Meldung, auf genau zwei von siebenunddreissig Seiten. Die Zeile in den HTML-Dateien zu verschieben haette es heute behoben und beim naechsten neuen Skript wieder gebrochen. Also lieber gar keine Reihenfolge voraussetzen. */ if (kopfRechts) { if (window.installKnopfBauen) window.installKnopfBauen(kopfRechts); else window.addEventListener('load', () => window.installKnopfBauen?.(kopfRechts), { once: true }); } /* Der Installieren-Knopf steht seit dem 22.09.2026 in einer eigenen Datei: assets/js/installieren.js. Grund war nicht die Groesse, sondern die Anmeldewand: Sie laedt kopf.js nicht (sie hat keine Kopfleiste) -- und sie ist die Seite, auf der jeder zuerst landet. Ein Angebot zum Installieren, das genau dort fehlt, erreicht die Leute nicht, um die es Filipe geht. */ /* ---------- Der Chat-Knopf mit Zähler ----------------------------------- Wunsch Filipe, 06.09.2026: *"die andere person bekommt eine benachrichtigung und kann dann antworten."* WARUM DER ZÄHLER IN DIE KOPFLEISTE GEHÖRT und nicht nur auf die Chatseite: Eine Nachricht, die man erst sieht, wenn man den Chat aufmacht, ist keine Nachricht -- sie ist ein Fundstück. Die Push-Benachrichtigung erreicht nur, wer sie erlaubt hat und wer gerade nicht am Rechner sitzt; hier drin sieht sie jeder, auf jeder Seite. Auf der Chatseite selbst wird der Knopf NICHT gebaut -- dort ist man schon, und ein Knopf, der auf die eigene Seite führt, ist eine Sackgasse. Die Zahl aktualisiert chat.js dann direkt. */ function aufbauChat(wohin) { /* Auf der Chatseite selbst gibt es nichts zu verlinken. */ if (/\/chat\.html$/.test(location.pathname)) return; const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; const a = el('a', 'chat-knopf'); a.href = 'chat.html'; a.title = 'Chat'; a.setAttribute('aria-label', 'Chat'); const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); blase.setAttribute('viewBox', '0 0 24 24'); blase.setAttribute('class', 'chat-knopf__zeichen'); blase.setAttribute('aria-hidden', 'true'); blase.innerHTML = ''; const zahl = el('span', 'chat-knopf__zahl'); zahl.hidden = true; a.append(blase, el('span', 'chat-knopf__text', 'Chat'), zahl); /* Vor der Suche: Der Chat ist das, was am ehesten auf eine Antwort wartet -- die Suche fängt man selbst an, wenn man sie braucht. */ wohin.prepend(a); /** Setzt die Zahl. Wird auch von chat.js aufgerufen, damit sie dort * sofort verschwindet, wenn man gelesen hat. */ window.chatZahlZeigen = (n) => { const wert = Number(n) || 0; zahl.hidden = wert === 0; /* Über 99 wird die Zahl breiter als der Knopf -- und die genaue Menge interessiert ab da ohnehin niemanden mehr. */ zahl.textContent = wert > 99 ? '99+' : String(wert); a.dataset.neu = wert > 0 ? 'ja' : ''; a.setAttribute('aria-label', wert ? `Chat, ${wert} ungelesene ${wert === 1 ? 'Nachricht' : 'Nachrichten'}` : 'Chat'); /* ---- UND DAS ABZEICHEN AM APP-SYMBOL (30.09.2026) ---------- Hier und nirgends sonst: Diese Funktion ist die EINE Stelle, an der die Zahl im Browser gesetzt wird — beim Laden, aus dem Ereignisstrom und aus dem Chat, sobald man gelesen hat. Eine zweite Zählung wäre die, die irgendwann abweicht. Damit verschwindet das Abzeichen auch wirklich: Wer liest, setzt es auf null. Genau deshalb zählt es ungelesene Nachrichten und keine offenen Aufgaben — die gingen durch das Öffnen der App nicht weg, und ein Abzeichen, das immer dasteht, ist keins mehr. Still versuchen: Firefox kennt es nicht, Safari nur für installierte Apps. Eine Zugabe darf nichts kaputtmachen. */ try { if (wert > 0) navigator.setAppBadge?.(wert); else navigator.clearAppBadge?.(); } catch { /* kann dieses Gerät nicht */ } }; async function holen() { try { const antwort = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' }); if (!antwort.ok) return; window.chatZahlZeigen((await antwort.json()).ungelesen); } catch { /* die Zahl ist Zugabe, kein Grund für eine Fehlermeldung */ } } holen(); /* NEUE NACHRICHTEN KOMMEN VON SELBST -- derselbe Strom wie im Chat. Ohne ihn müsste man die Seite neu laden, um zu erfahren, dass jemand geschrieben hat. Kein Nachfragen im Takt: Bei zehn Angemeldeten wären das siebentausend Anfragen in der Stunde für eine Zahl, die sich selten ändert. */ if (window.EventSource) { try { /* ==== DER STROM MUSS BEIM WEGGEHEN ZUGEMACHT WERDEN ============= (20.09.2026) Filipe, zum dritten Mal: "wenn ich in eine kategorie rein geh und dan zurueck, geht die hauptseite immer wieder ganz hoch ... OHNE DASS DIE SEITE HOCH SCROLLT ODER NEU LAEDT." Der zweite Teil ist der eigentliche: Die Seite LAEDT NEU. Und daran war dieser Strom schuld. Browser haben einen Vor-/Zurueck-Speicher (bfcache): Beim Zurueckgehen wird die alte Seite nicht neu gebaut, sondern eingefroren wieder aufgeweckt -- mit allem, auch mit der Bildlaufstelle, sofort und ohne einen einzigen Abruf. Genau das, was Filipe beschreibt. EINE OFFENE EventSource SPERRT DIESEN SPEICHER AUS. Das ist keine Vermutung, sondern die ausdrueckliche Regel von Chrome und Firefox: Eine Seite mit offener Verbindung darf nicht eingefroren werden, weil der Server sonst auf einen Zuhoerer schreibt, den es nicht mehr gibt. Dieser Strom laeuft auf 32 Seiten -- also auf allen. Damit war JEDE Rueckkehr ein vollstaendiger Neuaufbau: Seite leer, Inhalt nachladen, Stelle verloren. Das Nachspringen weiter unten hat danach repariert, was gar nicht haette kaputtgehen muessen. JETZT: Beim Weggehen (`pagehide`) wird zugemacht, beim Zurueckkommen (`pageshow`) neu aufgemacht -- und dann einmal nachgezaehlt, weil in der Zwischenzeit etwas gekommen sein kann. Fuer den Menschen aendert sich nichts, ausser dass Zurueckgehen sofort geht. `persisted` IST DIE ANTWORT AUF "kam sie aus dem Speicher?". Nur dann muss neu verbunden werden; bei einem gewoehnlichen Laden hat der Aufbau oben das schon getan. */ let strom = null; const stromAuf = () => { if (strom) return; strom = new EventSource('/workspace/api/chat/strom'); strom.addEventListener('neu', holen); /* NUR AB DEM ZWEITEN MAL (06.09.2026). Hier stand schlicht `addEventListener('open', holen)` -- und damit fragte JEDE Seite die Zahl zweimal ab: einmal direkt beim Laden und einmal Millisekunden spaeter, sobald der Strom aufging. Gemessen von pruef-tempo-workspace auf fuenf Seiten. Zwei Anfragen fuer dieselbe Zahl sind kein Fehler, den jemand bemerkt -- und genau deshalb bleibt so etwas jahrelang stehen. Bei zehn Angemeldeten ist es die doppelte Last fuer nichts. */ let schonOffen = false; strom.addEventListener('open', () => { if (schonOffen) holen(); schonOffen = true; }); }; const stromZu = () => { if (!strom) return; try { strom.close(); } catch { /* egal */ } strom = null; }; /* ==== SAGEN, OB WIR VORN LIEGEN (30.09.2026) ============= Diene im Support: „Die Benachrichtigungen werden nicht angezeigt, wenn neue Nachrichten reinkommen. Erst, wenn man die App öffnet." Gemessen (pruef-abzeichen, Abschnitt 8): Der Server unterdrückte die Meldung, solange eine Verbindung offen war. Ein Handy mit der App im Hintergrund hält sie weiter — also hielt er ihn für anwesend, während sein Bildschirm schwarz war. DIESE SEITE IST DIE EINZIGE, DIE DEN UNTERSCHIED KENNT. `document.visibilityState` sagt es; der Server kann es nicht wissen. Also sagt sie es ihm. `keepalive` BEIM WEGGEHEN: Ohne das bricht der Browser die Anfrage ab, während die Seite verschwindet — und genau die eine, die „ich bin weg" sagt, käme nie an. UND ALLE MINUTE NOCH EINMAL, solange wir vorn liegen. Nicht aus Misstrauen gegen `visibilitychange`, sondern weil der Server einen VERFALL braucht: Ein Gerät, das abstürzt oder im Funkloch steht, sagt gar nichts mehr. Ohne das Lebenszeichen bliebe es dort für immer „sichtbar" und für immer still. */ const sichtMelden = (sichtbar) => { try { fetch('/workspace/api/chat/sicht', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sichtbar: !!sichtbar }), keepalive: true, }).catch(() => { /* beim naechsten Mal wieder */ }); } catch { /* egal */ } }; const sichtJetzt = () => sichtMelden(document.visibilityState === 'visible'); sichtJetzt(); document.addEventListener('visibilitychange', sichtJetzt); const sichtUhr = setInterval(() => { if (document.visibilityState === 'visible') sichtMelden(true); }, 60_000); sichtUhr.unref?.(); window.addEventListener('pagehide', () => { sichtMelden(false); stromZu(); }); window.addEventListener('pageshow', (e) => { if (!e.persisted) return; /* gewoehnliches Laden: schon auf */ stromAuf(); sichtJetzt(); /* wir sind wieder da */ holen(); /* was kam, waehrend wir weg waren? */ }); stromAuf(); /* Ab hier nur noch der alte Aufbau als Erklaerung -- die Zuhoerer haengen jetzt in stromAuf(). */ } catch { /* dann eben nur beim Laden */ } } /* Zurück an den Bildschirm: nachsehen. Fängt den Fall ab, dass der Strom währenddessen stillgelegt wurde. */ document.addEventListener('visibilitychange', () => { if (!document.hidden) holen(); }); } function aufbauSuche(wohin) { const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; /* Auslöser in der Kopfleiste */ const knopf = el('button', 'suche-knopf'); knopf.type = 'button'; knopf.title = 'Suchen (Taste /)'; knopf.setAttribute('aria-label', 'Suchen'); const lupe = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); lupe.setAttribute('viewBox', '0 0 24 24'); lupe.setAttribute('class', 'suche-knopf__lupe'); lupe.setAttribute('aria-hidden', 'true'); lupe.innerHTML = ''; knopf.append(lupe, el('span', 'suche-knopf__text', 'Suchen')); wohin.prepend(knopf); /* Overlay */ const schleier = el('div', 'suche-schleier'); schleier.hidden = true; const fenster = el('div', 'suche-fenster'); fenster.setAttribute('role', 'dialog'); fenster.setAttribute('aria-modal', 'true'); fenster.setAttribute('aria-label', 'Suche über alle Bereiche'); const zeile = el('div', 'suche-zeile'); const feld = el('input', 'suche-feld'); feld.type = 'search'; feld.placeholder = 'Suchen – Aufgaben, Gespräche, Dateien, Profile …'; feld.setAttribute('aria-label', 'Suchbegriff'); feld.maxLength = 100; const zu = el('button', 'suche-zu', 'Esc'); zu.type = 'button'; zeile.append(feld, zu); const ergebnis = el('div', 'suche-ergebnis'); ergebnis.setAttribute('aria-live', 'polite'); fenster.append(zeile, ergebnis); schleier.append(fenster); document.body.append(schleier); const zeigeHinweis = (text) => { ergebnis.textContent = ''; ergebnis.append(el('p', 'suche-hinweis', text)); }; let letzte = 0; async function suche() { const q = feld.value.trim(); if (q.length < 2) { zeigeHinweis('Mindestens zwei Zeichen.'); return; } const meine = ++letzte; try { const a = await fetch('/workspace/api/suche?q=' + encodeURIComponent(q), { credentials: 'same-origin' }); if (meine !== letzte) return; // eine neuere Abfrage ist schon unterwegs if (a.status === 401) { location.assign('/workspace/'); return; } if (!a.ok) { zeigeHinweis('Die Suche ist gerade nicht erreichbar.'); return; } zeichne(await a.json()); } catch { if (meine === letzte) zeigeHinweis('Die Suche ist gerade nicht erreichbar.'); } } function zeichne(d) { ergebnis.textContent = ''; if (!d.gesamt) { zeigeHinweis(`Nichts gefunden zu „${d.frage}“.`); return; } for (const g of d.gruppen) { const s = el('section', 'suche-gruppe'); s.append(el('h3', 'suche-gruppe__titel', g.titel)); for (const tr of g.treffer) { const a = el('a', 'suche-treffer'); a.href = tr.ziel; a.append(el('span', 'suche-treffer__titel', tr.titel)); if (tr.zusatz) a.append(el('span', 'suche-treffer__zusatz', tr.zusatz)); if (tr.text) a.append(el('span', 'suche-treffer__text', tr.text)); s.append(a); } ergebnis.append(s); } } let warte = null; feld.addEventListener('input', () => { clearTimeout(warte); warte = setTimeout(suche, 220); }); function auf() { schleier.hidden = false; document.body.classList.add('suche-offen'); feld.focus(); feld.select(); if (!feld.value.trim()) zeigeHinweis('Tippen, um alle Bereiche zu durchsuchen.'); } function zuMachen() { schleier.hidden = true; document.body.classList.remove('suche-offen'); knopf.focus(); } knopf.addEventListener('click', auf); zu.addEventListener('click', zuMachen); schleier.addEventListener('mousedown', (e) => { if (e.target === schleier) zuMachen(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !schleier.hidden) { zuMachen(); return; } /* "/" oeffnet die Suche -- aber nur, wenn man nicht ohnehin gerade in ein Feld schreibt. */ if (e.key !== '/' || schleier.hidden === false) return; const z = document.activeElement; if (z && (z.tagName === 'INPUT' || z.tagName === 'TEXTAREA' || z.tagName === 'SELECT' || z.isContentEditable)) return; e.preventDefault(); auf(); }); } })(); /* =================================================================== "Zeig mir genau das" -- der Sprung von einer Zahl zu dem, was sie zaehlt. Wunsch vom 01.09.2026 zur Report-Seite: "wenn man auf die Kisten drueckt, will ich sofort zu der Seite -- diesem Punkt -- weitergeschickt werden, wo man draufgedrueckt hat." Eine Zahl auf einem Bericht ist eine Behauptung. Wer sie nicht nachsehen kann, muss sie glauben -- und wer sie nachsehen will, sucht sie auf der Zielseite von Hand zusammen. Genau dieser Weg faellt hier weg. Die Zahl fuehrt deshalb nicht nur auf die richtige SEITE, sondern auf die richtige STELLE: ?zeigen= in der Adresse, die Zielseite sucht die passenden Elemente, springt hin und hebt sie kurz hervor. EIN Helfer fuer alle Seiten und nicht fuenf eigene: Sonst springt die eine Seite hart, die andere weich, die dritte hebt gar nichts hervor -- und niemand traut dem Weg mehr. Wichtig ist die RUECKNAHME. Eine Seite, die nach dem Sprung dauerhaft gefiltert bleibt, ist eine Falle: Man kommt spaeter wieder, sieht drei von zwanzig Aufgaben und haelt das fuer den ganzen Bestand. Deshalb steht immer eine Zeile da, die sagt, was gerade gezeigt wird -- mit einem Weg zurueck zu allem. =================================================================== */ (() => { 'use strict'; const ZEIGEN = new URLSearchParams(location.search).get('zeigen') || ''; /** Was steht in der Adresse? Leerer Text, wenn nichts. */ window.zielSchluessel = () => ZEIGEN; /** Hervorheben und hinspringen. * elemente: was gemeint war (Feld, darf leer sein) * text: was gerade gezeigt wird, in Worten * aufraeumen: optional -- wird gerufen, wenn "alles zeigen" geklickt wird */ window.zielMarkieren = function (elemente, text, aufraeumen) { const liste = [...(elemente || [])].filter(Boolean); /* Die Zeile kommt IMMER, auch wenn nichts gefunden wurde. Ein stiller Sprung ins Leere laesst einen glauben, man sei falsch abgebogen -- dabei ist die Sache schlicht erledigt. */ const leiste = document.createElement('div'); leiste.className = 'ziel-leiste'; leiste.setAttribute('role', 'status'); const marke = document.createElement('span'); marke.className = 'ziel-leiste__marke'; marke.textContent = liste.length ? String(liste.length) : '0'; leiste.append(marke); const satz = document.createElement('span'); satz.className = 'ziel-leiste__text'; satz.textContent = liste.length ? text : text + ' – hier ist gerade nichts davon da.'; leiste.append(satz); const weg = document.createElement('button'); weg.type = 'button'; weg.className = 'ziel-leiste__weg'; weg.textContent = 'Alles zeigen'; weg.addEventListener('click', () => { for (const e of liste) e.removeAttribute('data-treffer'); leiste.remove(); /* Die Adresse mitaufraeumen: Sonst zeigt ein Neuladen wieder den Ausschnitt, und man wundert sich, warum die Haelfte fehlt. */ const u = new URL(location.href); u.searchParams.delete('zeigen'); history.replaceState(null, '', u); aufraeumen?.(); }); leiste.append(weg); const inhalt = document.querySelector('main.inhalt') || document.body; const nach = inhalt.querySelector('.kopf-zeile'); if (nach && nach.nextSibling) inhalt.insertBefore(leiste, nach.nextSibling); else inhalt.prepend(leiste); for (const e of liste) e.dataset.treffer = 'ja'; /* Erst im naechsten Bild springen: Die Elemente sind gerade eben entstanden, ihre Position steht noch nicht fest. */ if (liste.length) { requestAnimationFrame(() => { const ruhig = window.matchMedia('(prefers-reduced-motion: reduce)').matches; liste[0].scrollIntoView({ block: 'center', behavior: ruhig ? 'auto' : 'smooth', }); }); } return liste.length; }; })(); /* =================================================================== DIE SICHT EINES ANDEREN — nur fuer DogFather, auf jeder Seite. Wunsch vom 01.09.2026: "ich will alles von den Scouts und Managern einsehen koennen, auswaehlen, was und von wem ich sehen will. Und das soll ich als DogFather-Rolle ueberall haben, auf jeder Seite. NUR WIR BEIDE SOLLEN DIESE OPTION HABEN." ZWEI ENTSCHEIDUNGEN, die alles andere nach sich ziehen. 1. DIE PERSON WIRD GETAUSCHT, NICHT DIE REGEL. Waehlt DogFather einen Scout, laeuft jede vorhandene Sichtbarkeitsregel auf dem Server unveraendert mit diesem Scout -- er sieht dann exakt dessen Arbeitsplatz. Kein neuer Filter je Seite, keine zweite Rechteregel, die irgendwann von der ersten abweicht. 2. EIN EINZIGER ORT IM BROWSER. Statt in vierzehn Dateien "&sicht=" anzuhaengen (und es in der fuenfzehnten zu vergessen), wird fetch EINMAL umgeleitet: Jede lesende Anfrage an /workspace/api bekommt den Wert automatisch mit. Neue Seiten sind damit von selbst dabei -- man kann es gar nicht vergessen. Angehaengt wird NUR bei GET. Ein Schreibvorgang findet immer im eigenen Namen statt; alles andere waere ein Eintrag im Protokoll unter falschem Namen. Die Auswahl liegt im Browser (localStorage), nicht am Konto: Sie ist eine Ansicht dieses Geraets, keine Eigenschaft der Person. Wer sich woanders anmeldet, faengt bei "alles" an -- und das ist richtig so. Der Server prueft die Rolle NOCH EINMAL (workspace.js, sichtPerson). Was hier steht, ist Bedienung, kein Schutz: Wer kein DogFather ist, kann den Wert von Hand anhaengen, so oft er mag -- er wird ignoriert. =================================================================== */ (() => { 'use strict'; /* Welcher Kalendertag ist heute -- nach der ORTSZEIT. Steht in kopf.js, weil die Datei auf jeder Workspace-Seite geladen wird und VOR den Seitenskripten laeuft. Vorher rechnete jede Seite selbst, und vier von fuenf rechneten in UTC: Zwischen Mitternacht und 2 Uhr war "heute" dort der Vortag. Fuers RECHNEN mit Datumsangaben bleibt es bei UTC-Mittag (siehe kalender.js) -- das ueberlebt die Zeitumstellung. Hier geht es nur um die Frage "welcher Tag ist gerade". */ window.heuteLokal = function () { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }; const SCHLUESSEL = 'dfw-sicht'; const lesen = () => { try { return Number(localStorage.getItem(SCHLUESSEL)) || 0; } catch { return 0; } }; const schreiben = (id) => { try { if (id) localStorage.setItem(SCHLUESSEL, String(id)); else localStorage.removeItem(SCHLUESSEL); } catch { /* privater Modus -- dann gilt es nur fuer diese Seite */ } }; window.sichtId = lesen; /* ---------- fetch einmal umleiten ------------------------------------- */ /* SOFORT gesetzt, nicht erst beim Bauen der Kopfleiste. Genau das war der Fehler hinter "ich hab die Ansicht von Tili ausgewaehlt und seh immer noch die Seite genau wie meine": `aktiv` wurde erst in sichtAufbauen() gefuellt, und das laeuft ueber werZeigen() -- also NACHDEM die Seite ihre erste Abfrage schon abgeschickt hat. Die allererste Anfrage (/api/ich, aus der Kacheln, Rolle und Begruessung entstehen) ging damit immer ohne die gewaehlte Sicht hinaus. Ein Fehler der REIHENFOLGE, nicht der Logik -- und deshalb einer, den man im Quelltext nicht sieht: Beide Zeilen sind fuer sich richtig. */ let aktiv = lesen(); const echtesFetch = window.fetch.bind(window); window.fetch = function (eingabe, optionen) { try { const art = (optionen?.method || (typeof eingabe === 'object' ? eingabe.method : '') || 'GET') .toUpperCase(); const pfad = typeof eingabe === 'string' ? eingabe : (eingabe?.url || ''); if (aktiv && art === 'GET' && pfad.startsWith('/workspace/api/') && !pfad.includes('sicht=')) { const neu = pfad + (pfad.includes('?') ? '&' : '?') + 'sicht=' + aktiv; return echtesFetch(typeof eingabe === 'string' ? neu : new Request(neu, eingabe), optionen); } } catch { /* im Zweifel unveraendert weiterreichen */ } return echtesFetch(eingabe, optionen); }; /* ---------- Der Umschalter im Seitenkopf -------------------------------- */ const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; /* DIE REIHENFOLGE KOMMT AUS DER ZENTRALEN LISTE (09.09.2026). Hier stand eine eigene: manager, scout, creator, admin. Also VIER Rollen -- und Spicy Media fehlte. Wer eine Spicy-Media-Person ansehen wollte, fand sie im Umschalter nicht, obwohl der Server sie mitliefert. Filipe hat es gemeldet: "ich will da auch noch die spicy rolle sehen und die leute in der rolle wie die anderen." Es ist zum vierten Mal derselbe Fehler: eine zweite Fassung einer Liste, die es zentral schon gibt. Vorher traf es die Leitungsliste (ein Set), die Rollennamen (ein Objekt in fuenf Skripten, im Chat stand "UNDEFINED" ueber einem Namen) und die Personenliste. Jedes Mal wurde die zentrale Fassung gepflegt und die Kopie vergessen. Seit dem 11.09.2026 steht hier gar keine Liste mehr -- auch keine Ableitung. Die Reihenfolge und die Ueberschriften kommen vom Server; `window.Bereiche.gruppieren()` setzt sie nur noch um. Das ist die Fassung, die auch dann stimmt, wenn eine Rolle dazukommt, deren Namen diese Datei gar nicht kennen darf. */ /* EIN WETTLAUF, den es wirklich gab: Auf profil.html rufen zwei Dateien werZeigen() auf (profil.js und steckbrief.js). Beide kamen an der Pruefung "gibt es den Umschalter schon?" vorbei, BEVOR eine von ihnen ihn angehaengt hatte -- dazwischen liegt ein await auf die Personenliste. Ergebnis: zwei Umschalter uebereinander, der eine verdeckte den anderen. Gemeldet von pruef-handy als "verdeckt". Die Sperre muss deshalb VOR dem ersten await gesetzt werden. */ let baut = false; window.sichtAufbauen = async function (ich) { if (!ich || ich.rolle !== 'admin') return; const kopf = document.querySelector('.kopfleiste__rechts'); if (!kopf || baut || document.getElementById('kopf-sicht-wahl')) return; baut = true; aktiv = lesen(); /* nochmals lesen -- ein anderer Reiter kann sie geaendert haben */ let personen = []; try { /* Ueber das echte fetch -- diese eine Abfrage darf nicht durch die gewaehlte Sicht laufen, sonst waere die Auswahlliste selbst gefiltert und man kaeme nie wieder heraus. */ const a = await echtesFetch('/workspace/api/personen', { credentials: 'same-origin' }); if (!a.ok) { baut = false; return; } personen = (await a.json()).personen || []; } catch { baut = false; return; } const feld = el('div', 'sicht'); feld.id = 'kopf-sicht-wahl'; /* EIN AUGE STATT DES WORTES "SICHT" (06.09.2026). Vorher stand da ein Etikett mit dem Wort SICHT und daneben ein Auswahlfeld -- ein Formular, das in die Kopfleiste gerutscht war. Das Auge sagt dasselbe in einem Zeichen und laesst dem Namen den Platz, um den es geht. aria-hidden, weil der Knopf daneben bereits sagt, was er tut -- ein Vorleseprogramm soll nicht "Grafik, Auge" davor lesen. */ const auge = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); auge.setAttribute('class', 'sicht__auge'); auge.setAttribute('viewBox', '0 0 24 24'); auge.setAttribute('aria-hidden', 'true'); auge.innerHTML = '' + ''; feld.append(auge); /* Fuer Vorleseprogramme bleibt die Beschriftung erhalten -- sie ist nur nicht mehr zu sehen. */ const schild = el('label', 'sicht__schild', 'Sicht'); schild.setAttribute('for', 'kopf-sicht-feld'); feld.append(schild); const s = el('select', 'sicht__feld'); s.id = 'kopf-sicht-feld'; /* "Meine Sicht" statt "Alles (meine Sicht)". Der Zusatz erklaerte nichts und machte den Knopf so breit, dass am Handy nichts anderes mehr danebenpasste. */ s.append(new Option('Meine Sicht', '')); /* IN DER REIHENFOLGE DES SERVERS (11.09.2026). Vorher zwei Durchgaenge: erst die fuenf Rollen aus bereiche.js, dann "alle uebrigen". Niemand fehlte mehr -- aber Team Dogi stand zwangslaeufig ganz unten, hinter jedem Creator. Filipe wollte die rechte Hand ueberall vorn haben, und "ueberall" heisst auch hier. Die Ueberschrift kommt vom Server, so kann in dieser Auswahl auch eine Rolle stehen, deren Namen diese Datei gar nicht kennen darf. */ const sichtbar = personen.filter((p) => p.id !== ich.id && p.aktiv !== false); const gruppen = window.Bereiche?.gruppieren?.(sichtbar) || [{ titel: 'Alle', rolle: '', leute: sichtbar }]; for (const gruppe of gruppen) { const g = document.createElement('optgroup'); g.label = gruppe.titel; for (const p of gruppe.leute) g.append(new Option(p.name, String(p.id))); s.append(g); } if (aktiv) s.value = String(aktiv); /* Stand die gemerkte Person nicht mehr in der Liste (geloescht oder abgeschaltet), faellt die Auswahl zurueck -- sonst zeigte das Feld "Alles" und die Seiten trotzdem eine fremde Sicht. */ if (aktiv && s.value !== String(aktiv)) { aktiv = 0; schreiben(0); } s.addEventListener('change', () => { aktiv = Number(s.value) || 0; schreiben(aktiv); /* Neu laden statt jede Seite einzeln zu benachrichtigen: Jede Seite baut sich beim Laden ohnehin vollstaendig auf, und ein halb umgestellter Bildschirm waere schlimmer als eine kurze Pause. */ location.reload(); }); feld.append(s); /* Ein deutlicher Hinweis, SOLANGE eine fremde Sicht aktiv ist. Ohne ihn ist der gefaehrlichste Fall dieser Funktion: Man vergisst die Auswahl, sieht am naechsten Tag drei Aufgaben statt dreissig und haelt das fuer den Bestand. */ if (aktiv) { feld.dataset.fremd = 'ja'; /* ---- WESSEN SICHT: FARBE UND ANFANGSBUCHSTABE (09.09.2026) ---- Filipe, mit Bildschirmfoto aus der App: "es ist noch nicht alles in einer zeile." Gemessen war der Grund NICHT die Breite seines Geraets, sondern der Zustand: In der eigenen Sicht klappt der Umschalter auf 36 px zusammen, in einer fremden bleibt er mit dem Namen darin 152 px breit. Das sind 116 px mehr, und die passen auf KEINER Handybreite mehr in die Reihe -- auch nicht auf 430 px. Meine bisherigen Pruefungen liefen alle in der eigenen Sicht und haben deshalb nie etwas gefunden. Sie haben einen Zustand gemessen, in dem der Nutzer gar nicht war. Der Name wandert deshalb aus der Leiste in ein eigenes Band darunter (siehe unten). Im Knopf bleiben zwei Dinge, die zusammen weniger als ein Viertel des Platzes brauchen und mehr sagen als ein abgeschnittenes "Miesmus...": die FARBE der Rolle und der ANFANGSBUCHSTABE des Namens. Die Farbe kommt aus `data-rolle` -- dieselbe Zuordnung, die gate.css ohnehin fuer jede Rolle hat. Keine zweite Liste; im Haus ist genau die schon mehrfach auseinandergelaufen. */ const wen = personen.find((p) => p.id === aktiv); if (wen?.rolle) feld.dataset.rolle = wen.rolle; const name = (wen?.name || '').trim(); const kuerzel = el('span', 'sicht__kuerzel', (name.charAt(0) || '?').toUpperCase()); /* Fuer ein Vorleseprogramm steht der ganze Name im Band darunter und im Auswahlfeld -- ein einzelner Buchstabe waere dort nur Rauschen. */ kuerzel.setAttribute('aria-hidden', 'true'); auge.after(kuerzel); /* EIN KREUZ STATT "zurueck zu mir". Der Satz brauchte mehr Platz als der Name daneben, und was ein Kreuz an einer aktiven Auswahl tut, weiss jeder. Die Worte bleiben im aria-label. */ const zurueck = el('button', 'sicht__weg'); zurueck.type = 'button'; zurueck.setAttribute('aria-label', 'Zurück zu meiner eigenen Sicht'); zurueck.title = 'Zurück zu meiner eigenen Sicht'; const kreuz = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); kreuz.setAttribute('viewBox', '0 0 24 24'); kreuz.setAttribute('aria-hidden', 'true'); kreuz.innerHTML = ''; zurueck.append(kreuz); zurueck.addEventListener('click', () => { schreiben(0); location.reload(); }); feld.append(zurueck); document.body.dataset.fremdeSicht = 'ja'; /* ---- DAS BAND UNTER DER LEISTE -------------------------------- Es traegt, was aus der Leiste weichen musste: den vollen Namen und den Weg zurueck -- beides in Worten statt als Kreuz. Auf dem Handy ist es die einzige Stelle, an der der Name steht, und es steht dort GANZ, nicht als "Miesmus...". Es gehoert nicht in die Kopfleiste, sondern darunter: In der Leiste waere es die zweite Zeile, die weg soll. Als eigenes Band ist es eine Meldung zur Lage, kein Bedienelement mehr -- und es scrollt mit, waehrend der Rahmen um die Seite als staendiges Zeichen bleibt. Auf dem Rechner ist es ueberfluessig, dort steht der Name im Umschalter selbst. Das entscheidet das CSS an einer Stelle. */ const band = el('div', 'fremd-band'); band.id = 'kopf-fremd-band'; if (wen?.rolle) band.dataset.rolle = wen.rolle; band.setAttribute('role', 'status'); const bandAuge = auge.cloneNode(true); bandAuge.setAttribute('class', 'fremd-band__auge'); band.append(bandAuge); const satz = el('p', 'fremd-band__text'); satz.append(document.createTextNode('Du siehst gerade die Sicht von ')); satz.append(el('strong', 'fremd-band__name', name || 'jemand anderem')); band.append(satz); const raus = el('button', 'fremd-band__weg', 'Zurück zu meiner Sicht'); raus.type = 'button'; raus.addEventListener('click', () => { schreiben(0); location.reload(); }); band.append(raus); const leiste = document.querySelector('.kopfleiste'); if (leiste) leiste.after(band); else document.body.prepend(band); } kopf.prepend(feld); /* DER RAHMEN UM DIE SEITE NIMMT DIESELBE FARBE AN. Der Wert wird aus dem fertigen Element GELESEN, nicht in Javascript nachgeschlagen -- sonst stuende hier eine zweite Liste mit Rollenfarben neben der in gate.css, und beim naechsten Farbwechsel bliebe eine davon stehen. Erst nach dem Einhaengen, vorher hat das Element noch keinen berechneten Stil. */ if (aktiv) { const ton = getComputedStyle(feld).getPropertyValue('--rolle-haupt').trim(); if (ton) document.body.style.setProperty('--fremd-ton', ton); } }; /* An werZeigen anhaengen: Jede Seite ruft es ohnehin auf, sobald sie weiss, wer angemeldet ist. Damit ist der Umschalter wirklich auf JEDER Seite da, ohne dass eine davon eine Zeile bekommt. */ let gerufen = false; const vorher = window.werZeigen; window.werZeigen = function (ich) { gerufen = true; vorher?.(ich); window.sichtAufbauen(ich); }; /* DAS NETZ DARUNTER. "Jede Seite ruft es ohnehin auf" stimmte nicht ganz: steckbrief.html tat es nie, und deshalb stand dort oben rechts monatelang "…" statt des eigenen Namens. Gefunden hat es nicht das Auge, sondern eine Pruefung, die den Umschalter auf ALLEN Seiten verlangt hat. Ein Fehler, der einmal passiert ist, passiert wieder -- die naechste neue Seite vergisst dieselbe Zeile. Meldet sich also nach kurzer Zeit niemand, holt der Kopf sich selbst, wer angemeldet ist. Im Normalfall kostet das nichts: Dann war jemand schneller, und diese Abfrage findet gar nicht erst statt. */ setTimeout(async () => { if (gerufen || !document.getElementById('wer')) return; try { const a = await echtesFetch('/workspace/api/ich', { credentials: 'same-origin' }); if (a.ok) window.werZeigen(await a.json()); } catch { /* nicht angemeldet -- die Seite leitet ohnehin um */ } }, 1200); /* ===================================================================== ZURÜCK-TASTE: die Seite neu holen statt aus der Schublade nehmen (02.09.2026) Gemeldet: "wie du siehst hab ich es gemacht und wird es immer noch angezeigt" -- ein Protokoll war geschrieben, die Startseite meldete trotzdem weiter "1 Gespräch hat noch kein Protokoll". Nachgestellt: Der Server lag richtig. Der Hinweis erscheint, sobald ein Gespräch ohne Protokoll vorbei ist, und verschwindet in dem Moment, in dem das Protokoll steht. Falsch war der BILDSCHIRM. Browser legen eine verlassene Seite komplett beiseite (bfcache) und holen sie beim Zurückgehen unverändert wieder hervor -- mitsamt allen Zahlen, die beim ersten Laden geholt wurden. Kein Skript läuft dabei erneut. Wer also ein Protokoll schreibt und dann auf "Zurück" tippt, sieht zwangsläufig den Stand von vorher. Das ist kein Schönheitsfehler: Eine Zahl, die etwas Falsches behauptet, ist schlimmer als gar keine -- man glaubt ihr ja. Genau diese Sorte Fehler kostet danach Vertrauen in ALLE Zahlen. `persisted` unterscheidet die beiden Fälle sauber: true heisst "aus der Schublade geholt", false heisst "ganz normal geladen". Nur im ersten Fall wird neu geladen -- ein pauschales Neuladen bei jedem Anzeigen wäre eine Endlosschleife. */ addEventListener('pageshow', (e) => { if (!e.persisted) return; /* DIE STELLE UEBERLEBT DAS NEULADEN (21.09.2026). ---------------------------------------------------------------- Filipe, zum vierten Mal: "wenn ich in eine kategorie rein gehe und dan zurueck geh die hauptseite immer wieder ganz hoch ... ohne dass die seite hoch scrollt ODER NEU LAEDT." Das "oder neu laedt" war der entscheidende Hinweis, und ich habe ihn zweimal ueberlesen. Genau hier wird neu geladen -- und nach einem Neuladen heisst die Navigationsart "reload", nicht "back_forward". Die Wiederherstellung sprang deshalb nicht an UND loeschte die gemerkte Stelle sogar (siehe unten). Ein einziges Zurueckgehen reichte, und danach war sie fuer immer weg. Warum es in JEDER Messung funktionierte: Hier haelt kopf.js einen Ereignisstrom offen, und der sperrt den Rueckwaerts- Speicher aus -- `persisted` blieb immer false, diese Zeile lief nie. Auf einem echten Handy greift er sehr wohl. Jetzt wird die Stelle samt Zurueck-Merker festgehalten, BEVOR neu geladen wird. Danach findet die Wiederherstellung sie wieder. */ if (typeof window.stelleFesthalten === 'function') window.stelleFesthalten(); location.reload(); }); })(); /* ===================================================================== DIE SEITE MERKT SICH, WO DU WARST — 19.09.2026 Filipe: „wenn ich von einer seite auf die andere zurück gehe, swipt die seite immer wieder komplett hoch. das nervt man muss dan immer wieder runter scrollen bis man da ist wo man vorher war." --------------------------------------------------------------------- WARUM DER BROWSER ES NICHT VON SELBST HINBEKOMMT Er versucht es sogar — `history.scrollRestoration` steht ab Werk auf „auto", und bei einer gewöhnlichen Seite klappt das. Hier nicht, und der Grund ist die Bauweise des ganzen Hauses: Jede Seite kommt fast leer an und holt ihren Inhalt danach per Abruf (Aufgaben, Termine, Einträge, Karten). In dem Moment, in dem der Browser die alte Position wiederherstellen will, ist das Dokument ein paar hundert Pixel hoch. Er kann nicht auf Zeile 900 springen, die es noch nicht gibt — also bleibt er oben. Eine halbe Sekunde später ist der Inhalt da, aber der Browser versucht es kein zweites Mal. --------------------------------------------------------------------- WIE ES HIER GELÖST IST Wir übernehmen die Aufgabe selbst (`manual`) und warten, bis die Seite WIRKLICH hoch genug ist: 1. Beim Verlassen wird die Position gemerkt — je Adresse einzeln, inklusive Frageteil (`bereich.html?b=treff` ist eine andere Seite als `bereich.html?b=wunsch`). 2. Beim Öffnen wird sie zurückgelesen und dann jeden Bildaufbau lang versucht, bis das Dokument die nötige Höhe hat. 3. Nach längstens drei Sekunden wird aufgegeben — eine Seite, die ewig nachspringt, ist schlimmer als eine, die oben steht. --------------------------------------------------------------------- DREI DINGE, DIE ABSICHT SIND NUR BEIM ZURÜCKGEHEN, NICHT BEI JEDEM ÖFFNEN. Wer eine Kachel anklickt, will oben anfangen. Wer zurückgeht, will dorthin, wo er war. Unterschieden wird über `performance.navigation`: Nur bei `back_forward` wird wiederhergestellt. Ein eigener Merker fängt den Fall ab, dass der Zurück-Knopf der Kopfleiste benutzt wurde. WER SELBST SCROLLT, GEWINNT. Sobald der Mensch das Rad dreht oder wischt, hört das Nachspringen sofort auf. Nichts ist ärgerlicher als eine Seite, die einen zurückzieht. UND SIE VERGISST NACH EINER STUNDE. `sessionStorage` überlebt den Reiter ohnehin nicht; der Zeitstempel fängt den Fall ab, dass jemand denselben Reiter am nächsten Morgen weiterbenutzt. Auf Zeile 900 zu landen, weil man gestern dort war, ist keine Hilfe. ===================================================================== */ (function () { 'use strict'; /* Nach so vielen Millisekunden wird nicht mehr versucht. Drei Sekunden sind großzügig für einen Abruf und kurz genug, dass es nie nach „die Seite hängt" aussieht. AUF SECHS ERHOEHT (21.09.2026). Drei Sekunden sind hier am Kabel grosszuegig und auf einem Handy im Funkloch knapp -- und wer im Funkloch ist, wartet ohnehin auf den Inhalt. Solange die Seite nicht hoch genug ist, wird nichts gesetzt; die Frist kostet also nichts ausser Geduld. Und wer selbst scrollt, beendet sie sofort. */ var FRIST_MS = 6000; /* Nach einer Stunde gilt die gemerkte Stelle nicht mehr. */ var ALTER_MS = 3600000; /* Unter dieser Höhe lohnt das Merken nicht -- wer zehn Pixel gescrollt hat, merkt vom Wiederherstellen nichts. */ var MINDEST_PX = 80; var SCHLUESSEL = 'ws-stelle:' + location.pathname + location.search; function merken() { try { var y = window.scrollY || document.documentElement.scrollTop || 0; if (y < MINDEST_PX) { sessionStorage.removeItem(SCHLUESSEL); return; } sessionStorage.setItem(SCHLUESSEL, JSON.stringify({ y: Math.round(y), am: Date.now() })); } catch (e) { /* Kein Speicher -> dann eben ohne. */ } } /* MEHRERE ANLÄSSE, NICHT NUR EINER. `beforeunload` feuert auf iOS oft gar nicht, `pagehide` dafür zuverlässig; `visibilitychange` fängt den Fall ab, dass jemand die App in den Hintergrund schiebt und das Gerät sie später beendet. Dreimal dasselbe zu schreiben ist hier billiger als ein Fall, in dem nichts gemerkt wird. */ window.addEventListener('pagehide', merken); window.addEventListener('beforeunload', merken); /* UND LAUFEND, WAEHREND MAN SCROLLT (21.09.2026). ------------------------------------------------------------------ Bis heute wurde nur beim Klicken und beim Verlassen gemerkt. Das deckt den Weg ab, den ich mir vorgestellt habe -- nicht die, die es sonst noch gibt: die Glocke, eine Benachrichtigung, ein Skript, das selbst weiterschickt, ein Absturz des Reiters. Gedrosselt auf 250 ms: Ein Schreibvorgang je Bildaufbau waere sechzigmal in der Sekunde und wuerde das Scrollen ruckeln lassen. Bei 250 ms merkt man nichts davon, und schlimmstenfalls fehlen die letzten 250 ms Bewegung. */ var merkUhr = 0; window.addEventListener('scroll', function () { if (merkUhr) return; merkUhr = setTimeout(function () { merkUhr = 0; merken(); }, 250); }, { passive: true }); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') merken(); }); /* Und der Zurück-Knopf der Kopfleiste: Er ruft `history.back()`, und dabei ist `pagehide` je nach Browser zu spät dran. */ /* UNSER EIGENES ZURUECK IST FUER DEN BROWSER EIN HINGEHEN (gebaut am 21.09.2026 -- versprochen war es seit dem 20.09.). Im Kopf dieser Datei steht seit jeher: "Ein eigener Merker faengt den Fall ab, dass der Zurueck-Knopf der Kopfleiste benutzt wurde." Gebaut wurde er nie. Ein Kommentar, der eine Absicht beschreibt, erfuellt sie nicht. WAS DAS IN DER PRAXIS HIESS -- Filipes Video vom 21.09.: Er steht weit unten auf der Startseite, oeffnet ein Brett, geht ueber "TEAM DOGI" in der Kopfzeile zurueck -- und landet ganz oben. Bei jedem Mal. DER GRUND: Dieser Weg zurueck ist ein ``. Fuer den Browser ist das eine NEUE Fahrt vorwaerts, nicht `back_forward`. Die Wiederherstellung unten laeuft deshalb nicht -- und schlimmer: Der `else`-Zweig loescht die eben gemerkte Stelle sogar, weil "ein frisch geoeffneter Bereich faengt oben an". Der Browser-Zurueckknopf und der Pfeil in der Kopfleiste (`history.back()`) waren nie betroffen. Deshalb hat es in jedem Test funktioniert und im Alltag nie -- gemessen mit Playwright: y=1200 sauber wiederhergestellt. Man muss den Weg gehen, den der Mensch geht. DER MERKER GILT NUR EINMAL UND NUR KURZ: Er nennt das Ziel und die Zeit. Wer eine Minute spaeter dieselbe Seite normal oeffnet, faengt wieder oben an -- wie es sein soll. */ var ZURUECK_SCHLUESSEL = 'ws-zurueck'; var ZURUECK_MS = 30000; function zurueckMerken(ziel) { try { sessionStorage.setItem(ZURUECK_SCHLUESSEL, JSON.stringify({ ziel: ziel, am: Date.now() })); } catch (e) { /* ohne Speicher eben ohne */ } } /** Bin ich gerade ueber einen EIGENEN Zurueck-Weg hier gelandet? * Verbraucht den Merker dabei -- er gilt fuer genau eine Ankunft. */ function ueberEigenesZurueck() { var roh; try { roh = sessionStorage.getItem(ZURUECK_SCHLUESSEL); } catch (e) { return false; } if (!roh) return false; try { sessionStorage.removeItem(ZURUECK_SCHLUESSEL); } catch (e) { /* egal */ } var m; try { m = JSON.parse(roh); } catch (e) { return false; } if (!m || Date.now() - (m.am || 0) > ZURUECK_MS) return false; return m.ziel === location.pathname + location.search; } document.addEventListener('click', function (e) { var ziel = e.target && e.target.closest && e.target.closest('a[href], .zurueck-knopf, .zurueck'); if (!ziel) return; merken(); /* NUR bei den Wegen, die WIRKLICH zurueckfuehren -- nicht bei jedem Link. Sonst wuerde jede Kachel als "zurueck" gelten und man landete beim Oeffnen eines Bretts mitten darin. */ var istZurueck = ziel.classList && (ziel.classList.contains('zurueck') || ziel.classList.contains('zurueck-knopf')); if (!istZurueck) return; var adresse = ziel.getAttribute && ziel.getAttribute('href'); if (!adresse) { /* DER PFEIL IN DER KOPFLEISTE HAT ZWEI WEGE, und nur einer davon ist ein echtes Zurueck (21.09.2026, nachgemessen): history.back() -> Art "back_forward", alles gut location.assign(...) -> Art "navigate", Stelle verloren Welcher genommen wird, entscheidet `hatVorherigeSeite()` ueber `document.referrer`. Der fehlt schon, wenn jemand die Seite einmal neu geladen hat. Hier stand deshalb "der braucht nichts" -- und fuer den zweiten Weg stimmte das nie. Der Merker wird jetzt fuer BEIDE gesetzt. Er schadet dem ersten nicht: Kommt die Ankunft ohnehin als "back_forward" an, wird er einfach nicht gebraucht und verfaellt. */ try { var los = window.__startSeite || '/workspace/start.html'; zurueckMerken(new URL(los, location.href).pathname); } catch (f) { /* egal */ } return; } try { var u = new URL(adresse, location.href); zurueckMerken(u.pathname + u.search); } catch (f) { /* unbrauchbare Adresse -> dann eben ohne */ } }, true); /* ---- Und zurück ------------------------------------------------- */ function kamVonZurueck() { try { var e = performance.getEntriesByType('navigation')[0]; if (e && e.type) return e.type === 'back_forward'; /* Ältere Browser: die alte Zählweise (2 = TYPE_BACK_FORWARD). */ return performance.navigation && performance.navigation.type === 2; } catch (f) { return false; } } function wiederherstellen() { var roh; try { roh = sessionStorage.getItem(SCHLUESSEL); } catch (e) { return; } if (!roh) return; var stand; try { stand = JSON.parse(roh); } catch (e) { return; } if (!stand || typeof stand.y !== 'number') return; if (Date.now() - (stand.am || 0) > ALTER_MS) { try { sessionStorage.removeItem(SCHLUESSEL); } catch (f) { /* egal */ } return; } var bis = Date.now() + FRIST_MS; var abgebrochen = false; /* WER SELBST SCROLLT, GEWINNT. `wheel`, `touchstart` und die Tastatur -- alle drei, weil es alle drei Arten gibt, eine Seite zu bewegen. */ var aufhoeren = function () { abgebrochen = true; aufraeumen(); }; window.addEventListener('wheel', aufhoeren, { passive: true, once: true }); window.addEventListener('touchstart', aufhoeren, { passive: true, once: true }); window.addEventListener('keydown', aufhoeren, { once: true }); /* ==== DIE VERANKERUNG MUSS WEG, SOLANGE WIR ZIELEN (20.09.2026) === GEMESSEN: gemerkt 900, gelandet 1054 -- 154 Pixel daneben, und zwar zuverlaessig. Die Erklaerung ist Chromes Scroll-Verankerung: Waechst Inhalt OBERHALB der Stelle, an der man steht, verschiebt der Browser den Bildlauf mit, damit das Sichtbare stehen bleibt. Im Alltag ist das genau richtig -- beim Wiederherstellen ist es das Gegenteil: Wir setzen 900, danach laedt der Inhalt darueber nach, und die Verankerung rechnet unsere 900 in 1054 um. Sie wird deshalb fuer die Dauer des Wiederherstellens ausgeschaltet und danach wieder eingeschaltet. Nicht dauerhaft: Beim Nachladen im Chat oder in langen Listen ist sie das, was verhindert, dass einem der Text unter dem Finger wegspringt. */ var wurzel = document.documentElement; var vorherAnker = wurzel.style.overflowAnchor; wurzel.style.overflowAnchor = 'none'; var aufgeraeumt = false; function aufraeumen() { if (aufgeraeumt) return; aufgeraeumt = true; wurzel.style.overflowAnchor = vorherAnker || ''; } /* Wie hoch war das Dokument beim letzten Versuch? Solange sich das aendert, ist die Seite noch am Aufbauen -- und solange wird die Stelle nachgesetzt. Erst wenn sie eine Weile gleich bleibt, ist fertig. */ var letzteHoehe = -1; var ruhigSeit = 0; var RUHIG_MS = 400; function hoeheJetzt() { return Math.max( document.body ? document.body.scrollHeight : 0, wurzel ? wurzel.scrollHeight : 0); } (function versuchen() { if (abgebrochen) return; var hoch = hoeheJetzt(); if (hoch >= stand.y + window.innerHeight * 0.5) { /* Nur setzen, wenn wir nicht ohnehin schon dort stehen -- ein scrollTo je Bild waere sonst 60-mal in der Sekunde. */ if (Math.abs((window.scrollY || 0) - stand.y) > 1) { window.scrollTo(0, stand.y); } if (hoch === letzteHoehe) { if (!ruhigSeit) ruhigSeit = Date.now(); /* Lange genug ruhig -> die Seite steht. */ if (Date.now() - ruhigSeit >= RUHIG_MS) { aufraeumen(); return; } } else { letzteHoehe = hoch; ruhigSeit = 0; } } if (Date.now() > bis) { aufraeumen(); return; } /* Aufgeben ist auch eine Antwort. */ requestAnimationFrame(versuchen); })(); } if ('scrollRestoration' in history) { /* Wir machen es selbst -- sonst springt der Browser erst nach oben und wir gleich danach wieder hinunter, und das sieht man. */ history.scrollRestoration = 'manual'; } /* DER RUECKWAERTS-SPEICHER DES BROWSERS (21.09.2026). Holt der Browser eine Seite aus seinem Speicher zurueck, laeuft KEINE Zeile dieser Datei noch einmal -- und `manual` oben verbietet ihm gleichzeitig, die Stelle selbst wiederherzustellen. Ohne diese drei Zeilen waere die Seite dann oben, und zwar ohne dass irgendetwas kaputt ist. Nachgemessen: In Playwright greift der Speicher nicht (persisted bleibt false), im echten Browser sehr wohl. Der Fall ist hier also NICHT durch eine Messung belegt, sondern durch die Spezifikation -- und diese Zeile kostet nichts, wenn er nie eintritt. Das steht so da, damit niemand spaeter glaubt, es sei nachgewiesen. */ window.addEventListener('pageshow', function (e) { if (e && e.persisted) wiederherstellen(); }); /* ---- Wann wird wiederhergestellt? -------------------------------- BIS HEUTE: nur bei "back_forward" und beim eigenen Merker. Alles andere hat die gemerkte Stelle nicht nur ignoriert, sondern GELOESCHT. Ein einziges Neuladen reichte damit, und die Stelle war fuer immer weg -- das ist der Grund, warum Filipe es viermal gemeldet hat und ich es viermal fuer behoben hielt. JETZT: Wiederhergestellt wird bei jeder Ankunft, die eine RUECKKEHR sein kann -- und das sind mehr Faelle, als ich gedacht habe: back_forward der Browser-Zurueck. Klar. eigener Merker der Pfeil und die Brotkrume in der Kopfleiste. reload neu geladen (auch das Neuladen aus dem Rueckwaerts-Speicher). Wer eine Seite neu laedt, will dort bleiben, wo er ist -- so macht es jeder Browser von Haus aus. navigate MIT von einer anderen Seite dieses Hauses hergekommen. Herkunft Deckt den Fall ab, in dem der Pfeil `location.assign()` nimmt. NICHT wiederhergestellt wird bei einem echten Neuanfang: keine Herkunft, kein Merker -- also vom Startbildschirm, aus einer Benachrichtigung oder ueber die Adresszeile. Dort gehoert man nach oben. UND GELOESCHT WIRD NICHTS MEHR. Die Stelle verfaellt von selbst nach einer Stunde (ALTER_MS). Eine Ankunft, die sie nicht braucht, hat kein Recht, sie dem naechsten Zurueckgehen wegzunehmen. */ function kamVonAnderswo() { if (!document.referrer) return false; var u; try { u = new URL(document.referrer); } catch (e) { return false; } if (u.origin !== location.origin) return false; if (!u.pathname.startsWith('/workspace/')) return false; return u.pathname !== location.pathname; } function istNeuladen() { try { var e = performance.getEntriesByType('navigation')[0]; if (e && e.type) return e.type === 'reload'; return performance.navigation && performance.navigation.type === 1; } catch (f) { return false; } } if (kamVonZurueck() || ueberEigenesZurueck() || istNeuladen() || kamVonAnderswo()) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', wiederherstellen); } else { wiederherstellen(); } } /* DAMIT DAS NEULADEN AUS DEM RUECKWAERTS-SPEICHER DIE STELLE NICHT KOSTET -- aufgerufen weiter oben, kurz vor `location.reload()`. Zu diesem Zeitpunkt steht die Seite noch vollstaendig da und `window.scrollY` ist die richtige Stelle. */ window.stelleFesthalten = function () { merken(); try { zurueckMerken(location.pathname + location.search); } catch (e) { /* egal */ } }; })(); /* ===================================================================== AUF DIE AUSKUNFT WARTEN (22.09.2026) ===================================================================== `window.__ich` kommt ueber das Netz. Jede Seite, die etwas davon abhaengig machen will, steht beim ersten Aufruf vor `undefined` -- und `undefined` ist weder "darf" noch "darf nicht". Gemessen an zwei Stellen an einem Tag: Auf der Entwicklungsseite blieb das Verteil-Band unsichtbar, bis man eine Person anklickte; auf derselben Seite fragte die Kartenliste eine Route ab, die es fuer einen Modi nicht gibt, und der Browser protokollierte einen 404 bei jedem Aufruf. Beide hatten dieselbe Ursache und bekamen fast dieselbe Loesung -- ein eigenes `setInterval` je Datei. Zwei Fassungen desselben Wartens altern unterschiedlich. Deshalb hier EINE: const ich = await window.wennIchDaBin(); if (!ich) { ... } // auch nach drei Sekunden nichts KEIN FESTES `setTimeout`. Gewartet wird darauf, dass die Auskunft DA ist -- und laengstens drei Sekunden, damit eine Seite nicht ewig haengt, wenn die Anmeldung abgelaufen ist. Der dritte Ausgang: `null` heisst "wir konnten nicht nachsehen", nicht "darf nicht". ===================================================================== */ (function () { 'use strict'; var wartende = null; window.wennIchDaBin = function wennIchDaBin() { if (window.__ich !== undefined) return Promise.resolve(window.__ich); /* Mehrere Aufrufe teilen sich dieselbe Wartezeit -- sonst laufen drei Uhren nebeneinander und wecken dreimal. */ if (wartende) return wartende; wartende = new Promise(function (fertig) { var versuche = 0; var uhr = setInterval(function () { if (window.__ich !== undefined || ++versuche > 30) { clearInterval(uhr); wartende = null; fertig(window.__ich !== undefined ? window.__ich : null); } }, 100); }); return wartende; }; /* ===================================================================== WIE HOCH IST ALLES, WAS OBEN FESTKLEBT? --kopf-hoehe (22.09.2026) ===================================================================== STAND BIS HEUTE NUR IN chat.js. Dort wurde es gebraucht, dort wurde es gebaut. Als die Regelseite eine Leiste bekam, die ebenfalls unter der Kopfleiste kleben soll, stand die Variable dort nicht zur Verfuegung -- der Rueckfallwert (64 px) galt, und auf einem 390-px-Schirm ist die Kopfleiste rund 115 px hoch. Die Leiste verschwand hinter ihr. Nichts war kaputt, man sah sie nur nicht. Eine Zahl, die eine Seite misst und eine andere raet, ist schlimmer als gar keine: Auf der einen stimmt sie, auf der anderen nicht, und niemand sucht den Fehler bei einer Zahl. GEZAEHLT WIRD ALLES, WAS UEBER DEM INHALT LIEGT -- nicht nur die Kopfleiste. Das Band mit dem fremden Namen (.fremd-band, weiter oben in dieser Datei) kommt dazu, sobald jemand mit fremder Sicht unterwegs ist. Wer nur die Leiste misst, liegt genau um die Hoehe dieses Bands daneben. DREI ANLAESSE ZUM NACHMESSEN, und alle drei sind noetig: * Fenstergroesse geaendert * am Koerper kommt etwas dazu (das Band entsteht erst, wenn kopf.js weiss, wer angemeldet ist -- also NACH der ersten Messung) * die Leiste selbst aendert sich (Glocke erscheint, Name wird eingesetzt) ===================================================================== */ var UEBER_DEM_INHALT = ['.kopfleiste', '.fremd-band']; function kopfhoeheMessen() { var teile = UEBER_DEM_INHALT .map(function (w) { return document.querySelector(w); }) .filter(Boolean); if (!teile.length) return; var h = Math.ceil(teile.reduce(function (summe, e) { return summe + e.getBoundingClientRect().height; }, 0)); document.documentElement.style.setProperty('--kopf-hoehe', h + 'px'); } window.kopfhoeheMessen = kopfhoeheMessen; function kopfhoeheBeobachten() { kopfhoeheMessen(); window.addEventListener('resize', kopfhoeheMessen); if (window.MutationObserver) { new MutationObserver(kopfhoeheMessen).observe(document.body, { childList: true }); } if (window.ResizeObserver) { var leiste = document.querySelector('.kopfleiste'); if (leiste) new ResizeObserver(kopfhoeheMessen).observe(leiste); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', kopfhoeheBeobachten); } else { kopfhoeheBeobachten(); } }());