diff --git a/assets/css/webdesign.css b/assets/css/webdesign.css
index b66453a7..1692adb3 100644
--- a/assets/css/webdesign.css
+++ b/assets/css/webdesign.css
@@ -1390,3 +1390,155 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.po-legende .po-l-2 { background: var(--wd-p2); }
.po-legende .po-l-3 { background: var(--wd-p3); }
.po-legende .po-l-4 { background: var(--wd-p4); }
+
+/* =====================================================================
+ PORTAL — AUFGABENLISTE UND POSTFACH (22.08.2026)
+
+ "wenn sie drauf drücken sollen sie auch sehen was ich schon gemacht
+ habe von dem was im plan war."
+
+ Dieselben vier Prozessfarben wie auf der Ablaufseite, in der Vorschau
+ und in der Verwaltung. Das ist der eigentliche Wert: Eine Farbe
+ bedeutet im ganzen Bereich dasselbe — sonst wäre sie Dekoration.
+ ===================================================================== */
+
+/* Die grosse Zahl. "9 von 14 erledigt" ist greifbar, "64 %" ist eine
+ Rechnung, die niemand fühlt. */
+.po-aufg-stand { margin: .9rem 0 .2rem; font-size: .92rem; color: var(--wd-text-soft); }
+.po-aufg-stand b {
+ font-family: var(--font-head); font-weight: 800;
+ font-size: 1.7rem; color: var(--wd-text); margin-right: .3rem;
+}
+
+.po-fortschritt { display: flex; gap: 4px; height: 8px; margin-bottom: 1.2rem; }
+.po-fs-teil { flex: 1; border-radius: 999px; background: rgba(255,255,255,.07); position: relative; overflow: hidden; }
+.po-fs-teil > i { position: absolute; inset: 0 auto 0 0; border-radius: 999px; }
+.po-fs-teil[data-k="start"] > i { background: var(--wd-p1); }
+.po-fs-teil[data-k="gestaltung"] > i { background: var(--wd-p2); }
+.po-fs-teil[data-k="umsetzung"] > i { background: var(--wd-p3); }
+.po-fs-teil[data-k="abschluss"] > i { background: var(--wd-p4); }
+
+/* ---------- "Das brauche ich noch von dir" ----------
+ Ein EIGENER Kasten ganz oben, nicht nur eine farbige Zeile irgendwo
+ in der Liste. Wer eine lange Liste sieht, liest sie als Bericht über
+ fremde Arbeit und überliest seinen eigenen Teil — und genau daraus
+ entstehen die meisten Verzögerungen.
+
+ Gold, weil es im ganzen Bereich für "das betrifft dich" steht. Nicht
+ Rot: Es ist eine Bitte, kein Fehler. */
+.po-dubist {
+ margin: 0 0 1.3rem;
+ padding: .9rem 1rem 1rem;
+ border-radius: 14px;
+ background: rgba(232,200,119,.07);
+ border: 1px solid rgba(232,200,119,.32);
+}
+.po-dubist h4 {
+ margin: 0; font-size: 1rem; color: var(--wd-gold);
+ font-family: var(--font-head); font-weight: 700;
+}
+
+/* Der beruhigende Gegenpart. Wenn nichts offen ist, soll das auch
+ dastehen — Abwesenheit einer Aufforderung ist eine gute Nachricht und
+ darf ruhig ausgesprochen werden. */
+.po-allesgut {
+ margin: 0 0 1.3rem; padding: .75rem .95rem;
+ border-radius: 12px; font-size: .9rem;
+ background: rgba(111,214,163,.08);
+ border: 1px solid rgba(111,214,163,.28);
+ color: var(--wd-ok);
+}
+
+/* ---------- Gruppen und Zeilen ---------- */
+.po-aufg-gruppe { margin-bottom: 1.1rem; }
+.po-aufg-kopf {
+ display: flex; align-items: baseline; gap: .5rem;
+ font-size: .74rem; letter-spacing: .13em; text-transform: uppercase;
+ margin-bottom: .45rem; padding-bottom: .35rem;
+ border-bottom: 1px solid var(--wd-rand);
+}
+.po-aufg-zahl { margin-left: auto; letter-spacing: 0; text-transform: none; font-size: .8rem; color: var(--wd-text-soft); }
+.po-aufg-gruppe[data-k="start"] .po-aufg-kopf { color: var(--wd-p1); }
+.po-aufg-gruppe[data-k="gestaltung"] .po-aufg-kopf { color: var(--wd-p2); }
+.po-aufg-gruppe[data-k="umsetzung"] .po-aufg-kopf { color: var(--wd-p3); }
+.po-aufg-gruppe[data-k="abschluss"] .po-aufg-kopf { color: var(--wd-p4); }
+
+/* Zeile und Spalte ausdrücklich für JEDES Kind.
+
+ Ohne Spaltenangabe geht es schief, und zwar auf eine Art, die man
+ nicht errät: Das Raster platziert zuerst alle Elemente mit
+ FESTGELEGTER Zeile und erst danach die freien. Kästchen und Marke
+ haben beide eine feste Zeile — sie würden zusammen nach vorne rutschen
+ und der Titel landete ganz rechts. Genau das ist am 22.08.2026 in der
+ Verwaltung passiert und erst am Screenshot aufgefallen. */
+.po-aufg {
+ display: grid;
+ grid-template-columns: auto minmax(0,1fr) auto;
+ gap: .12rem .7rem; align-items: start;
+ padding: .55rem .6rem; margin-bottom: .25rem;
+ border-radius: 10px; border: 1px solid transparent;
+}
+.po-aufg-haken { grid-row: 1 / span 2; grid-column: 1; }
+.po-aufg-titel { grid-row: 1; grid-column: 2; font-size: .94rem; color: var(--wd-text); overflow-wrap: anywhere; }
+.po-aufg-marken { grid-row: 1; grid-column: 3; }
+.po-aufg-text { grid-row: 2; grid-column: 2 / span 2; font-size: .82rem; color: var(--wd-text-soft); line-height: 1.5; }
+
+.po-aufg--fertig .po-aufg-titel { color: var(--wd-text-soft); text-decoration: line-through; text-decoration-thickness: 1px; }
+.po-aufg--weg .po-aufg-titel { color: var(--wd-text-soft); text-decoration: line-through; opacity: .55; }
+.po-aufg--du { background: rgba(232,200,119,.06); }
+
+/* Im oberen Kasten stehen dieselben Punkte noch einmal. Dort brauchen
+ sie den eigenen Hintergrund nicht — der Kasten ist schon golden, ein
+ zweiter Goldton darin würde nur schmutzig wirken. */
+.po-aufg--hervor { background: transparent; padding-left: 0; padding-right: 0; }
+
+/* Das Kästchen. Bei eigenen Punkten ein echter Knopf (tastaturbedienbar,
+ 44px Trefferfläche über das Innenabstands-Polster), sonst nur Anzeige.
+ Ein Knopf, der nichts tut, lässt die Seite kaputt wirken. */
+.po-aufg-haken {
+ width: 26px; height: 26px; margin-top: .05rem; padding: 0;
+ border-radius: 8px; border: 2px solid var(--wd-rand-stark);
+ background: transparent; color: transparent;
+ display: grid; place-items: center;
+ font-size: .95rem; line-height: 1;
+ transition: all var(--wd-zeit-schnell) ease;
+}
+button.po-aufg-haken { cursor: pointer; }
+button.po-aufg-haken:hover { border-color: var(--wd-gold); background: rgba(232,200,119,.1); }
+.po-aufg-haken--fest { cursor: default; }
+.po-aufg--fertig .po-aufg-haken { background: var(--wd-ok); border-color: var(--wd-ok); color: #06120c; }
+.po-aufg--jetzt .po-aufg-haken { border-color: var(--wd-p2); color: var(--wd-p2); }
+.po-aufg--weg .po-aufg-haken { border-style: dashed; color: var(--wd-text-soft); }
+/* Offene EIGENE Punkte: der Rand ist golden, damit klar ist, dass hier
+ geklickt werden kann und soll. */
+.po-aufg--du button.po-aufg-haken { border-color: rgba(232,200,119,.55); }
+
+.po-aufg-marke {
+ display: inline-block; white-space: nowrap;
+ font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
+ padding: .14rem .45rem; border-radius: 999px;
+}
+.po-aufg-marke--ok { background: rgba(111,214,163,.13); color: var(--wd-ok); }
+.po-aufg-marke--jetzt { background: rgba(183,157,255,.15); color: var(--wd-p2); }
+.po-aufg-marke--du { background: rgba(232,200,119,.16); color: var(--wd-gold); }
+.po-aufg-nicht {
+ display: inline-block; margin-left: .4rem;
+ font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
+ padding: .12rem .4rem; border-radius: 999px;
+ background: rgba(255,157,157,.12); color: var(--wd-fehler);
+}
+
+/* Auf dem Handy rutscht die Marke unter den Titel — nebeneinander bliebe
+ für den Titel eine Spalte von zwei Wörtern Breite. */
+@media (max-width: 520px) {
+ .po-aufg { grid-template-columns: auto minmax(0,1fr); }
+ .po-aufg-marken { grid-row: 2; grid-column: 2; margin-bottom: .15rem; }
+ .po-aufg-text { grid-row: 3; grid-column: 2; }
+}
+
+/* ---------- Postfach auf der Übersicht ---------- */
+.po-pf-verlauf {
+ max-height: 46vh; overflow-y: auto;
+ margin: .8rem 0 .2rem; padding-right: .3rem;
+ display: flex; flex-direction: column; gap: .5rem;
+}
diff --git a/assets/js/i18n-wd-portal.js b/assets/js/i18n-wd-portal.js
index 0c41dd93..9b2ef314 100644
--- a/assets/js/i18n-wd-portal.js
+++ b/assets/js/i18n-wd-portal.js
@@ -205,7 +205,13 @@ window.I18N_WD = {
po_senden: { de: "Senden", "de-CH": "Schicke", en: "Send", fr: "Envoyer", pt: "Enviar" },
/* ---------- Änderungswünsche ---------- */
- po_ideen: { de: "Ideen & Änderungswünsche", "de-CH": "Ideä & Änderigswünsch", en: "Ideas & change requests", fr: "Idées & demandes de modification", pt: "Ideias & pedidos de alteração" },
+ /* Hier steht ein ECHTES "&", kein "&" -- anders als in den meisten
+ anderen i18n-Dateien. Der Grund: Texte, die über data-i18n gesetzt
+ werden, landen als HTML in der Seite, dort ist "&" richtig.
+ Dieser Text wird aber im JavaScript durch die Absicherung geschickt
+ (s(...)), und die kodiert das "&" ein zweites Mal -- auf dem
+ Bildschirm stand "Ideen & Änderungswünsche". */
+ po_ideen: { de: "Ideen & Änderungswünsche", "de-CH": "Ideä & Änderigswünsch", en: "Ideas & change requests", fr: "Idées & demandes de modification", pt: "Ideias & pedidos de alteração" },
po_ideen_text: {
de: "Trag hier ein, was dir noch einfällt. Ich prüfe Aufwand und Preis und melde mich — du entscheidest danach in Ruhe.",
"de-CH": "Trag do ii, was dir no iifallt. Ich prüefe Ufwand und Priis und mälde mich — du entscheidsch dernoch i Rueh.",
@@ -268,4 +274,183 @@ window.I18N_WD = {
fr: "Ta session a expiré. Merci de te reconnecter.",
pt: "A tua sessão expirou. Inicia sessão novamente.",
},
+
+ /* ---------- Aufgabenliste (22.08.2026) ----------
+ "wenn sie drauf drücken sollen sie auch sehen was ich schon gemacht
+ habe von dem was im plan war."
+
+ Der Ton ist hier besonders wichtig. Der Kunde liest diese Liste,
+ wenn er unsicher ist, wie weit es steht — also im Zweifel schon
+ leicht angespannt. Sie muss beruhigen und klar sagen, was von IHM
+ erwartet wird, ohne zu drängen. "Wir warten auf dich" wäre Druck;
+ "Das brauche ich noch von dir" ist eine Bitte. */
+ po_aufgaben: {
+ de: "Was schon erledigt ist",
+ "de-CH": "Was scho erledigt isch",
+ en: "What's already done",
+ fr: "Ce qui est déjà fait",
+ pt: "O que já está feito",
+ },
+ po_aufg_text: {
+ de: "Jeder Punkt aus deinem Plan — abgehakt, sobald er fertig ist. Du siehst jederzeit, woran gerade gearbeitet wird und was noch von dir gebraucht wird.",
+ "de-CH": "Jede Punkt us dim Plan — abghakt, sobald er fertig isch. Du gsehsch jederziit, wo grad dra gschaffet wird und was no vo dir brucht wird.",
+ en: "Every point from your plan — ticked off as soon as it's finished. You can always see what's being worked on and what's still needed from you.",
+ fr: "Chaque point de ton plan — coché dès qu'il est terminé. Tu vois à tout moment ce sur quoi je travaille et ce qu'il me faut encore de ta part.",
+ pt: "Cada ponto do teu plano — assinalado assim que estiver pronto. Vês sempre no que se está a trabalhar e o que ainda é preciso da tua parte.",
+ },
+ po_aufg_leer: {
+ de: "Die Punkte erscheinen hier, sobald dein Plan steht.",
+ "de-CH": "D Pünkt erschiined do, sobald din Plan stoht.",
+ en: "The points appear here as soon as your plan is set.",
+ fr: "Les points apparaîtront ici dès que ton plan sera établi.",
+ pt: "Os pontos aparecem aqui assim que o teu plano estiver definido.",
+ },
+ po_aufg_fertig: {
+ de: "von {gesamt} erledigt",
+ "de-CH": "vo {gesamt} erledigt",
+ en: "of {gesamt} done",
+ fr: "sur {gesamt} terminés",
+ pt: "de {gesamt} concluídos",
+ },
+ po_aufg_dubist: {
+ de: "Das brauche ich noch von dir",
+ "de-CH": "Das bruuch ich no vo dir",
+ en: "This is what I still need from you",
+ fr: "Voici ce qu'il me faut encore de ta part",
+ pt: "Isto é o que ainda preciso da tua parte",
+ },
+ po_aufg_dubist_eins: {
+ de: "Ein Punkt wartet auf dich",
+ "de-CH": "Ei Punkt wartet uf dich",
+ en: "One point is waiting for you",
+ fr: "Un point t'attend",
+ pt: "Um ponto está à tua espera",
+ },
+ po_aufg_dubist_viele: {
+ de: "{n} Punkte warten auf dich",
+ "de-CH": "{n} Pünkt warted uf dich",
+ en: "{n} points are waiting for you",
+ fr: "{n} points t'attendent",
+ pt: "{n} pontos estão à tua espera",
+ },
+ po_aufg_allesbei: {
+ de: "Von dir wird gerade nichts gebraucht — lehn dich zurück.",
+ "de-CH": "Vo dir wird grad nüt brucht — lehn di zrugg.",
+ en: "Nothing is needed from you right now — sit back.",
+ fr: "Rien n'est attendu de toi pour l'instant — tu peux te détendre.",
+ pt: "Neste momento não é preciso nada da tua parte — descontrai.",
+ },
+ po_aufg_erledigt_marke: {
+ de: "erledigt",
+ "de-CH": "erledigt",
+ en: "done",
+ fr: "terminé",
+ pt: "concluído",
+ },
+ po_aufg_arbeit_marke: {
+ de: "läuft gerade",
+ "de-CH": "lauft grad",
+ en: "in progress",
+ fr: "en cours",
+ pt: "em curso",
+ },
+ po_aufg_du_marke: {
+ de: "bei dir",
+ "de-CH": "bi dir",
+ en: "your turn",
+ fr: "à toi",
+ pt: "contigo",
+ },
+ po_aufg_nicht: {
+ de: "nicht im Paket",
+ "de-CH": "nöd im Paket",
+ en: "not included",
+ fr: "hors forfait",
+ pt: "não incluído",
+ },
+ po_aufg_hake: {
+ de: "Erledigt — als erledigt melden",
+ "de-CH": "Erledigt — als erledigt mälde",
+ en: "Done — mark as done",
+ fr: "Fait — marquer comme terminé",
+ pt: "Feito — marcar como concluído",
+ },
+ po_aufg_zurueck: {
+ de: "Doch noch offen",
+ "de-CH": "Doch no offe",
+ en: "Still open after all",
+ fr: "Finalement pas terminé",
+ pt: "Afinal ainda em aberto",
+ },
+
+ /* ---------- Allgemeines Postfach (22.08.2026) ----------
+ "auf dieser seite sollen die kunden mir auch nachrichten hinterlassen
+ können, für noch kleinigkeiten oder sachen die ich brauch."
+
+ Bewusst niedrigschwellig formuliert. Wer glaubt, eine Nachricht
+ müsse eine "richtige" Anfrage sein, schreibt gar nicht erst — und
+ genau die kurzen Fragen sollen ja hier landen. */
+ po_postfach: {
+ de: "Kurz etwas fragen",
+ "de-CH": "Churz öppis frooge",
+ en: "Ask something quickly",
+ fr: "Une question rapide",
+ pt: "Perguntar algo rápido",
+ },
+ po_postfach_text: {
+ de: "Für alles Kleine zwischendurch — eine Frage, ein Hinweis, etwas Vergessenes. Auch wenn es zu keinem bestimmten Projekt gehört. Kein Anlass ist zu klein.",
+ "de-CH": "Für alles Chliine zwüschedure — e Frog, en Hiiwiis, öppis Vergässnigs. Au wenns zu kem bstimmte Projekt ghört. Kein Aalass isch z chlii.",
+ en: "For all the small things in between — a question, a note, something forgotten. Even if it doesn't belong to a particular project. No reason is too small.",
+ fr: "Pour tout ce qui est petit entre-temps — une question, une remarque, un oubli. Même si cela ne concerne aucun projet précis. Aucune raison n'est trop petite.",
+ pt: "Para todas as pequenas coisas pelo meio — uma pergunta, um aviso, algo esquecido. Mesmo que não pertença a um projeto específico. Nenhum motivo é pequeno demais.",
+ },
+ po_postfach_leer: {
+ de: "Noch nichts geschrieben. Schreib einfach los.",
+ "de-CH": "No nüt gschriebe. Schriib eifach los.",
+ en: "Nothing written yet. Just go ahead.",
+ fr: "Rien d'écrit pour l'instant. Lance-toi.",
+ pt: "Ainda nada escrito. Escreve à vontade.",
+ },
+ po_postfach_platzhalter: {
+ de: "Was möchtest du fragen oder mitteilen?",
+ "de-CH": "Was wotsch frooge oder mitteile?",
+ en: "What would you like to ask or tell me?",
+ fr: "Que veux-tu demander ou signaler ?",
+ pt: "O que queres perguntar ou dizer?",
+ },
+ po_postfach_bezug: {
+ de: "Worum geht es? (freiwillig)",
+ "de-CH": "Um was gohts? (freiwillig)",
+ en: "What is it about? (optional)",
+ fr: "De quoi s'agit-il ? (facultatif)",
+ pt: "Sobre o que é? (opcional)",
+ },
+ po_postfach_kein_bezug: {
+ de: "— nichts Bestimmtes —",
+ "de-CH": "— nüt Bstimmts —",
+ en: "— nothing in particular —",
+ fr: "— rien de particulier —",
+ pt: "— nada em particular —",
+ },
+ po_postfach_leer_fehler: {
+ de: "Bitte schreib zuerst etwas.",
+ "de-CH": "Bitte schriib zerscht öppis.",
+ en: "Please write something first.",
+ fr: "Merci d'écrire quelque chose d'abord.",
+ pt: "Escreve algo primeiro.",
+ },
+ po_postfach_gesendet: {
+ de: "Ist angekommen. Ich melde mich.",
+ "de-CH": "Isch aacho. Ich mälde mich.",
+ en: "Received. I'll get back to you.",
+ fr: "Bien reçu. Je reviens vers toi.",
+ pt: "Recebido. Volto a contactar-te.",
+ },
+ po_von_dogfather: {
+ de: "Dogfather",
+ "de-CH": "Dogfather",
+ en: "Dogfather",
+ fr: "Dogfather",
+ pt: "Dogfather",
+ },
};
diff --git a/assets/js/wd-portal.js b/assets/js/wd-portal.js
index 499fe259..b91f441e 100644
--- a/assets/js/wd-portal.js
+++ b/assets/js/wd-portal.js
@@ -144,6 +144,133 @@
s(window.WD.t(wartet === "kunde" ? "po_wartet_kunde" : "po_wartet_dogfather")) + "";
}
+ /* ---------------- Aufgabenliste (22.08.2026) ----------------
+ "wenn sie drauf drücken sollen sie auch sehen was ich schon gemacht
+ habe von dem was im plan war."
+
+ Bis hierher sah der Kunde nur die Phase ("Design · 3/7") — eine Zahl
+ ohne Inhalt. Sie beantwortet die eigentliche Frage nicht: WAS ist
+ denn schon fertig?
+
+ Zwei Entscheidungen prägen diese Ansicht:
+
+ 1. Punkte, die auf den KUNDEN warten, stehen ganz oben in einem
+ eigenen Kasten — nicht nur farblich markiert irgendwo mittendrin.
+ Wer eine lange Liste sieht, liest sie als Bericht über MEINE
+ Arbeit und übersieht seinen eigenen Teil. Genau daraus entstehen
+ die meisten Verzögerungen.
+
+ 2. Der Kunde kann seine eigenen Punkte selbst abhaken. Das ist keine
+ Spielerei: Es gibt ihm einen sichtbaren Anteil am Fortschritt,
+ und ich sehe sofort, dass die Texte da sind, ohne dass er extra
+ schreiben muss.
+ ---------------------------------------------------------------- */
+ var KATS = ["start", "gestaltung", "umsetzung", "abschluss"];
+ var KAT_KEY = { start: "po_k1", gestaltung: "po_k2", umsetzung: "po_k3", abschluss: "po_k4" };
+
+ function aufgabeZeile(a, hervor) {
+ var klasse = "po-aufg";
+ if (a.status === "erledigt") klasse += " po-aufg--fertig";
+ else if (a.status === "in_arbeit") klasse += " po-aufg--jetzt";
+ else if (a.status === "entfaellt") klasse += " po-aufg--weg";
+ var duBistDran = a.wer_dran === "kunde" && a.status !== "erledigt" && a.status !== "entfaellt";
+ if (duBistDran) klasse += " po-aufg--du";
+ if (hervor) klasse += " po-aufg--hervor";
+
+ /* Nur EIGENE offene Punkte bekommen einen Knopf. Bei allen anderen
+ ist das Zeichen reine Anzeige — sonst klickt der Kunde darauf,
+ nichts passiert, und er hält die Seite für kaputt. */
+ var eigen = a.wer_dran === "kunde" && !a.nicht_enthalten && a.status !== "entfaellt";
+ var zeichen = a.status === "erledigt" ? "✓" : a.status === "in_arbeit" ? "•" : a.status === "entfaellt" ? "–" : "";
+
+ var kaestchen = eigen
+ ? '"
+ : '' + zeichen + "";
+
+ var marke = a.status === "erledigt"
+ ? '' + s(window.WD.t("po_aufg_erledigt_marke")) + ""
+ : a.status === "in_arbeit"
+ ? '' + s(window.WD.t("po_aufg_arbeit_marke")) + ""
+ : duBistDran
+ ? '' + s(window.WD.t("po_aufg_du_marke")) + ""
+ : "";
+
+ return '
";
+ }).join("");
+
+ return html + "";
+ }
+
async function ladeUebersicht() {
zeige("po-uebersicht");
var ziel = document.getElementById("po-inhalt");
@@ -261,10 +388,23 @@
}).join("") + "";
}
+ /* Der allgemeine Nachrichtenbereich steht auf der ÜBERSICHT, nicht
+ im Projekt. Genau das war der Punkt: Wer kein Projekt hat — oder
+ eine Frage, die zu keinem gehört — konnte vorher gar nicht
+ schreiben und musste zur E-Mail greifen. Damit war der Verlauf
+ weg, sobald man ihn brauchte.
+
+ Er wird nachgeladen, nicht mitgeliefert: Die Übersicht soll
+ sofort dastehen. Ein paar Zehntelsekunden später erscheinen die
+ Nachrichten — das ist besser, als die ganze Seite auf sie warten
+ zu lassen. */
+ html += '';
+
ziel.innerHTML = html;
ziel.querySelectorAll("[data-projekt]").forEach(function (b) {
b.addEventListener("click", function () { ladeProjekt(b.dataset.projekt); });
});
+ ladePostfach();
} catch (fehler) {
if (behandleFehler(fehler)) return;
ziel.innerHTML = "";
@@ -273,6 +413,100 @@
}
/* ---------------- Projektdetail ---------------- */
+ /* ---------------- Allgemeines Postfach ----------------
+ "auf dieser seite sollen die kunden mir auch nachrichten hinterlassen
+ können, für noch kleinigkeiten oder sachen die ich brauch."
+
+ Bewusst niedrigschwellig: kein Betreff, kein Pflichtfeld, der Bezug
+ zu einem Projekt ist freiwillig. Wer glaubt, eine Nachricht müsse
+ eine "richtige" Anfrage sein, schreibt gar nicht erst — und genau
+ die kurzen Fragen sollen hier landen.
+ ---------------------------------------------------------------- */
+ async function ladePostfach() {
+ var kasten = document.getElementById("po-postfach");
+ if (!kasten) return;
+
+ var d;
+ try {
+ d = await api("/webdesign/portal/postfach");
+ } catch (fehler) {
+ /* Still bleiben. Der Bereich ist eine Ergänzung — eine rote
+ Fehlermeldung über der Projektübersicht wäre alarmierender, als
+ der Anlass es hergibt. */
+ return;
+ }
+
+ var html = "
";
try {
- var d = await api("/webdesign/portal/projekt/" + encodeURIComponent(id));
+ /* Beides gleichzeitig anfordern statt nacheinander. Nacheinander
+ waere die Wartezeit die Summe beider Anfragen — bei einer
+ Mobilfunkverbindung deutlich spuerbar.
+
+ Die Aufgabenliste darf dabei fehlschlagen, ohne die ganze Seite
+ mitzureissen: Sie ist eine Ergaenzung, keine Voraussetzung. Wer
+ wegen einer leeren Liste seine Zahlungen und Dateien nicht mehr
+ saehe, waere schlechter dran als vorher. */
+ var beides = await Promise.all([
+ api("/webdesign/portal/projekt/" + encodeURIComponent(id)),
+ api("/webdesign/portal/projekt/" + encodeURIComponent(id) + "/aufgaben")
+ .catch(function () { return { aufgaben: [], fortschritt: null }; }),
+ ]);
+ var d = beides[0];
+ var aufg = beides[1];
var p = d.projekt;
var html = "";
@@ -302,6 +550,12 @@
: "") +
"";
+ /* Die Aufgabenliste steht GANZ OBEN, direkt nach dem Projektkopf.
+ Sie beantwortet die Frage, mit der man das Projekt oeffnet
+ ("wie weit ist es?"). Zahlungen und Dateien sind wichtig, aber
+ sie sind nicht der Grund fuer den Klick. */
+ html += aufgabenKarte(aufg);
+
/* --- Zahlungen --- */
html += '
' + s(window.WD.t("po_zahlungen")) + "
";
if (!d.zahlungen.length) {
@@ -420,6 +674,26 @@
});
});
+ /* Eigene Punkte abhaken. Der Punkt erscheint in der Liste zweimal
+ (oben unter "Das brauche ich noch von dir" und unten im
+ Abschnitt), deshalb wird nach dem Umschalten die ganze Ansicht
+ neu aufgebaut — sonst stuenden beide Fassungen widerspruechlich
+ nebeneinander. */
+ ziel.querySelectorAll("[data-haken]").forEach(function (b) {
+ b.addEventListener("click", async function () {
+ window.WD.knopfSperren(b, true, "…");
+ try {
+ await api("/webdesign/portal/aufgabe/" + encodeURIComponent(b.dataset.haken) + "/erledigt",
+ { method: "POST", body: {} });
+ ladeProjekt(id);
+ } catch (f) {
+ if (behandleFehler(f)) return;
+ window.WD.knopfSperren(b, false);
+ window.WD.meldung(meldung, f.message, "fehler");
+ }
+ });
+ });
+
ziel.querySelector("#po-idee-senden").addEventListener("click", async function (e) {
var titel = ziel.querySelector("#po-idee-titel").value.trim();
var beschreibung = ziel.querySelector("#po-idee-text").value.trim();
diff --git a/pruef-portal.mjs b/pruef-portal.mjs
new file mode 100644
index 00000000..2acca249
--- /dev/null
+++ b/pruef-portal.mjs
@@ -0,0 +1,224 @@
+/* Prüft die Aufgabenliste und das Postfach im Kundenportal.
+
+ Wichtig sind hier drei Dinge, die man leicht falsch baut:
+
+ 1. Der Kunde darf NUR seine eigenen Punkte anklicken. Ein Knopf, der
+ nichts tut, lässt die Seite kaputt wirken.
+ 2. Offene Kundenpunkte müssen VOR der langen Liste stehen — sonst
+ liest man die Liste als Bericht und überliest seinen Teil.
+ 3. Alle fünf Sprachen müssen vollständig sein. Ein fehlender
+ Schlüssel fällt sonst erst einem französischen Kunden auf.
+*/
+import { chromium } from "playwright";
+import { createServer } from "node:http";
+import { readFileSync, existsSync } from "node:fs";
+import { join, extname } from "node:path";
+
+const W = process.cwd();
+const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
+ ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
+ ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
+const srv = createServer((q, r) => {
+ const p = join(W, decodeURIComponent(q.url.split("?")[0]));
+ if (existsSync(p) && !p.endsWith("/")) {
+ r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
+ return r.end(readFileSync(p));
+ }
+ r.writeHead(404).end("");
+});
+await new Promise((r) => srv.listen(4326, r));
+
+const AUFGABEN = [
+ { id: "a1", titel: "Briefing-Gespräch führen", beschreibung: "Ziel und Umfang klären.",
+ kategorie: "start", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
+ { id: "a2", titel: "Texte und Bilder zusammenstellen", beschreibung: "Lieber früh als vollständig.",
+ kategorie: "start", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null },
+ { id: "a3", titel: "Entwurf der Startseite", beschreibung: null,
+ kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
+ { id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null,
+ kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null },
+ { id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null,
+ kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
+ { id: "a6", titel: "Mehrsprachigkeit", beschreibung: null,
+ kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1,
+ hinweis: "Auf Wunsch als Zusatz." },
+ { id: "a7", titel: "Seite live schalten", beschreibung: null,
+ kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
+];
+
+const FORTSCHRITT = { gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33,
+ jeKategorie: { start: { gesamt: 2, erledigt: 1 }, gestaltung: { gesamt: 2, erledigt: 1 },
+ umsetzung: { gesamt: 1, erledigt: 0 }, abschluss: { gesamt: 1, erledigt: 0 } } };
+
+const PROJEKT = { id: "p1", nummer: "P-0001", titel: "Website für Musterbau",
+ status: "design", wartet_auf: "kunde", naechster_schritt: "Deine Rückmeldung zum Entwurf." };
+
+const ergebnisse = [];
+function pruefe(name, ok, zusatz) {
+ ergebnisse.push({ name, ok });
+ console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : ""));
+}
+
+const browser = await chromium.launch();
+
+/* ---- 1. Vollständigkeit aller fünf Sprachen ---- */
+{
+ const p = await browser.newPage();
+ await p.goto("http://127.0.0.1:4326/webdesign/portal.html");
+ await p.waitForTimeout(600);
+ const luecken = await p.evaluate(() => {
+ const w = window.I18N_WD || {};
+ const sprachen = ["de", "de-CH", "en", "fr", "pt"];
+ const fehlt = [];
+ for (const [k, v] of Object.entries(w)) {
+ for (const sp of sprachen) {
+ if (!v[sp] || !String(v[sp]).trim()) fehlt.push(k + "/" + sp);
+ }
+ }
+ return { fehlt, anzahl: Object.keys(w).length };
+ });
+ console.log("\n=== Sprachen ===");
+ pruefe("alle Texte in allen fünf Sprachen vorhanden", luecken.fehlt.length === 0,
+ luecken.fehlt.slice(0, 6).join(", "));
+ console.log(" (" + luecken.anzahl + " Texte geprüft)");
+ await p.close();
+}
+
+for (const [wo, breite, hoehe] of [["Computer", 1280, 950], ["Handy", 390, 844]]) {
+ const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } });
+ const fehlerImProtokoll = [];
+ seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message));
+
+ let abgehakt = null;
+ // Nur die API abfangen, nicht die Seite selbst.
+ await seite.route("**/webdesign/portal/**", (route) => {
+ /* Auf den PFAD prüfen, nicht auf die ganze Adresse.
+
+ Der API-Server heisst "postfach.dogfather-universe.com" -- ein
+ url.includes("/postfach") trifft also den HOSTNAMEN und damit
+ JEDE Anfrage. Beim ersten Lauf bekam deshalb auch die Übersicht
+ die Postfach-Antwort, und der Fehler sah aus wie ein Fehler im
+ Portal ("Cannot read properties of undefined"). Er lag im Test. */
+ const u = new URL(route.request().url()).pathname;
+ if (u.includes("/projekt/p1/aufgaben")) {
+ return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } });
+ }
+ if (u.includes("/aufgabe/") && u.includes("/erledigt")) {
+ abgehakt = u.split("/aufgabe/")[1].split("/")[0];
+ return route.fulfill({ json: { ok: true, status: "erledigt", fortschritt: FORTSCHRITT } });
+ }
+ if (u.includes("/projekt/p1")) {
+ return route.fulfill({ json: { ok: true, projekt: PROJEKT,
+ zahlungen: [], dateien: [], aenderungen: [], nachrichten: [] } });
+ }
+ if (u.includes("/postfach")) {
+ if (route.request().method() === "POST") return route.fulfill({ json: { ok: true, id: "x" } });
+ return route.fulfill({ json: { ok: true, projekte: [{ id: "p1", titel: "Website für Musterbau" }],
+ nachrichten: [
+ { id: "m1", autor: "kunde", text: "Kurze Frage zur Domain.", projekt_titel: null,
+ erstellt_am: new Date(Date.now() - 7200000).toISOString() },
+ { id: "m2", autor: "dogfather", text: "Die läuft schon auf dich, alles gut.",
+ projekt_titel: null, erstellt_am: new Date(Date.now() - 3600000).toISOString() },
+ ] } });
+ }
+ if (u.includes("/uebersicht")) {
+ return route.fulfill({ json: { ok: true, kunde: { name: "Anna" },
+ projekte: [PROJEKT], offeneZahlungen: [], abos: [] } });
+ }
+ return route.fulfill({ json: { ok: true } });
+ });
+ await seite.addInitScript(() => {
+ sessionStorage.setItem("wd-portal-token", "t");
+ sessionStorage.setItem("wd-sitzung-aktiv", "1");
+ });
+
+ await seite.goto("http://127.0.0.1:4326/webdesign/portal.html");
+ await seite.waitForTimeout(1000);
+ console.log("\n=== " + wo + " ===");
+
+ // ---- Postfach auf der Übersicht ----
+ pruefe(wo + ": Postfach steht auf der Übersicht", await seite.locator("#po-pf-senden").count() === 1);
+ pruefe(wo + ": bisherige Nachrichten sind da", await seite.locator("#po-postfach .po-nachricht").count() === 2);
+
+ // Leere Nachricht muss abgelehnt werden, nicht stillschweigend gesendet
+ await seite.click("#po-pf-senden");
+ await seite.waitForTimeout(300);
+ pruefe(wo + ": leere Nachricht wird abgelehnt",
+ await seite.locator("#po-pf-feld.wd-feld--fehler").count() === 1);
+
+ await seite.screenshot({ path: `pruef-portal-postfach-${wo.toLowerCase()}.png`, fullPage: true });
+
+ // ---- Projekt öffnen ----
+ await seite.click("[data-projekt]");
+ await seite.waitForTimeout(900);
+
+ const m = await seite.evaluate(() => {
+ const gruppen = Array.from(document.querySelectorAll(".po-aufg-gruppe"));
+ const farben = gruppen.map((g) => getComputedStyle(g.querySelector(".po-aufg-kopf")).color);
+ const dubist = document.querySelector(".po-dubist");
+ const ersteGruppe = gruppen[0];
+ const knoepfe = document.querySelectorAll("button.po-aufg-haken");
+ const feste = document.querySelectorAll(".po-aufg-haken--fest");
+ const aufgKarte = document.querySelector(".po-aufg-stand");
+ const zahlKarte = Array.from(document.querySelectorAll(".wd-karte h3"))
+ .find((h) => h.textContent.includes("Zahlung"));
+
+ // Anordnung einer Zeile pruefen (Lehre aus der Verwaltung)
+ const z = document.querySelector(".po-aufg-gruppe .po-aufg");
+ const k = z?.querySelector(".po-aufg-haken")?.getBoundingClientRect();
+ const t = z?.querySelector(".po-aufg-titel")?.getBoundingClientRect();
+
+ return {
+ gruppen: gruppen.map((g) => g.dataset.k),
+ farbenVerschieden: new Set(farben).size,
+ dubistDa: !!dubist,
+ dubistVorListe: dubist && ersteGruppe
+ ? dubist.getBoundingClientRect().top < ersteGruppe.getBoundingClientRect().top : false,
+ knoepfe: knoepfe.length,
+ feste: feste.length,
+ standDa: !!aufgKarte,
+ aufgabenVorZahlungen: aufgKarte && zahlKarte
+ ? aufgKarte.getBoundingClientRect().top < zahlKarte.getBoundingClientRect().top : false,
+ kaestchenVorTitel: k && t ? k.right <= t.left + 1 : false,
+ balkenTeile: document.querySelectorAll(".po-fs-teil").length,
+ };
+ });
+
+ pruefe(wo + ": alle vier Abschnitte erscheinen",
+ m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(","));
+ pruefe(wo + ": jeder Abschnitt hat eine eigene Farbe", m.farbenVerschieden === 4, "n=" + m.farbenVerschieden);
+ pruefe(wo + ": Fortschrittsbalken hat vier Teile", m.balkenTeile === 4);
+ pruefe(wo + ": „Das brauche ich von dir“ ist da", m.dubistDa);
+ pruefe(wo + ": … und steht VOR der langen Liste", m.dubistVorListe);
+ pruefe(wo + ": Aufgabenliste steht vor den Zahlungen", m.aufgabenVorZahlungen);
+ pruefe(wo + ": grosse Zahl statt Prozent", m.standDa);
+ pruefe(wo + ": Kästchen steht links vom Titel", m.kaestchenVorTitel);
+
+ /* Der Kunde hat 2 offene eigene Punkte. Sie erscheinen zweimal (oben
+ und in der Gruppe) -> 4 Knöpfe. Alle übrigen Kästchen sind fest. */
+ pruefe(wo + ": nur eigene Punkte sind anklickbar", m.knoepfe === 4, "Knöpfe=" + m.knoepfe + ", fest=" + m.feste);
+ pruefe(wo + ": fremde Punkte haben KEINEN Knopf", m.feste === 5, "n=" + m.feste);
+
+ // Abhaken loest den richtigen Aufruf aus
+ await seite.locator("button.po-aufg-haken").first().click();
+ await seite.waitForTimeout(700);
+ pruefe(wo + ": Abhaken meldet den richtigen Punkt", abgehakt === "a2", "gemeldet: " + abgehakt);
+
+ if (breite < 900) {
+ const zuKlein = await seite.evaluate(() =>
+ Array.from(document.querySelectorAll("button.po-aufg-haken"))
+ .filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length);
+ pruefe(wo + ": Kästchen sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein);
+ }
+
+ pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | "));
+
+ await seite.screenshot({ path: `pruef-portal-aufgaben-${wo.toLowerCase()}.png`, fullPage: true });
+ await seite.close();
+}
+
+await browser.close();
+srv.close();
+const fehler = ergebnisse.filter((e) => !e.ok).length;
+console.log(`\n===== ${ergebnisse.length - fehler} bestanden, ${fehler} fehlgeschlagen =====`);
+process.exitCode = fehler ? 1 : 0;
diff --git a/pruef-texte.mjs b/pruef-texte.mjs
new file mode 100644
index 00000000..cb8d3cd8
--- /dev/null
+++ b/pruef-texte.mjs
@@ -0,0 +1,182 @@
+/* Sucht doppelt kodierte Zeichen auf ALLEN Webdesign-Seiten in ALLEN
+ fünf Sprachen.
+
+ Anlass: Im Portal stand sichtbar "Ideen & Änderungswünsche".
+ Ursache ist eine Falle, die sich leicht wiederholt — es gibt zwei
+ Wege, wie ein Text in die Seite kommt:
+
+ data-i18n="schluessel" -> wird als HTML eingesetzt.
+ Dort ist "&" RICHTIG.
+ s(window.WD.t("schluessel")) -> wird abgesichert eingesetzt.
+ Dort wird "&" ein zweites Mal
+ kodiert und erscheint wörtlich.
+
+ Derselbe Textbaustein kann also je nach Verwendungsort richtig oder
+ falsch sein. Das kann man nicht zuverlässig im Kopf behalten —
+ deshalb prüft es der Browser: Was am Ende auf dem Bildschirm steht,
+ darf keine Kodierungsreste enthalten.
+*/
+import { chromium } from "playwright";
+import { createServer } from "node:http";
+import { readFileSync, existsSync, readdirSync } from "node:fs";
+import { join, extname } from "node:path";
+
+const W = process.cwd();
+const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
+ ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
+ ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
+const srv = createServer((q, r) => {
+ const p = join(W, decodeURIComponent(q.url.split("?")[0]));
+ if (existsSync(p) && !p.endsWith("/")) {
+ r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
+ return r.end(readFileSync(p));
+ }
+ r.writeHead(404).end("");
+});
+await new Promise((r) => srv.listen(4327, r));
+
+const SEITEN = readdirSync(join(W, "webdesign"))
+ .filter((f) => f.endsWith(".html"));
+const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
+/* Reste, die eine doppelte Kodierung hinterlässt. Sie dürfen im
+ sichtbaren Text nie vorkommen. */
+const RESTE = ["&", "<", ">", """, "'", " "];
+
+/* =====================================================================
+ TEIL 1 — Prüfung am QUELLTEXT
+
+ Die Browserprüfung weiter unten sieht nur, was gerade auf dem
+ Bildschirm steht. Grosse Teile des Portals und der Verwaltung
+ erscheinen aber erst NACH einer Anmeldung — genau dort sass der
+ Fehler, den es zu finden galt ("Ideen & Änderungswünsche").
+ Gegengeprüft: Die Browserprüfung allein hätte ihn NICHT gefunden.
+
+ Diese Prüfung braucht keine Anzeige. Sie fragt nur:
+ Gibt es einen Textbaustein mit "&", der irgendwo durch die
+ Absicherung s(...) geschickt wird? Dann ist er dort doppelt kodiert.
+ ===================================================================== */
+function quelltextPruefen() {
+ const i18nDateien = readdirSync(join(W, "assets", "js"))
+ .filter((f) => f.startsWith("i18n-wd-") && f.endsWith(".js"));
+
+ // Alle Schlüssel einsammeln, deren Text ein "&" enthält.
+ const verdaechtig = new Map(); // schluessel -> datei
+ for (const datei of i18nDateien) {
+ const inhalt = readFileSync(join(W, "assets", "js", datei), "utf-8");
+ /* Der Block eines Schlüssels endet dort, wo seine geschweifte
+ Klammer wieder zugeht — nicht erst beim nächsten Schlüssel.
+
+ Der Unterschied ist nicht theoretisch: Zwischen zwei Schlüsseln
+ stehen Kommentare, und einer davon lautet "Ideen & Änderungs-
+ wünsche". Mit der Regel "bis zum nächsten Schlüssel" hat der
+ VORHERIGE Schlüssel (po_senden, ein schlichtes "Senden") diesen
+ Kommentar mitgeschluckt und wurde fälschlich gemeldet. Ein
+ Fehlalarm im Prüfwerkzeug ist fast so schädlich wie ein
+ übersehener Fehler — man gewöhnt sich daran, ihn wegzusehen. */
+ const zeilen = inhalt.split("\n");
+ let aktuell = null;
+ let text = "";
+ let tiefe = 0;
+ const abschliessen = () => {
+ if (aktuell && text.includes("&")) verdaechtig.set(aktuell, datei);
+ aktuell = null; text = ""; tiefe = 0;
+ };
+ for (const z of zeilen) {
+ if (!aktuell) {
+ const m = z.match(/^ ([a-zA-Z0-9_]+):\s*\{/);
+ if (!m) continue;
+ aktuell = m[1]; text = z;
+ tiefe = (z.match(/\{/g) || []).length - (z.match(/\}/g) || []).length;
+ if (tiefe <= 0) abschliessen();
+ } else {
+ text += "\n" + z;
+ tiefe += (z.match(/\{/g) || []).length - (z.match(/\}/g) || []).length;
+ if (tiefe <= 0) abschliessen();
+ }
+ }
+ abschliessen();
+ }
+
+ // Wo werden sie abgesichert eingesetzt?
+ const quellen = [
+ ...readdirSync(join(W, "assets", "js")).filter((f) => f.startsWith("wd-") && f.endsWith(".js"))
+ .map((f) => ["assets/js/" + f, readFileSync(join(W, "assets", "js", f), "utf-8")]),
+ ...SEITEN.map((f) => ["webdesign/" + f, readFileSync(join(W, "webdesign", f), "utf-8")]),
+ ];
+
+ const funde = [];
+ for (const [schluessel, datei] of verdaechtig) {
+ // s(window.WD.t("key")) oder s(t("key"))
+ const muster = new RegExp(
+ "\\bs\\(\\s*(?:window\\.WD\\.)?t\\(\\s*[\"']" + schluessel + "[\"']", "");
+ for (const [name, inhalt] of quellen) {
+ if (muster.test(inhalt)) funde.push({ schluessel, datei, benutztIn: name });
+ }
+ }
+
+ console.log(`Quelltext: ${verdaechtig.size} Textbausteine enthalten "&".`);
+ if (funde.length) {
+ for (const f of funde) {
+ console.log(` FEHL ${f.schluessel} (${f.datei}) wird in ${f.benutztIn} abgesichert eingesetzt`);
+ console.log(` -> dort muss ein echtes "&" stehen, sonst erscheint "&" wörtlich.`);
+ }
+ } else {
+ console.log(" ok keiner davon wird durch die Absicherung geschickt.\n");
+ }
+ return funde.length;
+}
+
+let quellFehler = quelltextPruefen();
+
+const browser = await chromium.launch();
+const seite = await browser.newPage({ viewport: { width: 1280, height: 900 } });
+// Die API gibt es hier nicht -- leere, gültige Antworten genügen.
+await seite.route("**/webdesign/admin/**", (r) => r.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], verlaeufe: [] } }));
+await seite.route("**/webdesign/portal/**", (r) => r.fulfill({ json: { ok: true } }));
+await seite.route("**/webdesign/api-ausweis", (r) => r.fulfill({ json: {} }));
+await seite.addInitScript(() => { sessionStorage.setItem("wd-sitzung-aktiv", "1"); });
+
+let treffer = 0;
+let geprueft = 0;
+
+for (const datei of SEITEN) {
+ for (const sprache of SPRACHEN) {
+ await seite.addInitScript(`localStorage.setItem("dogi-sprache", ${JSON.stringify(sprache)})`);
+ await seite.goto("http://127.0.0.1:4327/webdesign/" + datei);
+ await seite.waitForTimeout(320);
+ geprueft++;
+
+ const gefunden = await seite.evaluate((reste) => {
+ const raus = [];
+ const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+ let n;
+ while ((n = lauf.nextNode())) {
+ const eltern = n.parentElement;
+ if (!eltern || eltern.closest("script, style, code, pre")) continue;
+ const t = n.nodeValue;
+ for (const r of reste) {
+ if (t.includes(r)) {
+ raus.push({ rest: r, text: t.trim().slice(0, 70), wo: eltern.tagName.toLowerCase() });
+ }
+ }
+ }
+ return raus;
+ }, RESTE);
+
+ if (gefunden.length) {
+ treffer += gefunden.length;
+ for (const g of gefunden) {
+ console.log(` FEHL ${datei} [${sprache}] <${g.wo}> ${g.rest} in: "${g.text}"`);
+ }
+ }
+ }
+}
+
+await browser.close();
+srv.close();
+console.log(`\n${geprueft} Seiten-Sprach-Kombinationen geprüft (${SEITEN.length} Seiten x ${SPRACHEN.length} Sprachen).`);
+const gesamt = treffer + quellFehler;
+console.log(gesamt === 0
+ ? "===== keine doppelt kodierten Zeichen gefunden (Quelltext + Anzeige) ====="
+ : `===== ${gesamt} Fundstellen (${quellFehler} im Quelltext, ${treffer} in der Anzeige) =====`);
+process.exitCode = gesamt ? 1 : 0;
diff --git a/webdesign/ablauf.html b/webdesign/ablauf.html
index b06092b6..02e65249 100644
--- a/webdesign/ablauf.html
+++ b/webdesign/ablauf.html
@@ -13,8 +13,8 @@
-
-
+
+