diff --git a/assets/css/webdesign.css b/assets/css/webdesign.css
index 181f54c5..2cfee4d4 100644
--- a/assets/css/webdesign.css
+++ b/assets/css/webdesign.css
@@ -615,6 +615,13 @@ a.wd-karte:hover, .wd-karte--klickbar:hover {
.wd-tag--lila { background: rgb(var(--wd-lila-rgb) / .12); border-color: rgb(var(--wd-lila-rgb) / .34); color: var(--wd-lila); }
.wd-tag--gold { background: rgb(var(--wd-gold-rgb) / .12); border-color: rgb(var(--wd-gold-rgb) / .34); color: var(--wd-gold); }
.wd-tag--ok { background: rgb(var(--wd-ok-rgb) / .12); border-color: rgb(var(--wd-ok-rgb) / .34); color: var(--wd-ok); }
+/* Aqua fuer die Arcade. Als Schriftfarbe unbedenklich: Hyper Aqua liegt
+ auf allen vier dunklen Flaechen zwischen 12,4:1 und 19,8:1. */
+.wd-tag--aqua { background: rgb(var(--wd-aqua-rgb) / .12); border-color: rgb(var(--wd-aqua-rgb) / .34); color: rgb(var(--wd-aqua-rgb)); }
+/* Indigo NUR als Flaeche und Kante, NIE als Schrift: Prism Indigo ist
+ die einzige Farbe der Palette, die als Text durchfaellt (3,25--4,29:1).
+ Die Schrift bleibt deshalb bewusst Chrome Silver. */
+.wd-tag--indigo { background: rgb(var(--wd-indigo-rgb) / .16); border-color: rgb(var(--wd-indigo-rgb) / .40); color: rgb(var(--wd-chrome-rgb)); }
.wd-tags { display: flex; flex-wrap: wrap; gap: .45rem; }
/* ---------------------------------------------------------------------
diff --git a/assets/img/wd-projekt-analyse.jpg b/assets/img/wd-projekt-analyse.jpg
new file mode 100644
index 00000000..7501198d
Binary files /dev/null and b/assets/img/wd-projekt-analyse.jpg differ
diff --git a/assets/img/wd-projekt-buchhaltung.jpg b/assets/img/wd-projekt-buchhaltung.jpg
new file mode 100644
index 00000000..0ed45622
Binary files /dev/null and b/assets/img/wd-projekt-buchhaltung.jpg differ
diff --git a/assets/img/wd-projekt-zockeranstalt.jpg b/assets/img/wd-projekt-zockeranstalt.jpg
new file mode 100644
index 00000000..6e93d071
Binary files /dev/null and b/assets/img/wd-projekt-zockeranstalt.jpg differ
diff --git a/assets/js/i18n-wd-portfolio.js b/assets/js/i18n-wd-portfolio.js
index dabf4f14..3f220804 100644
--- a/assets/js/i18n-wd-portfolio.js
+++ b/assets/js/i18n-wd-portfolio.js
@@ -4,9 +4,16 @@
Inhaltsregeln, die hier bewusst eingehalten sind:
- Keine erfundenen Kundenbewertungen. Auf dieser Seite steht KEIN
einziges Zitat, weil es noch keines gibt, das jemand freigegeben hat.
- - Keine fremden Projekte ohne Erlaubnis. Beide gezeigten Seiten sind
- eigene Projekte; die Vorschaubilder sind echte Screenshots der
- laufenden Seiten vom 22.08.2026, keine Mockups.
+ - Keine fremden Projekte ohne Erlaubnis. Alle fuenf gezeigten Seiten
+ sind eigene Projekte; die Vorschaubilder sind echte Screenshots der
+ laufenden Seiten (Projekt 1-2 vom 22.08.2026, Projekt 3-5 vom
+ 24.08.2026), keine Mockups.
+ - Zwei Vorschaubilder sind bewusst NICHT unbearbeitet:
+ Die Buchhaltung zeigt nur die Anmeldung, weil dahinter echte
+ Geschaeftszahlen liegen. Beim Command Center sind die Gesichter
+ unkenntlich gemacht -- die Seite gehoert dem Team, und niemand hat
+ zugestimmt, sein Bild im Portfolio zu zeigen. Beides steht als
+ Hinweis auch fuer die Besucher auf der Seite, nicht nur hier.
- Jede Referenz im gleichen Format: Ausgangslage, Lösung, Ergebnis.
===================================================================== */
window.I18N_WD = {
@@ -20,18 +27,18 @@ window.I18N_WD = {
pf_eyebrow: { de: "Portfolio", "de-CH": "Portfolio", en: "Portfolio", fr: "Portfolio", pt: "Portfólio" },
pf_h1: {
- de: "Zwei Projekte, die man anfassen kann",
- "de-CH": "Zwei Projekt, wo mer aalange cha",
- en: "Two projects you can actually visit",
- fr: "Deux projets que l'on peut réellement visiter",
- pt: "Dois projetos que se podem mesmo visitar",
+ de: "Fünf Projekte, die man anfassen kann",
+ "de-CH": "Föif Projekt, wo mer aalange cha",
+ en: "Five projects you can actually visit",
+ fr: "Cinq projets que l'on peut réellement visiter",
+ pt: "Cinco projetos que se podem mesmo visitar",
},
pf_lead: {
- de: "Beide Seiten laufen wirklich, unter ihrer eigenen Adresse, auf einem eigenen Server. Die Vorschaubilder sind echte Aufnahmen der laufenden Seiten — keine gezeichneten Entwürfe.",
- "de-CH": "Beidi Site laufed würklich, under ihrer eigene Adress, uf eme eigene Server. D Vorschaubilder sind ächti Ufnahme vo de laufende Site — kei zeichneti Entwürf.",
- en: "Both sites are genuinely running, at their own address, on their own server. The preview images are real captures of the live sites — not drawn mockups.",
- fr: "Les deux sites fonctionnent réellement, à leur propre adresse, sur un serveur dédié. Les aperçus sont de vraies captures des sites en ligne — pas des maquettes dessinées.",
- pt: "Ambos os sites estão mesmo no ar, no seu próprio endereço, em servidor próprio. As pré-visualizações são capturas reais dos sites ativos — não são maquetes desenhadas.",
+ de: "Jede dieser Seiten läuft wirklich, unter ihrer eigenen Adresse. Die Vorschaubilder sind echte Aufnahmen der laufenden Seiten — keine gezeichneten Entwürfe. Wo eine Aufnahme bearbeitet ist, steht der Grund beim Projekt dabei.",
+ "de-CH": "Jedi vo dene Site lauft würklich, under ihrer eigene Adress. D Vorschaubilder sind ächti Ufnahme vo de laufende Site — kei zeichneti Entwürf. Wo en Ufnahm bearbeitet isch, staht de Grund bim Projekt debii.",
+ en: "Every one of these sites is genuinely running, at its own address. The preview images are real captures of the live sites — not drawn mockups. Where a capture has been edited, the reason is given with the project.",
+ fr: "Chacun de ces sites fonctionne réellement, à sa propre adresse. Les aperçus sont de vraies captures des sites en ligne — pas des maquettes dessinées. Lorsqu'une capture a été retouchée, la raison est indiquée avec le projet.",
+ pt: "Cada um destes sites está mesmo no ar, no seu próprio endereço. As pré-visualizações são capturas reais dos sites ativos — não são maquetes desenhadas. Sempre que uma captura foi editada, o motivo é indicado junto ao projeto.",
},
/* Gemeinsame Beschriftungen — gleiches Format für jede Referenz */
@@ -147,12 +154,165 @@ window.I18N_WD = {
pt: "Não há uma única citação de cliente nesta página. Não por falta de clientes satisfeitos, mas porque ainda ninguém escreveu e aprovou um depoimento — e avaliações inventadas não aparecem aqui por princípio. Assim que existirem reais, estarão aqui. Até lá, prefiro um espaço vazio a um inventado.",
},
+
+ /* ---------------- Projekt 3: Dogi's ZockerAnstalt ---------------- */
+ p3_h2: { de: "Dogi's ZockerAnstalt", "de-CH": "Dogi's ZockerAnstalt", en: "Dogi's ZockerAnstalt", fr: "Dogi's ZockerAnstalt", pt: "Dogi's ZockerAnstalt" },
+ p3_alt: {
+ de: "Startseite von zockeranstalt.dogfather-universe.com",
+ "de-CH": "Startsite vo zockeranstalt.dogfather-universe.com",
+ en: "Home page of zockeranstalt.dogfather-universe.com",
+ fr: "Page d'accueil de zockeranstalt.dogfather-universe.com",
+ pt: "Página inicial de zockeranstalt.dogfather-universe.com",
+ },
+ p3_art: { de: "Spieleseite", "de-CH": "Spilsite", en: "Games site", fr: "Site de jeux", pt: "Site de jogos" },
+ p3_ausgang: {
+ de: "Die Runde wollte zusammen spielen, ohne sich bei einem fremden Anbieter anzumelden.",
+ "de-CH": "D Rundi hät wölle zäme spile, ohni sich bi eme frönde Aabieter aazmelde.",
+ en: "The group wanted to play together without signing up with an outside provider.",
+ fr: "Le groupe voulait jouer ensemble sans s'inscrire chez un prestataire externe.",
+ pt: "O grupo queria jogar em conjunto sem se registar num fornecedor externo.",
+ },
+ p3_loesung: {
+ de: "Eine eigene Spielhalle mit acht selbst gebauten Spielen, mehrere davon gemeinsam in Echtzeit spielbar.",
+ "de-CH": "En eigeni Spilhalle mit acht sälber boute Spil, mehreri devo zäme i Echtziit spilbar.",
+ en: "An arcade of its own with eight hand-built games, several of them playable together in real time.",
+ fr: "Une salle de jeux maison avec huit jeux créés sur mesure, dont plusieurs jouables ensemble en temps réel.",
+ pt: "Um salão de jogos próprio com oito jogos feitos de raiz, vários deles jogáveis em conjunto em tempo real.",
+ },
+ p3_ergebnis: {
+ de: "Läuft unter eigener Adresse auf dem eigenen Server.",
+ "de-CH": "Lauft under eigener Adress uf em eigene Server.",
+ en: "Running at its own address on its own server.",
+ fr: "En ligne à sa propre adresse, sur un serveur dédié.",
+ pt: "No ar no seu próprio endereço, em servidor próprio.",
+ },
+ p3_f1: { de: "Acht Spiele, jedes einzeln gebaut", "de-CH": "Acht Spil, jedes einzeln boue", en: "Eight games, each built individually", fr: "Huit jeux, chacun développé séparément", pt: "Oito jogos, cada um construído de raiz" },
+ p3_f2: { de: "Mehrspieler in Echtzeit, mit Live-Ranglisten", "de-CH": "Mehrspiler i Echtziit, mit Live-Rangliste", en: "Real-time multiplayer with live rankings", fr: "Multijoueur en temps réel, avec classements en direct", pt: "Multijogador em tempo real, com classificações ao vivo" },
+ p3_f3: { de: "Konten und Bestenlisten", "de-CH": "Konte und Bestelischte", en: "Accounts and high-score tables", fr: "Comptes et tableaux des meilleurs scores", pt: "Contas e tabelas de melhores pontuações" },
+ p3_f4: { de: "Ein Regelwerk zu jedem Spiel", "de-CH": "Es Regelwärch zu jedem Spil", en: "A rulebook for every game", fr: "Un règlement pour chaque jeu", pt: "Um regulamento para cada jogo" },
+ p3_f5: { de: "Eigene Fotos hochladbar, für ein Suchbild daraus", "de-CH": "Eigeni Fotti chasch ufelade, für es Suechbild drus", en: "Upload your own photos to turn them into a spot-the-difference picture", fr: "Possibilité d'importer ses propres photos pour en faire un jeu des différences", pt: "Pode carregar fotos próprias para criar um jogo das diferenças" },
+ p3_stil: {
+ de: "Spielhallen-Optik in Neonfarben — kräftig, aber ohne Flackern und Blinken.",
+ "de-CH": "Spilhalle-Optik i Neonfarbe — kräftig, aber ohni Flackere und Blinke.",
+ en: "Arcade look in neon colours — bold, but with no flicker or blinking.",
+ fr: "Esthétique arcade aux couleurs néon — franche, mais sans scintillement ni clignotement.",
+ pt: "Visual de arcada em cores néon — marcante, mas sem tremeluzir nem piscar.",
+ },
+
+ /* ---------------- Projekt 4: Buchhaltung & Steuern ---------------- */
+ p4_h2: { de: "Buchhaltung & Steuern", "de-CH": "Buechhaltig & Stüüre", en: "Bookkeeping & Tax", fr: "Comptabilité & impôts", pt: "Contabilidade & Impostos" },
+ p4_alt: {
+ de: "Anmeldeseite von buchhaltung.vans-diy-bastelbedarf.com",
+ "de-CH": "Aameldesite vo buchhaltung.vans-diy-bastelbedarf.com",
+ en: "Sign-in page of buchhaltung.vans-diy-bastelbedarf.com",
+ fr: "Page de connexion de buchhaltung.vans-diy-bastelbedarf.com",
+ pt: "Página de início de sessão de buchhaltung.vans-diy-bastelbedarf.com",
+ },
+ p4_art: { de: "Fachanwendung", "de-CH": "Fachaawändig", en: "Specialist application", fr: "Application métier", pt: "Aplicação especializada" },
+ p4_ausgang: {
+ de: "Die Zahlen des Shops lagen in Tabellen und Zetteln verstreut, und vor dem Steuertermin musste alles von Hand zusammengesucht werden.",
+ "de-CH": "D Zahle vom Shop sind i Tabelle und uf Zettel verstreut gsi, und vor em Stüürtermin hät mer alles vo Hand zämesueche müesse.",
+ en: "The shop's figures were scattered across spreadsheets and slips of paper, and everything had to be gathered by hand before the tax deadline.",
+ fr: "Les chiffres de la boutique étaient éparpillés entre tableurs et bouts de papier, et il fallait tout rassembler à la main avant l'échéance fiscale.",
+ pt: "Os números da loja estavam dispersos por folhas de cálculo e papéis, e antes do prazo fiscal era tudo reunido à mão.",
+ },
+ p4_loesung: {
+ de: "Eine eigene Buchhaltung mit doppelter Buchführung, die Verkäufe automatisch aus dem Shop übernimmt.",
+ "de-CH": "En eigeni Buechhaltig mit doppleter Buechfüehrig, wo d Verchäuf automatisch us em Shop übernimmt.",
+ en: "A bookkeeping system of its own using double-entry accounting, which pulls sales in from the shop automatically.",
+ fr: "Une comptabilité maison en partie double, qui récupère automatiquement les ventes depuis la boutique.",
+ pt: "Uma contabilidade própria por partidas dobradas, que importa as vendas da loja automaticamente.",
+ },
+ p4_ergebnis: {
+ de: "Im laufenden Betrieb, mit einer schriftlichen Verfahrensdokumentation an den GoBD entlang.",
+ "de-CH": "Im laufende Betrib, mit ere schriftliche Verfahrensdokumentation em GoBD naa.",
+ en: "In daily use, with written process documentation following the German GoBD rules.",
+ fr: "En service quotidien, avec une documentation écrite des procédures suivant les règles allemandes GoBD.",
+ pt: "Em uso diário, com documentação escrita dos procedimentos segundo as regras alemãs GoBD.",
+ },
+ p4_f1: { de: "Verkäufe kommen alle fünf Minuten selbst aus dem Shop", "de-CH": "Verchäuf chömed alli föif Minute vo sälber us em Shop", en: "Sales arrive from the shop by themselves every five minutes", fr: "Les ventes remontent d'elles-mêmes de la boutique toutes les cinq minutes", pt: "As vendas chegam sozinhas da loja a cada cinco minutos" },
+ p4_f2: { de: "Rechnungen mit fortlaufender Nummer, Korrektur nur per Stornorechnung", "de-CH": "Rächnige mit fortloufender Nummere, Korrektur nur per Stornorächnig", en: "Invoices with consecutive numbering; corrections only via a cancellation invoice", fr: "Factures à numérotation continue ; correction uniquement par facture d'annulation", pt: "Faturas com numeração sequencial; correção apenas por fatura de anulação" },
+ p4_f3: { de: "Kontoauszüge einlesen (CAMT.053, MT940 oder CSV)", "de-CH": "Chontouszüg iiläse (CAMT.053, MT940 oder CSV)", en: "Import bank statements (CAMT.053, MT940 or CSV)", fr: "Import des relevés bancaires (CAMT.053, MT940 ou CSV)", pt: "Importação de extratos bancários (CAMT.053, MT940 ou CSV)" },
+ p4_f4: { de: "Eigene Konten je Kunde und Lieferant", "de-CH": "Eigeni Chonte pro Chund und Lieferant", en: "Separate accounts for each customer and supplier", fr: "Comptes distincts pour chaque client et fournisseur", pt: "Contas próprias para cada cliente e fornecedor" },
+ p4_f5: { de: "Auswertungen entstehen alle aus demselben Buchungsbestand", "de-CH": "D Uuswertige entstöhnd alli us em glyche Buechigsbestand", en: "Every report is generated from one and the same set of entries", fr: "Toutes les analyses proviennent d'un seul et même ensemble d'écritures", pt: "Todas as análises resultam do mesmo conjunto de lançamentos" },
+ p4_stil: {
+ de: "Ruhig und zurückhaltend — bei Zahlen soll nichts vom Lesen ablenken.",
+ "de-CH": "Ruehig und zrugghaltend — bi Zahle söll nüt vom Läse ablänke.",
+ en: "Calm and restrained — with figures, nothing should distract from reading.",
+ fr: "Sobre et discret — face à des chiffres, rien ne doit détourner de la lecture.",
+ pt: "Calmo e discreto — com números, nada deve distrair da leitura.",
+ },
+ p4_kein_link: {
+ de: "Ohne öffentlichen Zugang: Dahinter liegen echte Geschäftszahlen.",
+ "de-CH": "Ohni öffentliche Zuegang: Dehinter ligged ächti Gschäftszahle.",
+ en: "No public access: real business figures lie behind it.",
+ fr: "Pas d'accès public : de vrais chiffres d'entreprise se trouvent derrière.",
+ pt: "Sem acesso público: por trás estão números reais da empresa.",
+ },
+
+ /* ---------------- Projekt 5: Command Center ---------------- */
+ p5_h2: { de: "Command Center", "de-CH": "Command Center", en: "Command Center", fr: "Command Center", pt: "Command Center" },
+ p5_alt: {
+ de: "Startseite von analyse.dogfather-universe.com, die Gesichter sind zum Schutz der Beteiligten unkenntlich gemacht",
+ "de-CH": "Startsite vo analyse.dogfather-universe.com, d Gsichter sind zum Schutz vo de Beteiligte unkenntlich gmacht",
+ en: "Home page of analyse.dogfather-universe.com; faces have been obscured to protect the people shown",
+ fr: "Page d'accueil de analyse.dogfather-universe.com ; les visages sont floutés pour protéger les personnes concernées",
+ pt: "Página inicial de analyse.dogfather-universe.com; os rostos foram desfocados para proteger as pessoas visadas",
+ },
+ p5_art: { de: "Auswertung", "de-CH": "Uuswertig", en: "Analytics", fr: "Analyse", pt: "Análise" },
+ p5_ausgang: {
+ de: "Die Zahlen zu Videos und Reichweite standen an verschiedenen Stellen, und niemand im Team sah dasselbe Bild.",
+ "de-CH": "D Zahle zu Video und Reichwiiti sind a verschidene Ort gstande, und im Team hät niemert s glyche Bild gseh.",
+ en: "The figures on videos and reach sat in different places, and nobody on the team saw the same picture.",
+ fr: "Les chiffres sur les vidéos et la portée se trouvaient à divers endroits, et personne dans l'équipe ne voyait la même chose.",
+ pt: "Os números sobre vídeos e alcance estavam em sítios diferentes, e ninguém na equipa via o mesmo quadro.",
+ },
+ p5_loesung: {
+ de: "Eine gemeinsame Auswertung, zu der jede und jeder im Team einen eigenen Zugang hat.",
+ "de-CH": "En gmeinsami Uuswertig, wo jedi und jede im Team en eigene Zuegang hät.",
+ en: "One shared analytics view, with an individual login for everyone on the team.",
+ fr: "Une analyse commune, avec un accès personnel pour chaque membre de l'équipe.",
+ pt: "Uma análise partilhada, com acesso próprio para cada pessoa da equipa.",
+ },
+ p5_ergebnis: {
+ de: "Im täglichen Einsatz, alle arbeiten mit demselben Stand.",
+ "de-CH": "Im täglichen Iisatz, alli schaffed mit em glyche Stand.",
+ en: "In daily use — everyone works from the same set of numbers.",
+ fr: "Utilisé au quotidien : tout le monde travaille sur les mêmes données.",
+ pt: "Em uso diário: todos trabalham com os mesmos dados.",
+ },
+ p5_f1: { de: "Videos, Zahlen, Monate und Notizen an einer Stelle", "de-CH": "Video, Zahle, Mönet und Notize a einem Ort", en: "Videos, figures, months and notes in one place", fr: "Vidéos, chiffres, mois et notes au même endroit", pt: "Vídeos, números, meses e notas num só sítio" },
+ p5_f2: { de: "Eigener Zugang je Person, mit unterschiedlichen Rechten", "de-CH": "En eigene Zuegang pro Person, mit underschidliche Rächt", en: "A separate login per person, with different permissions", fr: "Un accès distinct par personne, avec des droits différents", pt: "Um acesso próprio por pessoa, com permissões diferentes" },
+ p5_f3: { de: "Zugangscodes selbst änderbar, nichts fest eingebaut", "de-CH": "Zuegangscode chasch sälber ändere, nüt fest iiboue", en: "Access codes can be changed by you; nothing is hard-wired", fr: "Les codes d'accès sont modifiables par vous ; rien n'est figé dans le code", pt: "Os códigos de acesso podem ser alterados por si; nada está fixo no código" },
+ p5_f4: { de: "Als App installierbar", "de-CH": "Als App installierbar", en: "Installable as an app", fr: "Installable comme une application", pt: "Instalável como aplicação" },
+ p5_stil: {
+ de: "Dunkel und aufgeräumt, mit einer ruhigen Leitfarbe je Bereich.",
+ "de-CH": "Dunkel und ufgruumt, mit ere ruehige Leitfarb pro Bereich.",
+ en: "Dark and tidy, with one calm accent colour per area.",
+ fr: "Sombre et ordonné, avec une couleur dominante sobre par section.",
+ pt: "Escuro e arrumado, com uma cor dominante discreta por área.",
+ },
+ p5_hinweis_h3: {
+ de: "Zum Vorschaubild",
+ "de-CH": "Zum Vorschaubild",
+ en: "About the preview image",
+ fr: "À propos de l'aperçu",
+ pt: "Sobre a pré-visualização",
+ },
+ p5_hinweis: {
+ de: "Die Gesichter auf der Aufnahme sind unkenntlich gemacht. Die Seite gehört dem Team, nicht mir — und niemand hat zugestimmt, sein Bild hier zu zeigen.",
+ "de-CH": "D Gsichter uf de Ufnahm sind unkenntlich gmacht. D Site ghört em Team, nöd mir — und niemert hät zuegstimmt, sis Bild do z zeige.",
+ en: "The faces in the capture have been obscured. The site belongs to the team, not to me — and nobody agreed to have their picture shown here.",
+ fr: "Les visages de la capture ont été floutés. Le site appartient à l'équipe, pas à moi — et personne n'a consenti à ce que son image figure ici.",
+ pt: "Os rostos da captura foram desfocados. O site pertence à equipa, não a mim — e ninguém consentiu que a sua imagem fosse mostrada aqui.",
+ },
+
pf_cta_h2: {
- de: "Soll deins das dritte sein?",
- "de-CH": "Söll dis s dritte si?",
- en: "Shall yours be the third?",
- fr: "Le tien sera-t-il le troisième ?",
- pt: "O teu vai ser o terceiro?",
+ de: "Soll deins das sechste sein?",
+ "de-CH": "Söll dis s sächste si?",
+ en: "Shall yours be the sixth?",
+ fr: "Le tien sera-t-il le sixième ?",
+ pt: "O teu vai ser o sexto?",
},
pf_cta_p: {
de: "Ob dein Projekt hier später auftaucht, entscheidest ausschließlich du — die Aufnahme ins Portfolio ist von der Veröffentlichung deiner Seite getrennt und braucht deine ausdrückliche Erlaubnis.",
diff --git a/blickfang-portfolio-handy.png b/blickfang-portfolio-handy.png
index 736dd3ae..cabd250f 100644
Binary files a/blickfang-portfolio-handy.png and b/blickfang-portfolio-handy.png differ
diff --git a/blickfang-portfolio-pc.png b/blickfang-portfolio-pc.png
index eb8a2326..2b460fb6 100644
Binary files a/blickfang-portfolio-pc.png and b/blickfang-portfolio-pc.png differ
diff --git a/pruef-portfolio-computer.png b/pruef-portfolio-computer.png
new file mode 100644
index 00000000..2d0d22ca
Binary files /dev/null and b/pruef-portfolio-computer.png differ
diff --git a/pruef-portfolio-handy.png b/pruef-portfolio-handy.png
new file mode 100644
index 00000000..910a1805
Binary files /dev/null and b/pruef-portfolio-handy.png differ
diff --git a/pruef-portfolio.mjs b/pruef-portfolio.mjs
new file mode 100644
index 00000000..2680c694
--- /dev/null
+++ b/pruef-portfolio.mjs
@@ -0,0 +1,210 @@
+/* =====================================================================
+ Prueft die Portfolio-Seite nach den drei neuen Eintraegen.
+
+ Der wichtigste Test hier ist NICHT "sieht huebsch aus", sondern:
+ Hat jeder data-i18n-Schluessel im HTML auch eine Uebersetzung? Ein
+ fehlender Schluessel faellt im Deutschen nicht auf -- der Text steht
+ ja als Rueckfalltext im HTML -- und bricht erst auf, wenn jemand die
+ Sprache wechselt. Genau deshalb wird hier in ALLEN fuenf Sprachen
+ geprueft, nicht nur in einer.
+ ===================================================================== */
+import { chromium } from "playwright";
+import http from "http";
+import fs from "fs";
+import path from "path";
+
+const TYP = {
+ ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
+ ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp",
+ ".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg",
+};
+
+const fehlt = [];
+const srv = http.createServer((q, r) => {
+ const u = decodeURIComponent(q.url.split("?")[0]);
+ const p = path.join(process.cwd(), u);
+ if (fs.existsSync(p) && !p.endsWith("/")) {
+ r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" });
+ return r.end(fs.readFileSync(p));
+ }
+ fehlt.push(u);
+ r.writeHead(404);
+ r.end("");
+});
+
+let ok = 0, fehl = 0;
+const pruefe = (name, gut, info) => {
+ if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
+ else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); }
+};
+
+/* Kontrast nach WCAG 2.2. Halbtransparente Flaechen muessen ueber
+ ihrem Untergrund verrechnet werden, sonst misst man Unsinn. */
+function leuchtkraft([r, g, b]) {
+ const f = (v) => {
+ v /= 255;
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+}
+function verhaeltnis(a, b) {
+ const l1 = leuchtkraft(a), l2 = leuchtkraft(b);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+}
+
+srv.listen(4403, async () => {
+ const browser = await chromium.launch();
+ const seite = await browser.newPage({ viewport: { width: 1440, height: 950 } });
+ const skriptfehler = [];
+ seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 120)));
+
+ await seite.goto("http://127.0.0.1:4403/webdesign/portfolio.html");
+ await seite.waitForTimeout(1500);
+
+ /* Die Vorschaubilder tragen loading="lazy" -- der Browser laedt sie
+ erst, wenn sie in Sichtweite kommen. Ohne dieses Vorbeischauen misst
+ der Test "Bild nicht geladen" und meint damit nur, dass er selbst
+ nie hingesehen hat. */
+ await seite.evaluate(async () => {
+ for (const el of document.querySelectorAll(".pfo-bild")) {
+ el.scrollIntoView();
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ window.scrollTo(0, 0);
+ });
+ await seite.waitForTimeout(1200);
+
+ console.log("1. SIND ALLE FUENF PROJEKTE DA?");
+ const projekte = await seite.evaluate(() =>
+ [...document.querySelectorAll(".pfo-projekt")].map((a) => ({
+ titel: a.querySelector("h2")?.textContent.trim(),
+ bild: a.querySelector("img")?.getAttribute("src")?.replace(/.*\//, ""),
+ geladen: a.querySelector("img")?.naturalWidth > 0,
+ link: a.querySelector("a[href^='http']")?.href || null,
+ })));
+ pruefe("fuenf Projekte auf der Seite", projekte.length === 5, String(projekte.length));
+ for (const p of projekte) {
+ pruefe(` ${p.titel}: Vorschaubild geladen`, p.geladen, p.bild);
+ }
+
+ /* Die Buchhaltung bekommt bewusst KEINEN Live-Knopf: Dahinter liegt
+ nur eine Anmeldemaske, ein Knopf dorthin waere ein leeres
+ Versprechen. Das wird hier festgehalten, damit es niemand spaeter
+ "der Vollstaendigkeit halber" wieder einbaut. */
+ const buch = projekte.find((p) => /Buchhaltung/i.test(p.titel || ""));
+ pruefe("die Buchhaltung hat absichtlich keinen Live-Knopf", buch && !buch.link,
+ buch ? String(buch.link) : "nicht gefunden");
+
+ console.log("\n2. UEBERSETZUNGEN — IN ALLEN FUENF SPRACHEN");
+ /* Geprueft werden die SEITENEIGENEN Schluessel (pf_, p1_-p5_,
+ seiten_titel). Navigations- und Markentexte liegen bewusst nicht
+ hier, sondern in wd-core.js -- sie gelten fuer alle Seiten und
+ werden von server/pruefe-webdesign-i18n.mjs abgedeckt. Sie hier
+ mitzuzaehlen hat nur Fehlalarm erzeugt.
+
+ Und geprueft wird das ERGEBNIS, nicht das Woerterbuch: Die Seite
+ wird wirklich auf jede der fuenf Sprachen umgeschaltet, danach darf
+ kein seiteneigener Text leer sein. */
+ const luecken = [];
+ for (const sprache of ["de", "de-CH", "en", "fr", "pt"]) {
+ const kaputt = await seite.evaluate((spr) => {
+ window.WD.setSprache(spr);
+ const raus = [];
+ const eigen = /^(pf_|p[1-5]_|seiten_titel)/;
+ for (const el of document.querySelectorAll("[data-i18n]")) {
+ const k = el.getAttribute("data-i18n");
+ if (eigen.test(k) && !el.textContent.trim()) raus.push(k + " (leer)");
+ }
+ for (const el of document.querySelectorAll("[data-i18n-attr]")) {
+ const k = el.getAttribute("data-i18n-attr").split(":")[1];
+ const wert = el.getAttribute("alt");
+ if (eigen.test(k) && !(wert && wert.trim())) raus.push(k + " (alt leer)");
+ }
+ return raus;
+ }, sprache);
+ kaputt.forEach((k) => luecken.push(sprache + ": " + k));
+ }
+ pruefe("in jeder Sprache ist jeder seiteneigene Text gefuellt",
+ luecken.length === 0, luecken.slice(0, 4).join(", ") || "alle fuenf Sprachen sauber");
+
+ /* Gegenprobe: Der Test darf nicht deshalb gruen sein, weil die
+ Umschaltung in Wahrheit gar nichts tut. */
+ const wechsel = await seite.evaluate(() => {
+ const el = document.querySelector('[data-i18n="pf_h1"]');
+ window.WD.setSprache("de");
+ const deutsch = el.textContent.trim();
+ window.WD.setSprache("en");
+ const englisch = el.textContent.trim();
+ window.WD.setSprache("de");
+ return { deutsch, englisch };
+ });
+ pruefe("die Umschaltung wirkt wirklich",
+ wechsel.deutsch !== wechsel.englisch, wechsel.englisch);
+ await seite.waitForTimeout(300);
+
+ console.log("\n3. DIE SEITE SPRICHT NICHT MEHR VON ZWEI PROJEKTEN");
+ const zahlwort = await seite.evaluate(() => {
+ const t = window.I18N_WD;
+ const treffer = [];
+ for (const [k, v] of Object.entries(t)) {
+ for (const [s, txt] of Object.entries(v)) {
+ if (/\b(Zwei|Two|Deux|Dois|Beide|Both|Ambos)\b/i.test(txt)) treffer.push(k + "/" + s);
+ }
+ }
+ return treffer;
+ });
+ pruefe("keine Stelle behauptet noch 'zwei' oder 'beide'", zahlwort.length === 0,
+ zahlwort.join(", ") || "sauber");
+
+ console.log("\n4. LESBARKEIT DER NEUEN SCHILDER");
+ const schilder = await seite.evaluate(() => {
+ const holen = (sel) => {
+ const el = document.querySelector(sel);
+ if (!el) return null;
+ const s = getComputedStyle(el);
+ /* Untergrund suchen: das erste Elternteil mit deckender Farbe. */
+ let p = el.parentElement, unter = null;
+ while (p && !unter) {
+ const bg = getComputedStyle(p).backgroundColor;
+ const m = bg.match(/[\d.]+/g);
+ if (m && (m.length === 3 || parseFloat(m[3]) === 1)) unter = m.slice(0, 3).map(Number);
+ p = p.parentElement;
+ }
+ return {
+ text: el.textContent.trim(),
+ farbe: s.color.match(/[\d.]+/g).slice(0, 3).map(Number),
+ eigen: s.backgroundColor.match(/[\d.]+/g),
+ unter: unter || [3, 8, 16],
+ };
+ };
+ return { aqua: holen(".wd-tag--aqua"), indigo: holen(".wd-tag--indigo") };
+ });
+
+ for (const [name, d] of Object.entries(schilder)) {
+ if (!d) { pruefe(`Schild ${name} gefunden`, false); continue; }
+ /* Eigene halbtransparente Flaeche ueber den Untergrund rechnen. */
+ const a = d.eigen.length === 4 ? parseFloat(d.eigen[3]) : 1;
+ const flaeche = d.unter.map((u, i) => Math.round(Number(d.eigen[i]) * a + u * (1 - a)));
+ const v = verhaeltnis(d.farbe, flaeche);
+ pruefe(`Schild "${d.text}" ist gut lesbar (AA = 4,5:1)`, v >= 4.5, v.toFixed(2) + ":1");
+ }
+
+ console.log("\n5. KEINE FEHLER, KEINE FEHLENDEN DATEIEN");
+ pruefe("kein Skriptfehler", skriptfehler.length === 0, skriptfehler[0] || "");
+ const verpasst = fehlt.filter((f) => /\.(jpg|webp|png|css|js|svg)$/.test(f));
+ pruefe("keine fehlende Datei", verpasst.length === 0, verpasst.join(", ") || "keine");
+
+ await seite.screenshot({ path: "pruef-portfolio-computer.png", fullPage: true });
+ const handy = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ await handy.goto("http://127.0.0.1:4403/webdesign/portfolio.html");
+ await handy.waitForTimeout(1500);
+ const ueberlauf = await handy.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ pruefe("auf dem Handy laeuft nichts seitlich ueber", ueberlauf <= 1, ueberlauf + "px");
+ await handy.screenshot({ path: "pruef-portfolio-handy.png", fullPage: true });
+
+ console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`);
+ await browser.close();
+ srv.close();
+ process.exit(fehl ? 1 : 0);
+});
diff --git a/webdesign/portfolio.html b/webdesign/portfolio.html
index ce1c4031..67567399 100644
--- a/webdesign/portfolio.html
+++ b/webdesign/portfolio.html
@@ -9,7 +9,7 @@
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
Beide Seiten laufen wirklich, unter ihrer eigenen Adresse, auf einem eigenen Server. Die Vorschaubilder sind echte Aufnahmen der laufenden Seiten — keine gezeichneten Entwürfe.
+
Fünf Projekte, die man anfassen kann
+
Jede dieser Seiten läuft wirklich, unter ihrer eigenen Adresse. Die Vorschaubilder sind echte Aufnahmen der laufenden Seiten — keine gezeichneten Entwürfe. Wo eine Aufnahme bearbeitet ist, steht der Grund beim Projekt dabei.
-
@@ -184,6 +184,161 @@
+
+
+
+
+
+
Dogi's ZockerAnstalt
+ Spieleseite
+
+
+
+
Ausgangslage
+
Die Runde wollte zusammen spielen, ohne sich bei einem fremden Anbieter anzumelden.
+
+
+
Umgesetzte Lösung
+
Eine eigene Spielhalle mit acht selbst gebauten Spielen, mehrere davon gemeinsam in Echtzeit spielbar.
+
+
+
Ergebnis
+
Läuft unter eigener Adresse auf dem eigenen Server.
+
+
+
+ Details ansehen
+
+
+
Wichtigste Funktionen
+
+
Acht Spiele, jedes einzeln gebaut
+
Mehrspieler in Echtzeit, mit Live-Ranglisten
+
Konten und Bestenlisten
+
Ein Regelwerk zu jedem Spiel
+
Eigene Fotos hochladbar, für ein Suchbild daraus
+
+
+
+
Stilrichtung
+
Spielhallen-Optik in Neonfarben — kräftig, aber ohne Flackern und Blinken.
Ob dein Projekt hier später auftaucht, entscheidest ausschließlich du — die Aufnahme ins Portfolio ist von der Veröffentlichung deiner Seite getrennt und braucht deine ausdrückliche Erlaubnis.
Projekt anfragen
diff --git a/webdesign/sw.js b/webdesign/sw.js
index d261c0f8..62711eed 100644
--- a/webdesign/sw.js
+++ b/webdesign/sw.js
@@ -34,7 +34,7 @@
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
Stand fest. */
-const CACHE_NAME = "dogfather-webdesign-v33";
+const CACHE_NAME = "dogfather-webdesign-v34";
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
HTML-Datei in dieser Liste. */