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);
if (kopfRechts) aufbauChat(kopfRechts);
if (kopfRechts) aufbauInstallieren(kopfRechts);
/* =====================================================================
DIE SEITE ALS APP INSTALLIEREN (20.09.2026)
Filipe: „kannst du da dazwischen in der starseite oben, ein button
reinsetzen damit die leute die seite installieren koennen auf dem
handy oder pc. das soll auch verschwinden wenn die leute es
installiert haben."
WARUM ES DEN KNOPF BRAUCHT: Das Manifest ist seit Langem
vollstaendig -- die App LIESS sich installieren. Nur bot das
ausschliesslich der Browser selbst an, versteckt in seinem
Dreipunktemenue. Wer es nicht sucht, findet es nie. Und ohne
installierte App gibt es auf dem Handy keine verlaesslichen
Benachrichtigungen; genau daran hing im September das Telefonieren.
DREI ZUSTAENDE, UND JEDER BRAUCHT EINE ANDERE ANTWORT:
1. SCHON INSTALLIERT -> gar kein Knopf. Ein Angebot fuer etwas,
das man hat, ist Rauschen. Erkannt an `display-mode:
standalone` (und an Safaris eigenem `navigator.standalone`).
2. DER BROWSER BIETET ES AN (Chrome, Edge, Android) -> der Knopf
fragt ihn. `beforeinstallprompt` kommt EINMAL und muss
aufgehoben werden; wer ihn nicht festhaelt, kann spaeter
nicht mehr fragen.
3. SAFARI AUF DEM iPHONE -> dort gibt es `beforeinstallprompt`
NICHT, und es wird ihn nicht geben. Apple laesst nur den Weg
ueber „Teilen -> Zum Home-Bildschirm". Ein Knopf, der dort
nichts tut, waere schlimmer als keiner -- also sagt er in
diesem Fall, was zu tun ist, statt es zu versuchen.
WARUM IN DER KOPFLEISTE UND NICHT NUR AUF DER STARTSEITE: Die
Leiste steht auf allen 32 Seiten. Wer den Knopf einmal uebersieht,
sieht ihn beim naechsten Mal wieder -- und er verschwindet ohnehin,
sobald er ueberfluessig ist.
===================================================================== */
function aufbauInstallieren(wohin) {
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* ---- Zustand 1: schon installiert -> nichts tun --------------- */
const schonDrin = () =>
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
|| window.navigator.standalone === true;
if (schonDrin()) return;
/* Apple: kein `beforeinstallprompt`, aber ein Weg. */
const istApple = /iphone|ipad|ipod/i.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
let angebot = null; // der aufgehobene Browser-Vorschlag
const knopf = el('button', 'install-knopf');
knopf.type = 'button';
knopf.hidden = true;
knopf.title = 'Diese Seite als App installieren';
/* DAS WORT VERSCHWINDET AM HANDY (siehe CSS) -- der Satz fuer
Vorleseprogramme nicht. Ein Knopf, der dort nur aus einem
Pfeil besteht, heisst sonst "Schaltflaeche". */
knopf.setAttribute('aria-label', 'Diese Seite als App installieren');
const zeichen = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
zeichen.setAttribute('viewBox', '0 0 24 24');
zeichen.setAttribute('class', 'install-knopf__zeichen');
zeichen.setAttribute('aria-hidden', 'true');
zeichen.innerHTML = '
';
knopf.append(zeichen, el('span', 'install-knopf__text', 'Installieren'));
knopf.addEventListener('click', async () => {
if (angebot) {
/* Zustand 2: den Browser fragen. Die Antwort interessiert uns
nur insofern, als ein angenommener Vorschlag den Knopf
ueberfluessig macht -- ein abgelehnter nicht: Vielleicht
ueberlegt man es sich morgen anders. */
knopf.disabled = true;
try {
angebot.prompt();
const { outcome } = await angebot.userChoice;
if (outcome === 'accepted') knopf.remove();
} catch { /* dann eben nicht */ }
angebot = null;
knopf.disabled = false;
return;
}
/* Zustand 3: erklaeren statt versuchen. */
window.frageNach?.({
titel: 'Als App auf dem Home-Bildschirm',
was: istApple
? 'Tippe unten in Safari auf das Teilen-Zeichen (Viereck mit Pfeil nach oben), '
+ 'dann auf „Zum Home-Bildschirm".'
: 'Öffne das Menü deines Browsers (die drei Punkte) und wähle dort '
+ '„App installieren" oder „Zum Startbildschirm hinzufügen".',
bleibt: 'Danach liegt die Seite wie eine App auf deinem Gerät – und '
+ 'Anrufe und Nachrichten kommen zuverlässiger an.',
ja: 'Alles klar',
nein: null,
});
});
wohin.prepend(knopf);
/* Der Browser meldet sich, wenn er es anbieten kann. */
window.addEventListener('beforeinstallprompt', (e) => {
/* Ohne `preventDefault` zeigt der Browser seine eigene Leiste --
zwei Angebote fuer dieselbe Sache sind eines zu viel. */
e.preventDefault();
angebot = e;
knopf.hidden = false;
});
/* Auf Apple gibt es diese Meldung nie -- dort steht der Knopf von
Anfang an da und erklaert den Weg. */
if (istApple) knopf.hidden = false;
/* Und wenn es geklappt hat, ist er fertig. */
window.addEventListener('appinstalled', () => knopf.remove());
/* Manche Browser melden `appinstalled` nicht -- dann faellt der
Knopf beim naechsten Oeffnen ueber `schonDrin()` weg. Und wer die
App waehrend der Sitzung installiert und zurueckwechselt, merkt
es hier: */
try {
window.matchMedia('(display-mode: standalone)')
.addEventListener('change', (m) => { if (m.matches) knopf.remove(); });
} catch { /* aeltere Browser koennen das nicht -- kein Grund zu scheitern */ }
}
/* ---------- 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');
};
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;
};
window.addEventListener('pagehide', stromZu);
window.addEventListener('pageshow', (e) => {
if (!e.persisted) return; /* gewoehnliches Laden: schon auf */
stromAuf();
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 */ }
};
})();