diff --git a/docs/funktionen/news-einbau.md b/docs/funktionen/news-einbau.md new file mode 100644 index 0000000..4437ecb --- /dev/null +++ b/docs/funktionen/news-einbau.md @@ -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 +
+ +
+``` + +- `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 ``) + +```html + +``` + +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 +
+

Hier hängt bald was Neues.

+

Neuigkeiten findest Du bald an dieser Pinnwand.

+
+``` + +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 ` + + +
Testseite der Kundenwebsite (intern, nicht verlinkt)
+
+

Neues aus dem Laden

+ +
+
+

Hier hängt bald was Neues.

+

Neuigkeiten findest du bald an dieser Pinnwand.

+
+
+
+ + + + diff --git a/public/embed/news.js b/public/embed/news.js new file mode 100644 index 0000000..0fa7cad --- /dev/null +++ b/public/embed/news.js @@ -0,0 +1,244 @@ +/*! WEBklar Neuigkeiten-Pinnwand: Einbau-Skript fuer Kundenwebsites. + *
statischer Leerzustand
+ * 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() +})() diff --git a/server/index.js b/server/index.js index a259219..bee591c 100644 --- a/server/index.js +++ b/server/index.js @@ -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) => { diff --git a/test/ui/embed.ui.mjs b/test/ui/embed.ui.mjs new file mode 100644 index 0000000..063a6bf --- /dev/null +++ b/test/ui/embed.ui.mjs @@ -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 = '' + 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)