/* ===================================================================
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