Einbau-Skript news.js für Kundenwebsites

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 17:16:37 +00:00
1 parent 5a09d060a1
commit 035d1d5517
5 files changed
+547

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
# News-Pinnwand auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. Die Pinnwand zeigt die Beiträge, die der Kunde im Portal (Tab „Funktionen“) schreibt. Den `siteKey` findest Du im Portal-Admin beim Projekt (16 Zeichen, a–z und 2–7).
## 1. Die Stelle auf der Seite
```html
<div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">
<!-- statischer Leerzustand, siehe unten -->
</div>
```
- `data-webklar-news`: der siteKey des Projekts (Pflicht).
- `data-api`: Herkunft des Portals. Ohne Angabe nimmt das Skript die Herkunft aus der Adresse des Skripts.
- `data-signature` (optional): Unterschrift unter jedem Zettel, z. B. `data-signature="Opa Martin"`. Ohne Attribut gibt es keine Unterschrift.
## 2. Das Skript (einmal pro Seite, vor `</body>`)
```html
<script src="https://project.webklar.com/embed/news.js" defer></script>
```
Keine Abhängigkeiten, keine Cookies, kein Browser-Speicher. Kundentexte werden nur als Text eingesetzt, nie als HTML. Das Skript wird 5 Minuten gecacht und darf von jeder Domain geladen werden.
## 3. Statischer Leerzustand
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die API mindestens einen Beitrag liefert. Bei 0 Beiträgen, Fehler, ausgeschaltetem JavaScript oder unbekanntem Schlüssel bleibt es stehen. Beispiel:
```html
<div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">
<h3>Hier hängt bald was Neues.</h3>
<p>Neuigkeiten findest Du bald an dieser Pinnwand.</p>
</div>
```
Gestalte den Leerzustand im Stil der Kundenseite. Das Skript fasst ihn nicht an.
## 4. Aussehen anpassen (CSS-Variablen)
Setze sie auf dem `div` oder in der Seiten-CSS, z. B. `[data-webklar-news]{ --wkn-frame:#2f5d50; }`.
| Variable | Standard |
| --- | --- |
| `--wkn-board` (Korkfläche) | `#C08A52` |
| `--wkn-frame` (Holzrahmen) | `#A8733F` |
| `--wkn-font-display` (Überschriften) | `"Fredoka", system-ui, sans-serif` |
| `--wkn-font-body` (Text) | `"Nunito", system-ui, sans-serif` |
| `--wkn-paper-sun` | `#FFF6D6` |
| `--wkn-paper-sky` | `#DDEEFB` |
| `--wkn-paper-coral` | `#FFE1DA` |
| `--wkn-paper-white` | `#fff` |
Die Schriften lädt das Skript nicht selbst. Sind sie auf der Kundenseite nicht eingebunden, greift die Systemschrift. Setze dann die Variablen auf die Schriften der Kundenseite.
Das Layout richtet sich nach der Breite des `div` (Container Query), nicht nach dem Bildschirm: ab 701 px Breite ein Korkbrett mit drei Spalten, darunter eine Wischreihe mit Punkten. Sechs Beiträge pro Seite, „Ältere Neuigkeiten ansehen“ lädt die nächsten nach. Alle Klassen beginnen mit `wkn-`, die Styles stehen einmalig in `<style id="wkn-style">`.
## 5. Datenschutzerklärung des Kunden
Das Skript lädt Beiträge und Bilder von `project.webklar.com`. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung des Kunden braucht deshalb den Absatz aus `docs/funktionen/website-datenschutz-absatz.md`. Bau die Pinnwand erst ein, wenn der Absatz dort steht.
## 6. Testen
`/embed/news-demo.html` auf dem Portal ist eine nicht verlinkte Testseite. Automatische Prüfung: `node test/ui/embed.ui.mjs`.
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>News-Pinnwand Testseite</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#fff;color:#232A33}
header,footer{padding:16px 24px;background:#f1f3f6}
main{max-width:1100px;margin:0 auto;padding:32px 16px}
.static-empty{text-align:center;padding:48px 16px;border:2px dashed #C08A52;border-radius:16px}
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h2>Neues aus dem Laden</h2>
<!-- data-api weglassen = Herkunft des Skripts; data-signature="Opa Martin" optional -->
<div data-webklar-news="abcdefghijklmnop">
<div class="static-empty" id="static-empty">
<h3>Hier hängt bald was Neues.</h3>
<p>Neuigkeiten findest du bald an dieser Pinnwand.</p>
</div>
</div>
</main>
<footer>Fußbereich</footer>
<script src="/embed/news.js" defer></script>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
/*! WEBklar Neuigkeiten-Pinnwand: Einbau-Skript fuer Kundenwebsites.
* <div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">statischer Leerzustand</div>
* Keine Abhaengigkeiten, keine Cookies, kein Storage. Kundentext nur ueber textContent. */
(function () {
'use strict'
var SELF = document.currentScript
var STYLE_ID = 'wkn-style'
var MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
var ROT = [-1.6, 1.2, -0.8, 1.8, -1.3, 0.9]
var PAPERS = { sun: 1, sky: 1, coral: 1, white: 1 }
var CSS = [
'.wkn-root{container-type:inline-size;display:block;--wkn-board:#C08A52;--wkn-frame:#A8733F;--wkn-font-display:"Fredoka",system-ui,sans-serif;--wkn-font-body:"Nunito",system-ui,sans-serif;--wkn-paper-sun:#FFF6D6;--wkn-paper-sky:#DDEEFB;--wkn-paper-coral:#FFE1DA;--wkn-paper-white:#fff;--wkn-ink:#232A33;--wkn-ink-soft:#5B6673;font-family:var(--wkn-font-body)}',
'.wkn-root *,.wkn-root *::before,.wkn-root *::after{box-sizing:border-box}',
'.wkn-board{position:relative;isolation:isolate;overflow:hidden;border-radius:28px;padding:40px 32px 48px;background-color:var(--wkn-board);',
'background-image:url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27260%27 height=%27260%27%3E%3Cfilter id=%27a%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%271.1%27 numOctaves=%272%27 seed=%273%27/%3E%3CfeColorMatrix values=%270 0 0 0 .29 0 0 0 0 .16 0 0 0 0 .06 0 0 0 -2.4 1.25%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23a)%27/%3E%3C/svg%3E"),url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27220%27 height=%27220%27%3E%3Cfilter id=%27b%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.85%27 numOctaves=%272%27 seed=%2711%27/%3E%3CfeColorMatrix values=%270 0 0 0 .95 0 0 0 0 .8 0 0 0 0 .58 0 0 0 2.6 -1.55%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23b)%27/%3E%3C/svg%3E"),url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27600%27 height=%27600%27%3E%3Cfilter id=%27c%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.012%27 numOctaves=%273%27 seed=%277%27/%3E%3CfeColorMatrix values=%270 0 0 0 .45 0 0 0 0 .27 0 0 0 0 .12 0 0 0 1.4 -.45%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23c)%27/%3E%3C/svg%3E");',
'border:12px solid var(--wkn-frame);box-shadow:inset 0 0 0 1px rgba(80,45,15,.35),inset 0 6px 14px rgba(60,32,10,.35),0 0 0 1px rgba(90,55,25,.5),0 12px 30px rgba(35,52,74,.18)}',
'.wkn-grid{display:grid;gap:32px 24px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}',
'.wkn-grid>:first-child{grid-column:span 2}',
'.wkn-pin{position:relative;transform:rotate(var(--wkn-r,0deg));transition:transform .25s ease;color:var(--wkn-ink)}',
'.wkn-pin:hover{transform:rotate(0deg) translateY(-4px)}',
'.wkn-pin::before{content:"";position:absolute;top:-12px;left:50%;width:84px;height:24px;z-index:2;transform:translateX(-50%) rotate(calc(var(--wkn-r,0deg) * -1.5 - 2deg));background:rgba(255,255,255,.62);box-shadow:0 1px 2px rgba(35,52,74,.12);border-left:2px dotted rgba(35,52,74,.06);border-right:2px dotted rgba(35,52,74,.06)}',
'.wkn-date{white-space:nowrap;font:700 12px/1 var(--wkn-font-body);letter-spacing:.08em;text-transform:uppercase;color:var(--wkn-ink-soft)}',
'.wkn-sig{font:600 15px/1 var(--wkn-font-display);color:var(--wkn-ink-soft);margin-top:14px}',
'.wkn-new{position:absolute;top:-14px;right:-10px;z-index:3;transform:rotate(8deg);background:#E4573D;color:#fff;font:700 12px/1 var(--wkn-font-body);letter-spacing:.04em;padding:7px 12px;border-radius:999px;box-shadow:0 3px 8px rgba(35,52,74,.25)}',
'.wkn-note{padding:28px 24px 22px;border-radius:6px 6px 18px 6px;box-shadow:0 8px 18px rgba(35,52,74,.14);background:var(--wkn-paper,#fff)}',
'.wkn-note h3{font:600 20px/1.25 var(--wkn-font-display);color:var(--wkn-ink);margin:8px 0}',
'.wkn-note p{font:400 16px/1.5 var(--wkn-font-body);color:var(--wkn-ink);margin:0;white-space:pre-line;overflow-wrap:anywhere}',
'.wkn-lead .wkn-note h3{font-size:28px}',
'.wkn-paper-sun{--wkn-paper:var(--wkn-paper-sun)}.wkn-paper-sky{--wkn-paper:var(--wkn-paper-sky)}.wkn-paper-coral{--wkn-paper:var(--wkn-paper-coral)}.wkn-paper-white{--wkn-paper:var(--wkn-paper-white)}',
'.wkn-polaroid{margin:0;background:#fff;padding:12px 12px 16px;border-radius:4px;box-shadow:0 10px 22px rgba(35,52,74,.16)}',
'.wkn-polaroid img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;border-radius:2px;background:#eef0f3}',
'.wkn-polaroid figcaption{display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-top:12px}',
'.wkn-polaroid figcaption b{font:600 17px/1.25 var(--wkn-font-display);color:var(--wkn-ink);overflow-wrap:anywhere}',
'.wkn-combo{display:grid;grid-template-columns:1.25fr 1fr;align-items:center}',
'.wkn-combo .wkn-polaroid{transform:rotate(-2deg)}',
'.wkn-combo .wkn-note{margin-left:-28px;transform:rotate(2.5deg);z-index:1}',
'.wkn-combo::before{left:28%}',
'.wkn-more[hidden]{display:none}',
'.wkn-more{display:flex;justify-content:center;margin-top:40px}',
'.wkn-btn{font:700 16px/1 var(--wkn-font-body);color:var(--wkn-ink);background:#fff;border:0;border-radius:999px;padding:14px 24px;cursor:pointer;box-shadow:0 3px 8px rgba(35,52,74,.25)}',
'.wkn-btn:hover{transform:translateY(-1px)}.wkn-btn:focus-visible{outline:3px solid var(--wkn-ink);outline-offset:2px}.wkn-btn[disabled]{opacity:.6;cursor:default}',
'.wkn-swipehint{display:none;width:max-content;margin:8px auto 0;justify-content:center;align-items:center;gap:6px;font:400 13px/1.2 var(--wkn-font-body);color:var(--wkn-ink);background:rgba(255,255,255,.85);padding:5px 12px;border-radius:999px}',
'.wkn-dots{display:flex;gap:6px;margin-right:8px}',
'.wkn-dots i{width:7px;height:7px;border-radius:50%;background:rgba(35,42,51,.2);transition:width .2s}',
'.wkn-dots i.wkn-on{background:var(--wkn-ink);width:18px;border-radius:999px}',
'@container (max-width:700px){',
'.wkn-board{padding:32px 0;border-radius:20px}',
'.wkn-grid{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:18px;padding:28px 20px 12px;scrollbar-width:none}',
'.wkn-grid::-webkit-scrollbar{display:none}',
'.wkn-grid>*{flex:0 0 76%;scroll-snap-align:center}',
'.wkn-grid>:first-child{grid-column:auto}',
'.wkn-combo{grid-template-columns:1fr}',
'.wkn-combo .wkn-note{margin:-26px 12px 0 26px}',
'.wkn-lead .wkn-note h3{font-size:20px}',
'.wkn-more{margin-top:24px;padding-inline:20px}.wkn-more .wkn-btn{width:100%}',
'.wkn-swipehint{display:flex}',
'}',
'@media (prefers-reduced-motion:reduce){.wkn-pin,.wkn-btn,.wkn-dots i{transition:none}}'
].join('\n')
function injectStyle () {
if (document.getElementById(STYLE_ID)) return
var st = document.createElement('style')
st.id = STYLE_ID
st.textContent = CSS
document.head.appendChild(st)
}
function el (tag, cls, text) {
var e = document.createElement(tag)
if (cls) e.className = cls
if (text != null) e.textContent = text
return e
}
// 'YYYY-MM-DD' (bereits Berliner Kalendertag) -> '29. Sep'
function fmtDate (d) {
var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(d || ''))
if (!m) return ''
return parseInt(m[3], 10) + '. ' + MONTHS[parseInt(m[2], 10) - 1]
}
function absUrl (api, p) {
return typeof p === 'string' && p.charAt(0) === '/' ? api + p : ''
}
function buildNote (p, extraCls) {
var paper = PAPERS[p.paper] ? p.paper : 'sun'
var n = el('div', 'wkn-note wkn-paper-' + paper + (extraCls || ''))
n.appendChild(el('span', 'wkn-date', fmtDate(p.date)))
if (p.title) n.appendChild(el('h3', '', p.title))
n.appendChild(el('p', '', p.text || ''))
if (p.signature) n.appendChild(el('div', 'wkn-sig', p.signature))
return n
}
function buildPhoto (p, api, withCaption) {
var fig = el('figure', 'wkn-polaroid')
if (p.image) {
var img = document.createElement('img')
var small = absUrl(api, p.image.srcSmall)
var full = absUrl(api, p.image.src)
img.src = small || full
if (small && full) {
img.srcset = small + ' 480w, ' + full + ' ' + (p.image.w || 1600) + 'w'
img.sizes = '(max-width:700px) 80vw, 480px'
}
if (p.image.w && p.image.h) { img.width = p.image.w; img.height = p.image.h }
img.alt = p.title || ''
img.loading = 'lazy'
img.decoding = 'async'
fig.appendChild(img)
}
if (withCaption) {
var cap = el('figcaption')
if (p.title) cap.appendChild(el('b', '', p.title))
cap.appendChild(el('span', 'wkn-date', fmtDate(p.date)))
fig.appendChild(cap)
}
return fig
}
function buildPin (p, index, isFirst, api, signature) {
var cls = 'wkn-pin' + (isFirst ? ' wkn-lead' : '')
var r = ROT[index % ROT.length]
var item
p = { id: p.id, type: p.type, title: p.title, text: p.text, paper: p.paper, date: p.date, image: p.image, signature: signature }
if (p.type === 'photo' && p.image) {
item = buildPhoto(p, api, true)
item.className += ' ' + cls
item.style.setProperty('--wkn-r', r + 'deg')
} else if (p.type === 'combo' && p.image) {
item = el('article', cls + ' wkn-combo')
item.style.setProperty('--wkn-r', '0deg')
item.appendChild(buildPhoto(p, api, false))
item.appendChild(buildNote(p))
} else {
item = el('article', cls)
item.style.setProperty('--wkn-r', r + 'deg')
var note = buildNote(p)
// Zettel-Klassen direkt auf die Pin-Huelle, damit Polster/Schatten nur einmal greifen
item.className += ' ' + note.className
while (note.firstChild) item.appendChild(note.firstChild)
}
if (isFirst) item.appendChild(el('span', 'wkn-new', 'Neu'))
return item
}
function init (root) {
var key = root.getAttribute('data-webklar-news')
if (!key || root.getAttribute('data-wkn-init')) return
root.setAttribute('data-wkn-init', '1')
var apiAttr = root.getAttribute('data-api')
var api = String(apiAttr || '').replace(/\/+$/, '')
if (!api && SELF && SELF.src) api = new URL(SELF.src).origin
var signature = root.getAttribute('data-signature') || ''
var state = { page: 1, shown: 0, ui: null, loading: false }
function fetchPage (page) {
return fetch(api + '/api/public/news/' + encodeURIComponent(key) + '?page=' + page, { headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json() })
.then(function (d) {
if (!d || !Array.isArray(d.posts)) throw new Error('format')
return d
})
}
function updateDots () {
var ui = state.ui
if (!ui) return
var kids = ui.grid.children
var center = ui.grid.scrollLeft + ui.grid.clientWidth / 2
var best = 0, bestDist = Infinity
for (var i = 0; i < kids.length; i++) {
var c = kids[i].offsetLeft + kids[i].offsetWidth / 2
if (Math.abs(c - center) < bestDist) { bestDist = Math.abs(c - center); best = i }
}
for (var j = 0; j < ui.dots.children.length; j++) ui.dots.children[j].className = j === best ? 'wkn-on' : ''
}
function append (posts) {
var ui = state.ui
posts.forEach(function (p) {
var idx = state.shown
ui.grid.appendChild(buildPin(p, idx, idx === 0, api, signature))
ui.dots.appendChild(document.createElement('i'))
state.shown++
})
updateDots()
}
function setMore (hasMore) {
var ui = state.ui
ui.more.hidden = !hasMore
ui.btn.disabled = false
}
function build (d) {
root.classList.add('wkn-root')
root.textContent = ''
var board = el('div', 'wkn-board')
var grid = el('div', 'wkn-grid')
var hint = el('div', 'wkn-swipehint')
var dots = el('span', 'wkn-dots')
hint.appendChild(dots)
hint.appendChild(document.createTextNode('wischen für mehr'))
var more = el('div', 'wkn-more')
var btn = el('button', 'wkn-btn', 'Ältere Neuigkeiten ansehen')
btn.type = 'button'
more.appendChild(btn)
board.appendChild(grid)
board.appendChild(hint)
board.appendChild(more)
root.appendChild(board)
state.ui = { grid: grid, dots: dots, more: more, btn: btn }
grid.addEventListener('scroll', updateDots, { passive: true })
btn.addEventListener('click', function () {
if (state.loading) return
state.loading = true
btn.disabled = true
fetchPage(state.page + 1).then(function (nd) {
state.page++
append(nd.posts)
setMore(!!nd.hasMore && nd.posts.length > 0)
}).catch(function () { btn.disabled = false }).then(function () { state.loading = false })
})
append(d.posts)
setMore(!!d.hasMore)
}
injectStyle()
fetchPage(1).then(function (d) {
if (d.posts.length > 0) build(d)
}).catch(function () { /* Fehler: statischer Leerzustand der Seite bleibt */ })
}
function run () {
var nodes = document.querySelectorAll('[data-webklar-news]')
for (var i = 0; i < nodes.length; i++) init(nodes[i])
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run)
else run()
})()
+6
View File
@@ -91,6 +91,12 @@ app.get('/api/health', async (_req, res) => {
})
})
// Einbau-Skript fuer Kundenwebsites: darf von fremden Seiten geladen werden, 5 Minuten Cache
app.use('/embed/', (_req, res, next) => {
res.set('Cache-Control', 'public, max-age=300')
res.set('Access-Control-Allow-Origin', '*')
next()
})
app.use(express.static(publicDir))
app.get('/dashboard.html', (req, res, next) => {
+204
View File
@@ -0,0 +1,204 @@
// Playwright-Checks Einbau-Skript public/embed/news.js: statischer Server fuer public/ + per page.route gemockte API.
// Nicht Teil von npm test. Aufruf: node test/ui/embed.ui.mjs
// Screenshots landen in /root/reports/funktionen-news-ui/embed-*.png.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/funktionen-news-ui'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const file = path.join(pub, url.pathname)
const data = await readFile(file)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(data)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const DEMO = `${base}/embed/news-demo.html`
// ---- Fixtures ----
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
const PAPERS = ['sun', 'sky', 'coral', 'white']
const KINDS = ['combo', 'note', 'photo', 'note', 'combo', 'note', 'photo', 'note']
function makePosts(n, offset = 0) {
return Array.from({ length: n }, (_, k) => {
const i = k + offset
const type = KINDS[i % KINDS.length]
return {
id: `p${i}`, type, title: i === 3 ? '' : `Beitrag ${i + 1}`, text: type === 'photo' ? '' : `Text zu Beitrag ${i + 1}. Frisch reingekommen, komm vorbei.`,
paper: PAPERS[i % 4], date: '2026-09-29',
image: type === 'note' ? null : { w: 1200, h: 900, src: `/api/public/news/abcdefghijklmnop/img/p${i}.webp?v=1`, srcSmall: `/api/public/news/abcdefghijklmnop/img/p${i}.webp?v=1&s=1` },
}
})
}
async function mockApi(page, { total = 0, status = 200, custom = null } = {}) {
await page.route('**/api/public/news/**', async (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes('/img/')) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG })
if (status !== 200) return route.fulfill({ status, contentType: 'application/json', body: '{"error":"down"}' })
if (custom) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ posts: custom, hasMore: false }) })
const pg = Number(url.searchParams.get('page') || 1)
const all = makePosts(total)
const posts = all.slice((pg - 1) * 6, pg * 6)
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ posts, hasMore: pg * 6 < total }) })
})
}
const browser = await chromium.launch()
const results = []
async function check(name, fn, { viewport = { width: 1280, height: 900 }, javaScriptEnabled = true } = {}) {
const ctx = await browser.newContext({ viewport, javaScriptEnabled })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
try {
await fn(page, errors)
results.push([name, true])
console.log(`ok ${name}`)
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message}`)
}
await ctx.close()
}
function assert(cond, msg) { if (!cond) throw new Error(msg) }
await check('leer_bleibt_leerzustand', async (page, errors) => {
await mockApi(page, { total: 0 })
await page.goto(DEMO)
await page.waitForLoadState('networkidle')
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
assert((await page.locator('.wkn-board').count()) === 0, 'Brett trotz 0 Posts')
assert((await page.locator('#wkn-style').count()) <= 1, 'Style mehrfach')
assert(errors.length === 0, errors.join())
})
await check('ein_beitrag_neu_schild', async (page) => {
await mockApi(page, { total: 1 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-pin').count()) === 1, 'nicht genau 1 Zettel')
assert((await page.locator('.wkn-new').count()) === 1, 'Neu-Schild fehlt')
assert((await page.locator('.wkn-new').innerText()) === 'Neu', 'Schild-Text')
assert((await page.locator('#static-empty').count()) === 0, 'Leerzustand blieb stehen')
assert((await page.locator('.wkn-more').isHidden()), 'Knopf trotz hasMore=false sichtbar')
assert((await page.locator('#wkn-style').count()) === 1, 'Style nicht genau einmal')
assert((await page.locator('.wkn-date').first().innerText()).toLowerCase() === '29. sep', 'Datum: ' + await page.locator('.wkn-date').first().innerText())
})
await check('acht_beitraege_aeltere', async (page) => {
await mockApi(page, { total: 8 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-pin').count()) === 6, '6 erwartet')
const btn = page.getByRole('button', { name: 'Ältere Neuigkeiten ansehen' })
assert(await btn.isVisible(), 'Knopf fehlt')
await btn.click()
await page.waitForFunction(() => document.querySelectorAll('.wkn-pin').length === 8)
assert(await btn.isHidden(), 'Knopf nicht verschwunden')
assert((await page.locator('.wkn-new').count()) === 1, 'Neu nur am neuesten')
})
await check('xss_als_text', async (page, errors) => {
const evil = '<img src=x onerror=window.__x=1>'
await mockApi(page, { custom: [{ id: 'x', type: 'note', title: evil, text: evil, paper: 'sun', date: '2026-09-29', image: null }] })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
await page.waitForTimeout(300)
assert((await page.locator('.wkn-pin h3').innerText()) === evil, 'Titel nicht als Text')
assert((await page.locator('.wkn-pin img').count()) === 0, 'img-Element eingeschleust')
assert((await page.evaluate(() => window.__x)) === undefined, 'window.__x gesetzt')
})
await check('api_down_kein_fehler', async (page, errors) => {
await mockApi(page, { status: 503 })
await page.goto(DEMO)
await page.waitForLoadState('networkidle')
await page.waitForTimeout(200)
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
assert(errors.length === 0, 'Exception: ' + errors.join())
})
await check('ohne_js', async (page) => {
await mockApi(page, { total: 3 })
await page.goto(DEMO)
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
}, { javaScriptEnabled: false })
await check('handy_wischreihe', async (page) => {
await mockApi(page, { total: 6 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
const info = await page.evaluate(() => {
const g = document.querySelector('.wkn-grid')
const cs = getComputedStyle(g)
return { snap: cs.scrollSnapType, ox: cs.overflowX, flex: cs.display, gw: g.scrollWidth, cw: g.clientWidth, doc: document.documentElement.scrollWidth, win: window.innerWidth, hint: getComputedStyle(document.querySelector('.wkn-swipehint')).display, dots: document.querySelectorAll('.wkn-dots i').length }
})
assert(/x\s+mandatory/.test(info.snap), 'kein Scroll-Snap: ' + info.snap)
assert(info.ox === 'auto' && info.flex === 'flex', 'keine Reihe')
assert(info.gw > info.cw, 'Reihe scrollt nicht')
assert(info.doc <= info.win, `Seite scrollt horizontal (${info.doc} > ${info.win})`)
assert(info.hint === 'flex' && info.dots === 6, 'Wisch-Hinweis/Punkte')
}, { viewport: { width: 390, height: 844 } })
await check('keine_cookies_storage', async (page) => {
await mockApi(page, { total: 8 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
const s = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length }))
assert(s.c === '' && s.l === 0 && s.s === 0, JSON.stringify(s))
})
await check('signatur_optional', async (page) => {
await mockApi(page, { total: 2 })
await page.route('**/embed/news-demo.html', async (route) => {
const r = await route.fetch()
await route.fulfill({ response: r, body: (await r.text()).replace('data-webklar-news="abcdefghijklmnop"', 'data-webklar-news="abcdefghijklmnop" data-signature="Opa Martin"') })
})
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-sig').count()) > 0, 'Signatur fehlt')
assert((await page.locator('.wkn-sig').first().innerText()) === 'Opa Martin', 'Signaturtext')
})
await check('ohne_titel_keine_ueberschrift', async (page) => {
await mockApi(page, { custom: [{ id: 'n', type: 'note', title: '', text: 'Nur Text', paper: 'sky', date: '2026-09-29', image: null }] })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-pin h3').count()) === 0, 'leere Ueberschrift')
assert((await page.locator('.wkn-sig').count()) === 0, 'Signatur ohne Attribut')
})
// Screenshots
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
for (const n of [1, 8]) {
const ctx = await browser.newContext({ viewport })
const page = await ctx.newPage()
await mockApi(page, { total: n })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
await page.waitForLoadState('networkidle')
await page.screenshot({ path: `${SHOTS}/embed-${label}-${n}.png`, fullPage: true })
await ctx.close()
}
}
await browser.close()
server.close()
const failed = results.filter(([, ok]) => !ok)
console.log(`${results.length - failed.length}/${results.length} Checks bestanden`)
process.exit(failed.length ? 1 : 0)