Heatmap in Geraeterahmen (Computer/Tablet + Handy) und Keks statt "Datenschutz-Einstellungen"

Heatmap im Portal (Funktionen -> Analyse): die echte Website laeuft jetzt in realistischen
Geraeterahmen, gross MacBook oder iPad (Umschalter oben), daneben das iPhone, jeweils in echter
Breite (1512 / 820 / 402 px), verkleinert und im Geraet scrollbar, mit den Klicks der
passenden Geraeteklasse als Overlay. MacBook und iPhone stammen aus den Rahmen der neuen
Website (device-mockups), das iPad ist im gleichen Stil nachgebaut (public/geraete.css/.js).

- heatmap-link: embed: true haengt __wk_embed=1 an; w.js reicht das als data-embed an h.js.
- h.js eingebettet (nur in einem Rahmen): keine Leiste, Links und Formulare gesperrt (kein
  Seitenwechsel, also keine Zaehlung und kein Banner), Scrollleisten aus, Klickzahl per
  postMessage nur an die Herkunft von h.js (project.webklar.com).
- iframe mit Sandbox allow-scripts allow-same-origin (keine Formulare, Pop-ups, keine
  Navigation des Portals); Laden erst, wenn die Heatmap in Sicht kommt. Laesst sich eine
  Website nicht einbetten, kommt nach 20 s ein Hinweis mit "In neuem Tab oeffnen" (alter Weg).

Banner-Skript: der Einstellungsknopf nach der Wahl ist jetzt ein angeknabberter Keks unten
rechts (Inline-SVG, 44 px). Er dreht sich langsam, haelt beim Darueberfahren/Fokus an und
zeigt dann "Datenschutz-Einstellungen" (auch sein Name fuer Screenreader, passend zum
Bannertext); bei reduzierter Bewegung dreht er sich nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 15:09:17 +00:00
1 parent ff7fe35c06
commit 0a113c2983
14 files changed
+1658 -91

No files matched your search

+56 -25
View File
@@ -119,6 +119,8 @@ function escapeHtml(s) {
}
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
// data-embed="1" (nur in einem Rahmen): Ansicht im Geraeterahmen des Kundenportals. Dann ohne Leiste,
// Links und Formulare gesperrt, und die Klickzahl geht per postMessage nur an das Portal (Herkunft von h.js).
function main() {
const me = document.currentScript;
@@ -128,21 +130,13 @@ function main() {
if (!token || !base) return;
const de = document.documentElement;
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
let portal = '';
try { portal = new URL(base).origin; } catch (e) { /* ohne Portal keine Einbettung */ }
const embed = !!portal && me.getAttribute('data-embed') === '1' && window.parent && window.parent !== window;
function tell(state, count) {
if (!embed) return;
try { window.parent.postMessage({ wk: 'heat', state: state, count: count }, portal); } catch (e) { /* egal */ }
}
const canvas = document.createElement('canvas');
const cs = canvas.style;
@@ -162,6 +156,38 @@ function main() {
return g.getImageData(0, 0, 256, 1).data;
})();
if (embed) {
// Nur ansehen: kein Seitenwechsel (er wuerde zaehlen und das Banner zeigen)
const block = function (e) {
const t = e.target;
if (e.type === 'submit' || (t && t.closest && t.closest('a,area'))) { e.preventDefault(); e.stopPropagation(); }
};
window.addEventListener('click', block, true);
window.addEventListener('submit', block, true);
// Handy und Tablet haben ueberlagernde Scrollleisten; so stimmt die Breite wie auf dem Geraet
const st = document.createElement('style');
st.textContent = 'html{scrollbar-width:none}html::-webkit-scrollbar{display:none}';
(document.head || de).appendChild(st);
start(function (n) { tell('ready', n); }, function () { tell('error', 0); });
return;
}
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
function draw() {
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
@@ -202,17 +228,22 @@ function main() {
host.remove(); canvas.remove();
}
close.addEventListener('click', stop);
window.addEventListener('resize', draw);
start(function (n) { msg.textContent = 'Heatmap – ' + n + ' Klicks (letzte 30 Tage)'; },
function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
draw();
timer = setInterval(redrawIfChanged, 1000);
})
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
// Punkte laden, zeichnen und bei Groessenaenderung neu zeichnen
function start(onReady, onError) {
window.addEventListener('resize', draw);
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
draw();
timer = setInterval(redrawIfChanged, 1000);
onReady(points.length);
})
.catch(function () { onError(); });
}
}
try { main(); } catch (e) { /* nie die Seite stoeren */ }
+36 -2
View File
@@ -142,12 +142,16 @@ function main() {
const qs = new URLSearchParams(WIN.location.search);
const heatToken = qs.get('__wk_heat');
if (heatToken) {
// __wk_embed=1: Ansicht im Geraeterahmen des Kundenportals (h.js ohne Leiste, Links gesperrt)
const embed = qs.get('__wk_embed') === '1';
qs.delete('__wk_heat');
qs.delete('__wk_embed');
const rest = qs.toString();
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
const s = DOC.createElement('script');
s.setAttribute('src', base + 'h.js');
s.setAttribute('data-token', heatToken);
if (embed) s.setAttribute('data-embed', '1');
(DOC.head || DOC.documentElement).appendChild(s);
return;
}
@@ -377,14 +381,44 @@ function main() {
if (linkHostEl) linkHostEl.style.display = '';
}
// Einstellungsknopf: angeknabberter Keks unten rechts, dreht sich langsam (nicht bei reduzierter Bewegung).
// Beim Darueberfahren haelt er an und zeigt "Datenschutz-Einstellungen"; das ist auch sein Name fuer Screenreader.
// Inline-SVG statt data:-Grafik, damit eine strenge CSP der Website (img-src) ihn nicht blockiert.
const SETTINGS_CSS = ':host{all:initial}' +
'.b{position:relative;display:block;width:44px;height:44px;padding:2px;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.b:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'.c{display:block;width:40px;height:40px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35));animation:wk-spin 9s linear infinite}' +
'.b:hover .c,.b:focus-visible .c{animation-play-state:paused}' +
'.l{position:absolute;right:52px;top:50%;transform:translate(6px,-50%);padding:7px 12px;border-radius:999px;background:#fff;color:#222;box-shadow:0 2px 10px rgba(0,0,0,.18);font:600 13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}' +
'.b:hover .l,.b:focus-visible .l{opacity:1;transform:translate(0,-50%)}' +
'@keyframes wk-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.c{animation:none}.l{transition:none}}';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const n = DOC.createElementNS(SVG_NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function cookieSvg() {
const svg = svgEl('svg', { class: 'c', viewBox: '0 0 64 64', 'aria-hidden': 'true', focusable: 'false' });
// Umriss mit Bissspur oben rechts (zwei Bissboegen, dann der grosse Kreis zurueck)
svg.appendChild(svgEl('path', { d: 'M44.84 6A10 10 0 0 0 57.5 19.37A7.5 7.5 0 0 0 60.96 33.5A29 29 0 1 1 44.84 6Z', fill: '#dfa25b', stroke: '#a5652c', 'stroke-width': '3', 'stroke-linejoin': 'round' }));
const chips = [[19, 22, 4.2, 3.3, -20], [31, 14, 3.2, 2.6, 15], [33, 33, 4, 3.2, 30], [46, 41, 3.6, 2.8, -35], [21, 45, 4, 3, 10], [36, 52, 3, 2.4, -10], [11, 34, 2.6, 2.1, 40], [47, 31, 2.4, 2, 0]];
for (const c of chips) svg.appendChild(svgEl('ellipse', { cx: c[0], cy: c[1], rx: c[2], ry: c[3], transform: 'rotate(' + c[4] + ' ' + c[0] + ' ' + c[1] + ')', fill: '#5a3216' }));
const crumbs = [[26, 28, 1.1], [40, 22, 1], [28, 40, 1.2], [14, 26, 0.9], [40, 46, 1]];
for (const c of crumbs) svg.appendChild(svgEl('circle', { cx: c[0], cy: c[1], r: c[2], fill: '#f3c98c' }));
return svg;
}
function addSettingsLink() {
linkHostEl = DOC.createElement('div');
const st = linkHostEl.style;
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
hosts.push(linkHostEl);
const root = linkHostEl.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
const b = el('button', { type: 'button' }, SETTINGS_LABEL);
root.appendChild(el('style', {}, SETTINGS_CSS));
const b = el('button', { type: 'button', class: 'b', 'aria-label': SETTINGS_LABEL });
b.appendChild(el('span', { class: 'l', 'aria-hidden': 'true' }, SETTINGS_LABEL));
b.appendChild(cookieSvg());
b.addEventListener('click', safe(function () { openBanner(true); }));
root.appendChild(b);
DOC.body.appendChild(linkHostEl);
+56 -25
View File
@@ -1,4 +1,6 @@
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
// data-embed="1" (nur in einem Rahmen): Ansicht im Geraeterahmen des Kundenportals. Dann ohne Leiste,
// Links und Formulare gesperrt, und die Klickzahl geht per postMessage nur an das Portal (Herkunft von h.js).
function main() {
const me = document.currentScript;
@@ -8,21 +10,13 @@ function main() {
if (!token || !base) return;
const de = document.documentElement;
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
let portal = '';
try { portal = new URL(base).origin; } catch (e) { /* ohne Portal keine Einbettung */ }
const embed = !!portal && me.getAttribute('data-embed') === '1' && window.parent && window.parent !== window;
function tell(state, count) {
if (!embed) return;
try { window.parent.postMessage({ wk: 'heat', state: state, count: count }, portal); } catch (e) { /* egal */ }
}
const canvas = document.createElement('canvas');
const cs = canvas.style;
@@ -42,6 +36,38 @@ function main() {
return g.getImageData(0, 0, 256, 1).data;
})();
if (embed) {
// Nur ansehen: kein Seitenwechsel (er wuerde zaehlen und das Banner zeigen)
const block = function (e) {
const t = e.target;
if (e.type === 'submit' || (t && t.closest && t.closest('a,area'))) { e.preventDefault(); e.stopPropagation(); }
};
window.addEventListener('click', block, true);
window.addEventListener('submit', block, true);
// Handy und Tablet haben ueberlagernde Scrollleisten; so stimmt die Breite wie auf dem Geraet
const st = document.createElement('style');
st.textContent = 'html{scrollbar-width:none}html::-webkit-scrollbar{display:none}';
(document.head || de).appendChild(st);
start(function (n) { tell('ready', n); }, function () { tell('error', 0); });
return;
}
const host = document.createElement('div');
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
const bar = document.createElement('div');
bar.className = 'bar';
const msg = document.createElement('span');
msg.textContent = 'Heatmap wird geladen ...';
const close = document.createElement('button');
close.setAttribute('type', 'button');
close.textContent = 'Schließen';
bar.appendChild(msg); bar.appendChild(close);
root.appendChild(style); root.appendChild(bar);
document.body.appendChild(host);
function draw() {
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
@@ -82,17 +108,22 @@ function main() {
host.remove(); canvas.remove();
}
close.addEventListener('click', stop);
window.addEventListener('resize', draw);
start(function (n) { msg.textContent = 'Heatmap – ' + n + ' Klicks (letzte 30 Tage)'; },
function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
draw();
timer = setInterval(redrawIfChanged, 1000);
})
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
// Punkte laden, zeichnen und bei Groessenaenderung neu zeichnen
function start(onReady, onError) {
window.addEventListener('resize', draw);
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
.then(function (j) {
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
draw();
timer = setInterval(redrawIfChanged, 1000);
onReady(points.length);
})
.catch(function () { onError(); });
}
}
try { main(); } catch (e) { /* nie die Seite stoeren */ }
+36 -2
View File
@@ -23,12 +23,16 @@ function main() {
const qs = new URLSearchParams(WIN.location.search);
const heatToken = qs.get('__wk_heat');
if (heatToken) {
// __wk_embed=1: Ansicht im Geraeterahmen des Kundenportals (h.js ohne Leiste, Links gesperrt)
const embed = qs.get('__wk_embed') === '1';
qs.delete('__wk_heat');
qs.delete('__wk_embed');
const rest = qs.toString();
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
const s = DOC.createElement('script');
s.setAttribute('src', base + 'h.js');
s.setAttribute('data-token', heatToken);
if (embed) s.setAttribute('data-embed', '1');
(DOC.head || DOC.documentElement).appendChild(s);
return;
}
@@ -258,14 +262,44 @@ function main() {
if (linkHostEl) linkHostEl.style.display = '';
}
// Einstellungsknopf: angeknabberter Keks unten rechts, dreht sich langsam (nicht bei reduzierter Bewegung).
// Beim Darueberfahren haelt er an und zeigt "Datenschutz-Einstellungen"; das ist auch sein Name fuer Screenreader.
// Inline-SVG statt data:-Grafik, damit eine strenge CSP der Website (img-src) ihn nicht blockiert.
const SETTINGS_CSS = ':host{all:initial}' +
'.b{position:relative;display:block;width:44px;height:44px;padding:2px;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.b:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'.c{display:block;width:40px;height:40px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35));animation:wk-spin 9s linear infinite}' +
'.b:hover .c,.b:focus-visible .c{animation-play-state:paused}' +
'.l{position:absolute;right:52px;top:50%;transform:translate(6px,-50%);padding:7px 12px;border-radius:999px;background:#fff;color:#222;box-shadow:0 2px 10px rgba(0,0,0,.18);font:600 13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}' +
'.b:hover .l,.b:focus-visible .l{opacity:1;transform:translate(0,-50%)}' +
'@keyframes wk-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.c{animation:none}.l{transition:none}}';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const n = DOC.createElementNS(SVG_NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function cookieSvg() {
const svg = svgEl('svg', { class: 'c', viewBox: '0 0 64 64', 'aria-hidden': 'true', focusable: 'false' });
// Umriss mit Bissspur oben rechts (zwei Bissboegen, dann der grosse Kreis zurueck)
svg.appendChild(svgEl('path', { d: 'M44.84 6A10 10 0 0 0 57.5 19.37A7.5 7.5 0 0 0 60.96 33.5A29 29 0 1 1 44.84 6Z', fill: '#dfa25b', stroke: '#a5652c', 'stroke-width': '3', 'stroke-linejoin': 'round' }));
const chips = [[19, 22, 4.2, 3.3, -20], [31, 14, 3.2, 2.6, 15], [33, 33, 4, 3.2, 30], [46, 41, 3.6, 2.8, -35], [21, 45, 4, 3, 10], [36, 52, 3, 2.4, -10], [11, 34, 2.6, 2.1, 40], [47, 31, 2.4, 2, 0]];
for (const c of chips) svg.appendChild(svgEl('ellipse', { cx: c[0], cy: c[1], rx: c[2], ry: c[3], transform: 'rotate(' + c[4] + ' ' + c[0] + ' ' + c[1] + ')', fill: '#5a3216' }));
const crumbs = [[26, 28, 1.1], [40, 22, 1], [28, 40, 1.2], [14, 26, 0.9], [40, 46, 1]];
for (const c of crumbs) svg.appendChild(svgEl('circle', { cx: c[0], cy: c[1], r: c[2], fill: '#f3c98c' }));
return svg;
}
function addSettingsLink() {
linkHostEl = DOC.createElement('div');
const st = linkHostEl.style;
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
hosts.push(linkHostEl);
const root = linkHostEl.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
const b = el('button', { type: 'button' }, SETTINGS_LABEL);
root.appendChild(el('style', {}, SETTINGS_CSS));
const b = el('button', { type: 'button', class: 'b', 'aria-label': SETTINGS_LABEL });
b.appendChild(el('span', { class: 'l', 'aria-hidden': 'true' }, SETTINGS_LABEL));
b.appendChild(cookieSvg());
b.addEventListener('click', safe(function () { openBanner(true); }));
root.appendChild(b);
DOC.body.appendChild(linkHostEl);
+50 -9
View File
@@ -168,6 +168,7 @@ function fire(node, type, ev) { for (const f of [...(node.listeners[type] || [])
function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if (n.shadow) walk(n.shadow, fn); }
function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
const settingsBtn = (h) => findAll(h.body, (n) => n.tagName === 'BUTTON' && n.attrs['aria-label'] === SETTINGS_LABEL)[0];
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) {
const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000;
@@ -183,6 +184,7 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
currentScript: script, get referrer() { reads.referrer++; return 'https://www.google.com/'; }, visibilityState: 'visible',
documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
createElement(t) { const e = makeEl(t); if (bannerHeight) e.offsetHeight = bannerHeight; return e; }, activeElement: null,
createElementNS(ns, t) { const e = makeEl(t); e.ns = ns; return e; },
querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
addEventListener(t, f) { (docL[t] ||= []).push(f); },
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
@@ -303,7 +305,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
assert.equal(en[0].d.scroll, 55); // (300+800)/2000
assert.equal(typeof en[0].d.sec, 'number');
// Widerruf ueber Einstellungs-Link
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
const settings = settingsBtn(h);
assert.ok(settings, 'fester Link');
settings.click();
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen');
@@ -355,7 +357,7 @@ test('w.js: gespeichertes Ablehnen => nichts senden, nur Einstellungsknopf; spae
assert.equal(h.winL.popstate, undefined);
assert.equal(h.history.pushState, h.origPush);
assert.equal(h.body.children.length, 1);
const open = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
const open = settingsBtn(h);
assert.ok(open);
open.click();
assert.equal(h.sent.length, 0);
@@ -411,9 +413,9 @@ test('w.js: Annehmen auf laufender Seite => bn:accept, genau ein cx mit ref+vp;
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(h.sent.at(-1).d, { t: 'cx', site: SITE, path: '/start', ref: refHost('https://www.google.com/', 'mein.de'), vp: 'd' });
// Widerruf, erneut annehmen
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
settingsBtn(h).click();
btn(hostEl(h).shadow, 'Ablehnen').click();
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
settingsBtn(h).click();
btn(hostEl(h).shadow, 'Annehmen').click();
assert.equal(h.sent.filter((s) => s.d.t === 'cx').length, 1);
});
@@ -443,15 +445,41 @@ test('w.js: T19 Platz unten solange Banner offen; Knopf mindestens 24px', () =>
assert.equal(h.body.style.paddingBottom, '5px');
const g = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
const css = findAll(g.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
assert.match(css, /min-width:24px/);
assert.match(css, /min-height:32px/);
assert.match(css, /padding:8px 12px/);
assert.match(css, /font-size:13px/);
assert.match(css, /width:44px;height:44px/);
});
test('w.js: Einstellungsknopf ist ein angeknabberter Keks, der sich dreht (ohne Drehung bei reduzierter Bewegung)', () => {
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
const b = settingsBtn(h);
assert.ok(b, 'Knopf da');
assert.equal(b.attrs.type, 'button');
assert.equal(b.textContent, '', 'kein sichtbarer Text im Knopf selbst');
// Etikett "Datenschutz-Einstellungen" erscheint beim Darueberfahren, fuer Screenreader zaehlt aria-label
const label = findAll(b, (n) => n.textContent === SETTINGS_LABEL)[0];
assert.ok(label);
assert.equal(label.attrs['aria-hidden'], 'true');
// Keks als Inline-SVG (keine data:-Grafik, die eine CSP der Website blockieren koennte)
const svg = findAll(b, (n) => n.tagName === 'SVG')[0];
assert.ok(svg);
assert.equal(svg.ns, 'http://www.w3.org/2000/svg');
assert.equal(svg.attrs['aria-hidden'], 'true');
const paths = findAll(svg, (n) => n.tagName === 'PATH');
assert.ok(paths.length >= 1, 'Umriss mit Bissspur');
assert.match(paths[0].attrs.d, /A/, 'Bogen fuer den Biss');
assert.ok(findAll(svg, (n) => n.tagName === 'ELLIPSE' || n.tagName === 'CIRCLE').length >= 5, 'Schokostueckchen');
const css = findAll(h.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
assert.match(css, /@keyframes wk-spin\{to\{transform:rotate\(360deg\)\}\}/);
assert.match(css, /animation:wk-spin \d+s linear infinite/);
assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{[^}]*animation:none/);
assert.match(css, /:hover \.c,[^{]*:focus-visible \.c\{animation-play-state:paused\}/);
assert.ok(!/url\(/.test(css), 'keine externen oder data:-Grafiken');
b.click();
assert.ok(hostEl(h), 'Klick oeffnet das Banner');
});
test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }, withConsentLink: true });
assert.equal(findAll(h.body, (x) => x.textContent === SETTINGS_LABEL).length, 0);
assert.equal(settingsBtn(h), undefined);
assert.equal(hostEl(h), undefined);
h.link.click();
assert.ok(hostEl(h));
@@ -465,9 +493,22 @@ test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () =>
assert.equal(h.injected.length, 1);
assert.equal(h.injected[0].attrs.src, 'https://project.webklar.com/a/h.js');
assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig');
assert.equal(h.injected[0].attrs['data-embed'], undefined);
assert.equal((h.docL.click || []).length, 0);
});
test('w.js: Heatmap-Modus mit __wk_embed=1 entfernt beide Parameter und reicht data-embed an h.js', () => {
const h = harness({ search: '?__wk_heat=TOKEN.sig&__wk_embed=1&b=2' });
assert.deepEqual(h.sent, []);
assert.equal(h.history.replaced, '/start?b=2');
assert.equal(h.injected.length, 1);
assert.equal(h.injected[0].attrs['data-embed'], '1');
assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig');
// __wk_embed ohne Heatmap-Token: normaler Betrieb (Banner, pv)
const n = harness({ search: '?__wk_embed=1' });
assert.deepEqual(n.types(), ['pv', 'bn:shown']);
});
test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
const h = harness();
h.nav('/start');
+129
View File
@@ -0,0 +1,129 @@
// Verhalten von dist/h.js (Heatmap-Overlay) im Fake-DOM: eigenes Fenster und Einbettung im Portal.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const H = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist', 'h.js'), 'utf8');
function makeEl(tag) {
const e = {
tagName: String(tag).toUpperCase(), nodeType: 1, attrs: {}, children: [], listeners: {}, style: { setProperty() {} }, parentNode: null,
textContent: '', removed: false,
setAttribute(n, v) { this.attrs[n] = String(v); },
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
addEventListener(t, f, o) { (this.listeners[t] ||= []).push({ f, o }); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x.f !== f); },
remove() { this.removed = true; },
attachShadow() { const r = makeEl('shadow'); this.shadow = r; return r; },
closest(sel) {
const tags = sel.split(',').map((t) => t.trim().toUpperCase());
for (let n = this; n && n.tagName; n = n.parentNode) if (tags.includes(n.tagName)) return n;
return null;
},
getContext() {
return {
createLinearGradient: () => ({ addColorStop() {} }),
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {}, clearRect() {},
getImageData: (x, y, w, hh) => ({ data: new Uint8ClampedArray(Math.max(4, w * hh * 4)) }),
putImageData() {},
};
},
};
return e;
}
async function harness({ embed = false, framed = true, fail = false, points = [{ x: 0.5, y: 0.5 }, { x: 0.1, y: 0.2 }, { x: 2, y: 0 }] } = {}) {
const body = makeEl('body'); const head = makeEl('head'); const html = makeEl('html');
Object.assign(html, { scrollWidth: 402, scrollHeight: 2000, clientWidth: 402, clientHeight: 800 });
const script = makeEl('script');
script.setAttribute('src', 'https://project.webklar.com/a/h.js');
script.setAttribute('data-token', 'TOK.sig');
if (embed) script.setAttribute('data-embed', '1');
script.src = 'https://project.webklar.com/a/h.js';
const fetches = []; const posted = []; const winL = {};
const document = {
currentScript: script, documentElement: html, body, head,
createElement: (t) => makeEl(t),
};
const win = {
document,
addEventListener(t, f, o) { (winL[t] ||= []).push({ f, o }); },
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x.f !== f); },
};
const parent = framed ? { postMessage(data, origin) { posted.push({ data, origin }); } } : win;
win.parent = parent;
const sandbox = {
window: win, document, URL, JSON, Math, String, Number, Array, Object, RegExp, Error, Uint8ClampedArray, Promise,
setInterval: () => 1, clearInterval() {},
fetch: (u, o) => {
fetches.push({ u, o });
if (fail) return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ points }) });
},
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(H, sandbox);
await new Promise((r) => setTimeout(r, 10));
const fire = (type, target) => {
const ev = { type, target, prevented: false, stopped: false, preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; } };
for (const { f } of winL[type] || []) f(ev);
return ev;
};
const shadowHosts = body.children.filter((c) => c.shadow);
return { body, head, fetches, posted, winL, fire, shadowHosts };
}
const linkIn = () => { const a = makeEl('a'); a.setAttribute('href', '/kontakt'); const span = makeEl('span'); span.parentNode = a; return span; };
test('h.js eigenes Fenster: schwarze Leiste mit Klickzahl, keine Nachricht, Links bleiben klickbar', async () => {
const h = await harness();
assert.equal(h.fetches.length, 1);
assert.equal(h.fetches[0].u, 'https://project.webklar.com/a/heat?token=TOK.sig');
assert.equal(h.fetches[0].o.credentials, 'omit');
assert.equal(h.shadowHosts.length, 1, 'Leiste');
const msg = h.shadowHosts[0].shadow.children.find((c) => c.className === 'bar').children[0];
assert.equal(msg.textContent, 'Heatmap – 2 Klicks (letzte 30 Tage)');
assert.deepEqual(h.posted, []);
assert.equal(h.fire('click', linkIn()).prevented, false);
});
test('h.js eingebettet: keine Leiste, meldet Klickzahl nur an project.webklar.com', async () => {
const h = await harness({ embed: true });
assert.equal(h.shadowHosts.length, 0, 'keine Leiste');
assert.ok(h.body.children.some((c) => c.tagName === 'CANVAS'), 'Overlay da');
assert.deepEqual(JSON.parse(JSON.stringify(h.posted)), [{ data: { wk: 'heat', state: 'ready', count: 2 }, origin: 'https://project.webklar.com' }]);
// Scrollleisten ausgeblendet (Handy/Tablet haben ueberlagernde Leisten)
assert.ok(h.head.children.some((c) => c.tagName === 'STYLE' && /scrollbar-width:none/.test(c.textContent)));
});
test('h.js eingebettet: Links und Formulare gesperrt, andere Klicks nicht', async () => {
const h = await harness({ embed: true });
assert.ok((h.winL.click || []).some((l) => l.o === true), 'Klick-Sperre in der Capture-Phase');
const ev = h.fire('click', linkIn());
assert.equal(ev.prevented, true);
assert.equal(ev.stopped, true, 'Router der Seite bekommt den Klick nicht');
const area = h.fire('click', makeEl('area'));
assert.equal(area.prevented, true);
const other = h.fire('click', makeEl('button'));
assert.equal(other.prevented, false);
const sub = h.fire('submit', makeEl('form'));
assert.equal(sub.prevented, true);
});
test('h.js eingebettet: Fehler beim Laden => state error', async () => {
const h = await harness({ embed: true, fail: true });
assert.deepEqual(JSON.parse(JSON.stringify(h.posted)), [{ data: { wk: 'heat', state: 'error', count: 0 }, origin: 'https://project.webklar.com' }]);
});
test('h.js mit data-embed, aber nicht in einem Rahmen: verhaelt sich wie das eigene Fenster', async () => {
const h = await harness({ embed: true, framed: false });
assert.equal(h.shadowHosts.length, 1);
assert.deepEqual(h.posted, []);
assert.equal(h.fire('click', linkIn()).prevented, false);
});