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:
1 parent
ff7fe35c06
commit
0a113c2983
14 files changed
+1642
-75
No files matched your search
Vendored
+49
-18
@@ -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'; });
|
||||
|
||||
// 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; });
|
||||
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
|
||||
draw();
|
||||
timer = setInterval(redrawIfChanged, 1000);
|
||||
onReady(points.length);
|
||||
})
|
||||
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
|
||||
.catch(function () { onError(); });
|
||||
}
|
||||
}
|
||||
|
||||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||||
|
||||
Vendored
+36
-2
@@ -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);
|
||||
|
||||
+49
-18
@@ -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'; });
|
||||
|
||||
// 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; });
|
||||
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
|
||||
draw();
|
||||
timer = setInterval(redrawIfChanged, 1000);
|
||||
onReady(points.length);
|
||||
})
|
||||
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
|
||||
.catch(function () { onError(); });
|
||||
}
|
||||
}
|
||||
|
||||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||||
+36
-2
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -188,6 +188,22 @@
|
||||
.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; }
|
||||
.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; }
|
||||
.an-heat-row select option { color: #111; }
|
||||
/* Heatmap-Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */
|
||||
.an-heat .hs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.an-heat .hs-head h3 { margin: 0; }
|
||||
.hs-switch { display: flex; gap: 8px; }
|
||||
.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; }
|
||||
.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; }
|
||||
.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.hs-frame { width: 100%; }
|
||||
.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--kb-muted); text-align: center; }
|
||||
.hs-dev .dm-canvas { background: #fff; }
|
||||
.hs-site { position: absolute; left: 0; background: #fff; }
|
||||
.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
@media (max-width: 640px) {
|
||||
.hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||
.hs-dev--phone .hs-frame { width: 62%; }
|
||||
}
|
||||
.an-off { margin-top: 16px; }
|
||||
.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
|
||||
+147
-21
@@ -4,6 +4,7 @@
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
|
||||
import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js'
|
||||
|
||||
const PRICE = '30 €'
|
||||
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||||
@@ -726,40 +727,165 @@ async function renderAnalyseStats(stage, project, reload) {
|
||||
renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || [])
|
||||
}
|
||||
|
||||
/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks.
|
||||
Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl
|
||||
per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */
|
||||
const heatSel = { big: 'd' }
|
||||
const HEAT_TIMEOUT_MS = 20000
|
||||
|
||||
let geraeteCss = null
|
||||
function loadGeraeteCss() {
|
||||
if (!geraeteCss) {
|
||||
geraeteCss = new Promise((resolve) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = '/geraete.css'
|
||||
link.onload = link.onerror = () => resolve()
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
return geraeteCss
|
||||
}
|
||||
|
||||
// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler
|
||||
let heatListening = false
|
||||
function onHeatMessage(ev) {
|
||||
const d = ev.data
|
||||
if (!d || d.wk !== 'heat') return
|
||||
for (const fig of document.querySelectorAll('.hs-dev')) {
|
||||
const frame = fig.querySelector('iframe')
|
||||
if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue
|
||||
clearTimeout(fig.heatTimer)
|
||||
fig.dataset.state = d.state === 'ready' ? 'ready' : 'error'
|
||||
setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n)))
|
||||
}
|
||||
}
|
||||
|
||||
function setHeatCaption(fig, text) {
|
||||
const cap = fig.querySelector('.hs-cap')
|
||||
if (cap) cap.textContent = text
|
||||
}
|
||||
|
||||
// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website
|
||||
function fitHeatDevice(fig) {
|
||||
fig.heatResize?.disconnect()
|
||||
const canvas = fig.querySelector('.dm-canvas')
|
||||
const host = canvas?.parentElement
|
||||
if (!host) return
|
||||
const fit = (width) => {
|
||||
if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6))
|
||||
}
|
||||
fit(parseFloat(getComputedStyle(host).width) || host.clientWidth)
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width))
|
||||
fig.heatResize.observe(host)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHeatDevice(fig, projectId, vp, path) {
|
||||
const run = (fig.heatRun = (fig.heatRun || 0) + 1)
|
||||
clearTimeout(fig.heatTimer)
|
||||
fig.heatResize?.disconnect()
|
||||
fig.dataset.state = 'loading'
|
||||
fig.innerHTML = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
|
||||
let url
|
||||
try {
|
||||
;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } }))
|
||||
} catch (err) {
|
||||
if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
|
||||
return
|
||||
}
|
||||
await loadGeraeteCss()
|
||||
if (run !== fig.heatRun || !fig.isConnected) return
|
||||
if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error'))
|
||||
fig.dataset.origin = new URL(url).origin
|
||||
fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` })
|
||||
fitHeatDevice(fig)
|
||||
// Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden
|
||||
let loads = 0
|
||||
fig.querySelector('iframe').addEventListener('load', () => {
|
||||
if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path)
|
||||
})
|
||||
fig.heatTimer = setTimeout(() => {
|
||||
if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path)
|
||||
}, HEAT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher
|
||||
async function showHeatFallback(fig, projectId, vp, path) {
|
||||
fig.dataset.state = 'timeout'
|
||||
const cap = fig.querySelector('.hs-cap')
|
||||
if (!cap) return
|
||||
cap.textContent = heatCaption('timeout')
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } })
|
||||
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
|
||||
if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
|
||||
} catch {
|
||||
/* Text reicht */
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnalyseHeat(box, projectId, pages) {
|
||||
if (!box) return
|
||||
if (!pages.length) {
|
||||
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
|
||||
return
|
||||
}
|
||||
if (!heatListening) {
|
||||
window.addEventListener('message', onHeatMessage)
|
||||
heatListening = true
|
||||
}
|
||||
const big = heatSel.big === 't' ? 't' : 'd'
|
||||
box.innerHTML = `
|
||||
<div class="hs-head">
|
||||
<h3>Heatmap</h3>
|
||||
<p>Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.</p>
|
||||
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
|
||||
</div>
|
||||
<p>So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.</p>
|
||||
<div class="an-heat-row">
|
||||
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
|
||||
<label>Gerät<select id="an-heat-vp">${Object.entries(DEVICE_LABELS).map(([k, v]) => `<option value="${k}"${k === 'm' ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
|
||||
</div>
|
||||
<p class="error hidden" id="an-heat-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-heat-go">Auf meiner Website ansehen</button></div>
|
||||
<div class="abo-actions" id="an-heat-link" aria-live="polite"></div>`
|
||||
const go = box.querySelector('#an-heat-go')
|
||||
const error = box.querySelector('#an-heat-error')
|
||||
go.addEventListener('click', async () => {
|
||||
go.disabled = true
|
||||
error.classList.add('hidden')
|
||||
box.querySelector('#an-heat-link').innerHTML = ''
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path: box.querySelector('#an-heat-page').value || '/', vp: box.querySelector('#an-heat-vp').value } })
|
||||
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
|
||||
const link = box.querySelector('#an-heat-link')
|
||||
link.innerHTML = safeUrl(url) ? `<a class="abo-btn" href="${esc(url)}" target="_blank" rel="noopener">Heatmap öffnen</a>` : ''
|
||||
} catch (err) {
|
||||
error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message
|
||||
error.classList.remove('hidden')
|
||||
} finally {
|
||||
go.disabled = false
|
||||
<div class="hs-stage" data-big="${big}">
|
||||
<figure class="hs-dev hs-dev--big"></figure>
|
||||
<figure class="hs-dev hs-dev--phone"></figure>
|
||||
</div>`
|
||||
const stage = box.querySelector('.hs-stage')
|
||||
const bigFig = box.querySelector('.hs-dev--big')
|
||||
const phoneFig = box.querySelector('.hs-dev--phone')
|
||||
const page = () => box.querySelector('#an-heat-page').value || '/'
|
||||
const show = (fig, vp) => {
|
||||
fig.heatReloads = 0
|
||||
loadHeatDevice(fig, projectId, vp, page())
|
||||
}
|
||||
box.querySelector('#an-heat-page').addEventListener('change', () => {
|
||||
show(bigFig, heatSel.big)
|
||||
show(phoneFig, 'm')
|
||||
})
|
||||
box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => {
|
||||
if (b.dataset.vp === heatSel.big) return
|
||||
heatSel.big = b.dataset.vp
|
||||
box.querySelectorAll('.hs-switch button').forEach((x) => {
|
||||
const on = x.dataset.vp === heatSel.big
|
||||
x.classList.toggle('active', on)
|
||||
x.setAttribute('aria-pressed', String(on))
|
||||
})
|
||||
stage.dataset.big = heatSel.big
|
||||
show(bigFig, heatSel.big)
|
||||
}))
|
||||
heatSel.big = big
|
||||
// Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite)
|
||||
const start = () => {
|
||||
show(bigFig, heatSel.big)
|
||||
show(phoneFig, 'm')
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'function') return start()
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return
|
||||
io.disconnect()
|
||||
start()
|
||||
}, { rootMargin: '300px 0px' })
|
||||
io.observe(stage)
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
|
||||
@@ -0,0 +1,931 @@
|
||||
/* Geraeterahmen fuer die Heatmap (geraete.js). MacBook und iPhone: device-frames.css aus KNSONWS/homepage
|
||||
(device-mockups, unveraendert uebernommen), iPad: im gleichen Stil nachgebaut (Abschnitt ganz unten). */
|
||||
|
||||
/* ==========================================================================
|
||||
device-frames.css – realistische, fluide Geräterahmen
|
||||
MacBook Pro 14" (ab 2021, mit Notch) · iPhone 16 Pro / 17 Pro
|
||||
|
||||
Eine Breite skaliert alles:
|
||||
.dm-macbook { width: 760px } .dm-iphone { width: 220px }
|
||||
Die Rahmengeometrie steht in cqw des Wurzelelements (container-type).
|
||||
Die Bildschirm-Chrome (Menüleiste, Safari, Statusleiste) ist in
|
||||
Design-Punkten gezeichnet: --dm-u = 1 pt des echten Bildschirms.
|
||||
Seiteninhalt gehört in .dm-canvas (feste Designgröße, wird per
|
||||
device-frames.js bzw. React-Hook mit transform: scale() eingepasst):
|
||||
MacBook-Webviewport 1512 x 893, iPhone-Bildschirm 402 x 874.
|
||||
|
||||
Maße (echte Geräte):
|
||||
MacBook Unterteil = Wurzelbreite = 114 % des Deckels (L = 87,72cqw);
|
||||
Alurand 0,35 % L, schwarzes Glas 1,4 % L (Seiten/oben),
|
||||
Kinn 3,1 % L, Deckelecke 2,0 % L, Panelecke 0,7 % S,
|
||||
Display 1512 x 982 pt, Notch 188 x 37 pt.
|
||||
iPhone Gehäuse 71,5 x 149,6 mm, Display 402 x 874 pt,
|
||||
Titanband 1,35 % + schwarzer Rand 2,05 % der Gehäusebreite,
|
||||
konzentrische Radien, Dynamic Island 126 x 37 pt @ 11 pt.
|
||||
========================================================================== */
|
||||
|
||||
/* Wurzeln mit Spezifität 0: Klassen der Seite (Tailwind usw.) gewinnen */
|
||||
:where(.dm-macbook, .dm-iphone) {
|
||||
position: relative;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
isolation: isolate;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
:where(.dm-macbook) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* box-sizing nur für die eigenen dm-Elemente, nicht für den Inhalt */
|
||||
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"]),
|
||||
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"])::before,
|
||||
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"])::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Systemschrift nur für die Chrome-Ebenen */
|
||||
.dm-macbook__menubar,
|
||||
.dm-macbook__toolbar,
|
||||
.dm-iphone__statusbar {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
text-rendering: geometricPrecision; /* kein Hinting-Zittern bei kleinen Größen */
|
||||
}
|
||||
:where(.dm-macbook__menubar, .dm-macbook__toolbar, .dm-iphone__statusbar) svg {
|
||||
display: block;
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Inhalts-Canvas (gemeinsam)
|
||||
-------------------------------------------------------------------------- */
|
||||
.dm-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(var(--dm-dw) * 1px);
|
||||
height: calc(var(--dm-dh) * 1px);
|
||||
transform-origin: 0 0;
|
||||
/* JS setzt --dm-k; ohne JS rechnet CSS Breite / Designbreite */
|
||||
transform: scale(var(--dm-k, tan(atan2(100cqw, calc(var(--dm-dw) * 1px)))));
|
||||
overflow: hidden;
|
||||
container-type: inline-size; /* cqw im Inhalt = Design-Pixel */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
/* Gerät als Bild (role="img"): Inhalt ist Dekoration, nicht bedienbar
|
||||
(zusätzlich zum inert-Attribut der Canvas) */
|
||||
:where(.dm-macbook, .dm-iphone)[role="img"] .dm-canvas {
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
MacBook Pro 14"
|
||||
========================================================================== */
|
||||
.dm-macbook {
|
||||
/* Silber (Standard) */
|
||||
--dm-al-hi: #f7f8f9;
|
||||
--dm-al-1: #e0e1e4;
|
||||
--dm-al-2: #cbcdd1;
|
||||
--dm-al-3: #b1b4b9;
|
||||
--dm-al-4: #8f9298;
|
||||
--dm-al-5: #6d7076;
|
||||
--dm-al-6: #4b4d52;
|
||||
--dm-al-spec: rgba(255, 255, 255, .85);
|
||||
--dm-al-sheen: rgba(255, 255, 255, .5);
|
||||
--dm-al-edge: rgba(40, 42, 46, .5);
|
||||
--dm-hinge-1: #08080a;
|
||||
--dm-hinge-2: #2b2c2f;
|
||||
--dm-hinge-3: #46484c;
|
||||
--dm-foot: #2a2b2e;
|
||||
--dm-bezel: #060607;
|
||||
}
|
||||
.dm-macbook[data-color="space-black"] {
|
||||
--dm-al-hi: #8a8c91;
|
||||
--dm-al-1: #56585c;
|
||||
--dm-al-2: #424448;
|
||||
--dm-al-3: #333539;
|
||||
--dm-al-4: #26272a;
|
||||
--dm-al-5: #1b1c1e;
|
||||
--dm-al-6: #111213;
|
||||
--dm-al-spec: rgba(255, 255, 255, .3);
|
||||
--dm-al-sheen: rgba(255, 255, 255, .16);
|
||||
--dm-al-edge: rgba(0, 0, 0, .6);
|
||||
--dm-hinge-1: #050506;
|
||||
--dm-hinge-2: #1e1f21;
|
||||
--dm-hinge-3: #34363a;
|
||||
--dm-foot: #0b0b0c;
|
||||
}
|
||||
|
||||
/* ---- Deckel: Aluschale, nur als haarfeiner Rand sichtbar ---- */
|
||||
.dm-macbook__lid {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 87.72cqw;
|
||||
padding: .307cqw; /* Rand 0,35 % L */
|
||||
border-radius: 1.754cqw 1.754cqw .26cqw .26cqw; /* 2,0 % L oben, unten fast eckig */
|
||||
background:
|
||||
/* Glanzstreifen und Kanten, die vom Licht wegdrehen */
|
||||
linear-gradient(90deg,
|
||||
rgba(0,0,0,.16) 0%, rgba(0,0,0,0) 9%,
|
||||
var(--dm-al-sheen) 30%, rgba(255,255,255,0) 46%,
|
||||
rgba(255,255,255,0) 64%, rgba(255,255,255,.22) 78%,
|
||||
rgba(0,0,0,0) 90%, rgba(0,0,0,.2) 100%),
|
||||
linear-gradient(180deg,
|
||||
var(--dm-al-hi) 0%, var(--dm-al-1) 3%, var(--dm-al-2) 18%, var(--dm-al-1) 34%,
|
||||
var(--dm-al-3) 55%, var(--dm-al-2) 72%, var(--dm-al-3) 90%, var(--dm-al-4) 100%);
|
||||
box-shadow:
|
||||
inset 0 max(.5px, .06cqw) 0 var(--dm-al-spec), /* Glanzkante oben */
|
||||
inset .19cqw 0 .16cqw rgba(0, 0, 0, .12), /* Seiten dunkler */
|
||||
inset -.19cqw 0 .16cqw rgba(0, 0, 0, .2),
|
||||
inset 0 -.26cqw .18cqw rgba(0, 0, 0, .28), /* Unterkante im Schatten */
|
||||
0 0 0 max(.5px, .04cqw) var(--dm-al-edge); /* Außenkante */
|
||||
}
|
||||
/* Glanzlichter, die um den Rand wandern (ringförmig maskiert) */
|
||||
.dm-macbook__lid::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: .307cqw;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: conic-gradient(from 0deg at 50% 50%,
|
||||
rgba(255,255,255,.35) 0deg, rgba(255,255,255,0) 16deg,
|
||||
rgba(255,255,255,.4) 40deg, rgba(255,255,255,0) 52deg,
|
||||
rgba(0,0,0,.1) 70deg, rgba(255,255,255,.18) 92deg, rgba(0,0,0,.12) 118deg,
|
||||
rgba(0,0,0,.2) 160deg, rgba(0,0,0,.2) 200deg,
|
||||
rgba(0,0,0,.06) 236deg, rgba(255,255,255,.28) 262deg, rgba(255,255,255,0) 286deg,
|
||||
rgba(255,255,255,.55) 318deg, rgba(255,255,255,.08) 336deg,
|
||||
rgba(255,255,255,.35) 360deg);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
}
|
||||
|
||||
/* ---- schwarzes Glas mit Rändern ---- */
|
||||
.dm-macbook__glass {
|
||||
position: relative;
|
||||
padding: 1.228cqw 1.228cqw 2.719cqw; /* 1,4 % L Seiten/oben, 3,1 % L Kinn */
|
||||
border-radius: 1.447cqw 1.447cqw .1cqw .1cqw;
|
||||
background: var(--dm-bezel);
|
||||
box-shadow:
|
||||
inset 0 0 0 max(.5px, .05cqw) rgba(0, 0, 0, .9),
|
||||
inset 0 0 0 max(1px, .1cqw) rgba(255, 255, 255, .06); /* Glaskante */
|
||||
}
|
||||
/* sehr schwache Spiegelung über dem ganzen Glas (stört nie die Lesbarkeit) */
|
||||
.dm-macbook__glass::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(122deg,
|
||||
rgba(255,255,255,.055) 0%,
|
||||
rgba(255,255,255,.028) 24%,
|
||||
rgba(255,255,255,.008) 46%,
|
||||
rgba(255,255,255,0) 60%);
|
||||
}
|
||||
|
||||
/* ---- Display 1512 x 982 pt ---- */
|
||||
.dm-macbook__screen {
|
||||
--dm-u: calc(100cqw / 1512); /* 1 macOS-Punkt */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1512 / 982;
|
||||
overflow: hidden;
|
||||
border-radius: .593cqw .593cqw 0 0; /* 0,7 % S, nur oben */
|
||||
container-type: inline-size;
|
||||
background: /* Schreibtisch-Hintergrundbild */
|
||||
radial-gradient(70% 140% at 12% 0%, #4a2f86 0%, rgba(74,47,134,0) 60%),
|
||||
radial-gradient(60% 120% at 88% 0%, #1f3b6e 0%, rgba(31,59,110,0) 65%),
|
||||
linear-gradient(180deg, #1b1430, #0b0a12);
|
||||
}
|
||||
/* Vollbild ohne Menüleiste: Streifen neben der Notch ist schwarz wie der Rand,
|
||||
die Notch verschwindet darin */
|
||||
.dm-macbook[data-menubar="off"] .dm-macbook__screen { background: var(--dm-bezel); }
|
||||
|
||||
/* macOS-Menüleiste (durchscheinend über dem Hintergrund, von der Notch geteilt) */
|
||||
.dm-macbook__menubar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: calc(37 * var(--dm-u));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 calc(14 * var(--dm-u)) 0 calc(21 * var(--dm-u));
|
||||
font-size: calc(13 * var(--dm-u));
|
||||
font-weight: 500;
|
||||
letter-spacing: .005em;
|
||||
color: rgba(255, 255, 255, .94);
|
||||
background: linear-gradient(180deg, rgba(0,0,0,.28), rgba(0,0,0,.18));
|
||||
text-shadow: 0 0 calc(2 * var(--dm-u)) rgba(0, 0, 0, .25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Text darf nie in die Notch laufen (auch nicht mit Mindestschriftgröße):
|
||||
was nicht passt, bricht in eine unsichtbare zweite Zeile um */
|
||||
.dm-macbook__menus,
|
||||
.dm-macbook__extras {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dm-macbook__menus {
|
||||
max-width: calc(633 * var(--dm-u));
|
||||
column-gap: calc(19 * var(--dm-u));
|
||||
}
|
||||
.dm-macbook__extras {
|
||||
max-width: calc(640 * var(--dm-u));
|
||||
justify-content: flex-end;
|
||||
column-gap: calc(17 * var(--dm-u));
|
||||
}
|
||||
.dm-macbook__menus > span,
|
||||
.dm-macbook__menus > b,
|
||||
.dm-macbook__extras > span { line-height: calc(37 * var(--dm-u)); }
|
||||
.dm-macbook__menus b { font-weight: 700; margin-left: calc(4 * var(--dm-u)); }
|
||||
.dm-macbook__apple { width: calc(14 * var(--dm-u)); height: calc(17 * var(--dm-u)); margin-top: calc(-2 * var(--dm-u)); }
|
||||
.dm-macbook__extras svg { height: calc(13 * var(--dm-u)); width: auto; }
|
||||
.dm-macbook__extras svg:nth-child(3) { height: calc(14 * var(--dm-u)); }
|
||||
.dm-macbook__extras span { margin-left: calc(2 * var(--dm-u)); white-space: pre; }
|
||||
|
||||
/* Notch in Rahmenschwarz, mit kleinen Hohlkehlen und Kamera */
|
||||
.dm-macbook__notch {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
z-index: 3;
|
||||
width: calc(188 * var(--dm-u));
|
||||
height: calc(37 * var(--dm-u));
|
||||
transform: translateX(-50%);
|
||||
background: var(--dm-bezel);
|
||||
border-radius: 0 0 calc(13 * var(--dm-u)) calc(13 * var(--dm-u));
|
||||
}
|
||||
.dm-macbook__notch::before,
|
||||
.dm-macbook__notch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: calc(6 * var(--dm-u));
|
||||
height: calc(6 * var(--dm-u));
|
||||
}
|
||||
.dm-macbook__notch::before {
|
||||
right: 100%;
|
||||
background: radial-gradient(circle at 0 100%, transparent calc(6 * var(--dm-u) - .5px), var(--dm-bezel) calc(6 * var(--dm-u)));
|
||||
}
|
||||
.dm-macbook__notch::after {
|
||||
left: 100%;
|
||||
background: radial-gradient(circle at 100% 100%, transparent calc(6 * var(--dm-u) - .5px), var(--dm-bezel) calc(6 * var(--dm-u)));
|
||||
}
|
||||
.dm-macbook__camera {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(8 * var(--dm-u));
|
||||
height: calc(8 * var(--dm-u));
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 36% 34%, rgba(150,140,255,.55) 0, rgba(80,70,170,.25) 22%, rgba(0,0,0,0) 46%),
|
||||
radial-gradient(circle at 50% 50%, #0d0f1c 0, #07080f 55%, #15161c 70%, #050506 100%);
|
||||
box-shadow: 0 0 0 calc(1.2 * var(--dm-u)) rgba(255, 255, 255, .035);
|
||||
}
|
||||
|
||||
/* Safari-Fenster (maximiert), Dunkelmodus */
|
||||
.dm-macbook__window {
|
||||
position: absolute;
|
||||
top: calc(37 * var(--dm-u));
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: calc(12 * var(--dm-u)) calc(12 * var(--dm-u)) calc(6 * var(--dm-u)) calc(6 * var(--dm-u));
|
||||
background: #1c1c1e;
|
||||
}
|
||||
.dm-macbook[data-menubar="off"] .dm-macbook__window { border-radius: 0; }
|
||||
.dm-macbook__window::after { /* Fensterkontur im Dunkelmodus */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 0 max(.5px, calc(1 * var(--dm-u))) rgba(255, 255, 255, .13);
|
||||
z-index: 2;
|
||||
}
|
||||
.dm-macbook__toolbar {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: calc(52 * var(--dm-u));
|
||||
padding: 0 calc(20 * var(--dm-u));
|
||||
background: linear-gradient(180deg, #333336, #2c2c2f);
|
||||
box-shadow: inset 0 calc(-1 * var(--dm-u)) 0 rgba(0, 0, 0, .75);
|
||||
color: rgba(255, 255, 255, .62);
|
||||
z-index: 1;
|
||||
}
|
||||
/* Ampel als eine Vektorgrafik: drei gleich große Kreise bei jeder Größe */
|
||||
.dm-macbook__lights { display: flex; margin-right: calc(28 * var(--dm-u)); }
|
||||
.dm-macbook__lights svg { width: calc(52 * var(--dm-u)); height: calc(12 * var(--dm-u)); }
|
||||
/* Vollbild: macOS blendet die Fenstertasten aus */
|
||||
.dm-macbook[data-menubar="off"] .dm-macbook__lights { visibility: hidden; }
|
||||
.dm-macbook__tool { display: flex; align-items: center; }
|
||||
.dm-macbook__tool--sidebar { gap: calc(5 * var(--dm-u)); margin-right: calc(30 * var(--dm-u)); }
|
||||
.dm-macbook__tool--sidebar svg:first-child { width: calc(22 * var(--dm-u)); }
|
||||
.dm-macbook__tool--sidebar svg:last-child { width: calc(9 * var(--dm-u)); opacity: .8; }
|
||||
.dm-macbook__tool--nav { gap: calc(26 * var(--dm-u)); }
|
||||
.dm-macbook__tool--nav svg { width: calc(10 * var(--dm-u)); }
|
||||
.dm-macbook__tool--nav svg:last-child { opacity: .35; }
|
||||
.dm-macbook__tool--right { margin-left: auto; gap: calc(28 * var(--dm-u)); }
|
||||
.dm-macbook__tool--right svg:nth-child(1) { width: calc(16 * var(--dm-u)); margin-top: calc(-3 * var(--dm-u)); }
|
||||
.dm-macbook__tool--right svg:nth-child(2) { width: calc(15 * var(--dm-u)); }
|
||||
.dm-macbook__tool--right svg:nth-child(3) { width: calc(18 * var(--dm-u)); }
|
||||
.dm-macbook__address {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(10 * var(--dm-u));
|
||||
width: calc(640 * var(--dm-u));
|
||||
height: calc(32 * var(--dm-u));
|
||||
padding: 0 calc(11 * var(--dm-u));
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: calc(9 * var(--dm-u));
|
||||
background: rgba(255, 255, 255, .085);
|
||||
box-shadow: inset 0 0 0 max(.4px, calc(.5 * var(--dm-u))) rgba(255, 255, 255, .04);
|
||||
}
|
||||
.dm-macbook__address > svg:first-child { width: calc(18 * var(--dm-u)); opacity: .75; }
|
||||
.dm-macbook__address > svg:last-child { width: calc(14 * var(--dm-u)); opacity: .75; }
|
||||
.dm-macbook__url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
gap: calc(6 * var(--dm-u));
|
||||
font-size: calc(13.5 * var(--dm-u));
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, .9);
|
||||
letter-spacing: .005em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dm-macbook__url span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dm-macbook__url svg { width: calc(9.5 * var(--dm-u)); color: rgba(255, 255, 255, .6); margin-top: calc(-1 * var(--dm-u)); }
|
||||
|
||||
/* Web-Viewport: 1512 x 893 pt */
|
||||
.dm-macbook__viewport {
|
||||
--dm-dw: 1512;
|
||||
--dm-dh: 893;
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* ---- Scharnier (dunkel, endet unter dem Alurand des Deckels) ---- */
|
||||
.dm-macbook__hinge {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 87.2cqw; /* 99,4 % L */
|
||||
height: .78cqw; /* 0,9 % L */
|
||||
margin-top: -.05cqw;
|
||||
border-radius: 0 0 .5cqw .5cqw / 0 0 .5cqw .5cqw;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,0) 4%, rgba(0,0,0,0) 96%, rgba(0,0,0,.55)),
|
||||
linear-gradient(180deg, var(--dm-hinge-1) 0%, var(--dm-hinge-1) 22%, var(--dm-hinge-2) 50%, var(--dm-hinge-3) 66%, var(--dm-hinge-2) 80%, var(--dm-hinge-1) 100%);
|
||||
}
|
||||
|
||||
/* ---- Unterteil: Aluplatte, fast genau von vorn gesehen ---- */
|
||||
.dm-macbook__base {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100cqw;
|
||||
height: 2.72cqw; /* 3,1 % L */
|
||||
margin-top: -.15cqw;
|
||||
margin-bottom: .36cqw; /* Platz für die Füße */
|
||||
border-radius: 1.2cqw 1.2cqw 2.2cqw 2.2cqw / .7cqw .7cqw 1.6cqw 1.6cqw;
|
||||
background:
|
||||
/* von oben gerundete Enden: auch die helle Oberkante dreht dort weg */
|
||||
radial-gradient(4.2cqw 1.5cqw at 0 0, rgba(0,0,0,.24), rgba(0,0,0,.08) 55%, rgba(0,0,0,0)),
|
||||
radial-gradient(4.2cqw 1.5cqw at 100% 0, rgba(0,0,0,.24), rgba(0,0,0,.08) 55%, rgba(0,0,0,0)),
|
||||
/* Enden, die vom Licht wegdrehen, und breite Glanzstreifen */
|
||||
linear-gradient(90deg,
|
||||
rgba(0,0,0,.32) 0%, rgba(0,0,0,.08) 2.5%, rgba(0,0,0,0) 6%,
|
||||
rgba(255,255,255,.16) 24%, rgba(255,255,255,0) 40%,
|
||||
rgba(255,255,255,0) 62%, rgba(255,255,255,.1) 76%,
|
||||
rgba(0,0,0,0) 94%, rgba(0,0,0,.08) 97.5%, rgba(0,0,0,.32) 100%),
|
||||
/* Oberseite als schmaler Streifen -> Kante -> Front, die in den Schatten fällt */
|
||||
linear-gradient(180deg,
|
||||
var(--dm-al-hi) 0%, var(--dm-al-1) 4%, var(--dm-al-2) 10%, var(--dm-al-3) 14%,
|
||||
var(--dm-al-5) 16%, var(--dm-al-2) 18.5%, var(--dm-al-hi) 21.5%, var(--dm-al-1) 28%,
|
||||
var(--dm-al-2) 42%, var(--dm-al-3) 58%, var(--dm-al-4) 72%, var(--dm-al-5) 84%,
|
||||
var(--dm-al-6) 93%, var(--dm-al-5) 100%);
|
||||
box-shadow:
|
||||
inset 0 calc(-1 * max(.5px, .044cqw)) 0 rgba(0, 0, 0, .35),
|
||||
0 0 0 max(.5px, .03cqw) rgba(0, 0, 0, .2);
|
||||
}
|
||||
/* Daumenmulde in der Vorderkante */
|
||||
.dm-macbook__lip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 12.28cqw; /* 14 % L */
|
||||
height: 1.07cqw; /* ca. 40 % der Höhe */
|
||||
transform: translateX(-50%);
|
||||
border-radius: 0 0 3.5cqw 3.5cqw / 0 0 1.07cqw 1.07cqw;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0,0,0,.22) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 78%, rgba(0,0,0,.22) 100%),
|
||||
linear-gradient(180deg,
|
||||
var(--dm-al-5) 0%, var(--dm-al-4) 30%, var(--dm-al-3) 60%, var(--dm-al-2) 86%, var(--dm-al-1) 100%);
|
||||
box-shadow:
|
||||
inset 0 .1cqw .18cqw rgba(0, 0, 0, .28),
|
||||
0 max(.5px, .044cqw) 0 rgba(255, 255, 255, .5); /* beleuchtete Unterlippe */
|
||||
}
|
||||
/* Gummifüße */
|
||||
.dm-macbook__base::before,
|
||||
.dm-macbook__base::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(100% - .08cqw);
|
||||
width: 4.2cqw;
|
||||
height: .26cqw;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(closest-side, var(--dm-foot) 30%, rgba(0, 0, 0, 0));
|
||||
opacity: .7; /* von vorn kaum sichtbar */
|
||||
}
|
||||
.dm-macbook__base::before { left: 8.5cqw; }
|
||||
.dm-macbook__base::after { right: 8.5cqw; }
|
||||
|
||||
/* Kontakt- und Umgebungsschatten auf dem "Tisch" */
|
||||
.dm-macbook__shadow {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -2.3cqw;
|
||||
height: 3.2cqw;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dm-macbook__shadow::before,
|
||||
.dm-macbook__shadow::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dm-macbook__shadow::before { /* Kontakt */
|
||||
left: 3%;
|
||||
right: 3%;
|
||||
top: .6cqw;
|
||||
height: .85cqw;
|
||||
background: rgba(0, 0, 0, .8);
|
||||
filter: blur(.27cqw);
|
||||
}
|
||||
.dm-macbook__shadow::after { /* Umgebung */
|
||||
left: 1%;
|
||||
right: 1%;
|
||||
top: .45cqw;
|
||||
height: 2.7cqw;
|
||||
background: radial-gradient(closest-side, rgba(0, 0, 0, .5), rgba(0, 0, 0, .22) 60%, rgba(0, 0, 0, 0));
|
||||
filter: blur(.44cqw);
|
||||
}
|
||||
|
||||
/* sehr klein: Haarlinien weglassen, sie würden nur rauschen */
|
||||
@container (max-width: 320px) {
|
||||
.dm-macbook__base::before,
|
||||
.dm-macbook__base::after { display: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
iPhone 16 Pro
|
||||
========================================================================== */
|
||||
.dm-iphone {
|
||||
/* Titan Natur (Standard): warmes, gebürstetes Mittelgrau */
|
||||
--dm-t-hi: #e2ddd5;
|
||||
--dm-t-1: #cac4ba;
|
||||
--dm-t-2: #b3aca1;
|
||||
--dm-t-3: #999287;
|
||||
--dm-t-4: #7f786e;
|
||||
--dm-t-5: #5c564e;
|
||||
--dm-t-ant: rgba(112, 104, 92, .55);
|
||||
--dm-t-glint: rgba(255, 255, 255, .38);
|
||||
--dm-t-band-hl: rgba(255, 255, 255, .10);
|
||||
--dm-status: #fff;
|
||||
}
|
||||
.dm-iphone[data-color="black"] {
|
||||
--dm-t-hi: #6c6c70;
|
||||
--dm-t-1: #46464a;
|
||||
--dm-t-2: #363639;
|
||||
--dm-t-3: #2a2a2d;
|
||||
--dm-t-4: #1e1e21;
|
||||
--dm-t-5: #111113;
|
||||
--dm-t-top-light: rgba(255, 255, 255, .09);
|
||||
--dm-t-ant: rgba(4, 4, 6, .6);
|
||||
--dm-t-glint: rgba(255, 255, 255, .26);
|
||||
--dm-t-band-hl: rgba(255, 255, 255, .06);
|
||||
--dm-t-glint-opacity: .55; /* dunkles Titan spiegelt matter */
|
||||
}
|
||||
.dm-iphone[data-color="white"] {
|
||||
--dm-t-hi: #ffffff;
|
||||
--dm-t-1: #f1f1ef;
|
||||
--dm-t-2: #e1e1de;
|
||||
--dm-t-3: #cbcbc8;
|
||||
--dm-t-4: #aeaeab;
|
||||
--dm-t-5: #8a8a87;
|
||||
--dm-t-ant: rgba(150, 150, 146, .55);
|
||||
--dm-t-glint: rgba(255, 255, 255, .6);
|
||||
}
|
||||
.dm-iphone[data-color="desert"] {
|
||||
--dm-t-hi: #f0e2d2;
|
||||
--dm-t-1: #dcc4ab;
|
||||
--dm-t-2: #c4a98f;
|
||||
--dm-t-3: #ad9178;
|
||||
--dm-t-4: #8f7560;
|
||||
--dm-t-5: #6b5645;
|
||||
--dm-t-ant: rgba(130, 104, 82, .55);
|
||||
--dm-t-glint: rgba(255, 246, 236, .55);
|
||||
}
|
||||
/* heller App-Inhalt: dunkle Statusleiste und Home-Balken */
|
||||
.dm-iphone[data-statusbar="dark"] { --dm-status: #000; }
|
||||
|
||||
/* ---- Titanband (von vorn sichtbar) ---- */
|
||||
.dm-iphone__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 1.35cqw; /* Titanband */
|
||||
border-radius: 17.66cqw; /* 14,26 + 2,05 + 1,35 */
|
||||
background:
|
||||
/* Antennenlinien (nur auf dem Band sichtbar, Glas deckt den Rest) */
|
||||
linear-gradient(180deg, transparent 10.9%, var(--dm-t-ant) 10.9%, var(--dm-t-ant) 11.15%, transparent 11.15%,
|
||||
transparent 88.85%, var(--dm-t-ant) 88.85%, var(--dm-t-ant) 89.1%, transparent 89.1%),
|
||||
linear-gradient(90deg, transparent 23.6%, var(--dm-t-ant) 23.6%, var(--dm-t-ant) 24.1%, transparent 24.1%,
|
||||
transparent 75.9%, var(--dm-t-ant) 75.9%, var(--dm-t-ant) 76.4%, transparent 76.4%),
|
||||
/* Licht von oben */
|
||||
linear-gradient(180deg, var(--dm-t-top-light, rgba(255,255,255,.16)), rgba(255,255,255,0) 22%, rgba(0,0,0,0) 74%, rgba(0,0,0,.14)),
|
||||
/* Umgebungsspiegelungen rundherum (0deg = oben) */
|
||||
conic-gradient(from 0deg at 50% 50%,
|
||||
var(--dm-t-1) 0deg, var(--dm-t-hi) 13deg, var(--dm-t-2) 26deg, var(--dm-t-3) 40deg,
|
||||
var(--dm-t-hi) 58deg, var(--dm-t-2) 72deg, var(--dm-t-3) 100deg, var(--dm-t-4) 126deg,
|
||||
var(--dm-t-2) 148deg, var(--dm-t-3) 166deg, var(--dm-t-4) 182deg, var(--dm-t-3) 196deg,
|
||||
var(--dm-t-1) 211deg, var(--dm-t-3) 236deg, var(--dm-t-4) 258deg, var(--dm-t-2) 282deg,
|
||||
var(--dm-t-hi) 302deg, var(--dm-t-2) 318deg, var(--dm-t-1) 336deg, var(--dm-t-hi) 347deg, var(--dm-t-1) 360deg);
|
||||
box-shadow:
|
||||
inset 0 0 0 max(.5px, .16cqw) rgba(0, 0, 0, .30), /* Außenkante rollt weg */
|
||||
inset 0 0 0 .42cqw rgba(0, 0, 0, .10),
|
||||
inset 0 0 0 .82cqw var(--dm-t-band-hl, rgba(255, 255, 255, .16)), /* gerundetes Band fängt Licht */
|
||||
0 0 0 max(.5px, .12cqw) rgba(0, 0, 0, .28), /* Silhouette auf hellem Grund */
|
||||
0 .8cqw 1.6cqw rgba(0, 0, 0, .18),
|
||||
0 5cqw 12cqw -1cqw rgba(0, 0, 0, .42);
|
||||
}
|
||||
/* Glanzpunkte auf dem Band (ringförmig maskiert, das Glas bleibt frei) */
|
||||
.dm-iphone__body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: .16cqw;
|
||||
z-index: 1;
|
||||
padding: 1.1cqw;
|
||||
border-radius: 17.5cqw;
|
||||
pointer-events: none;
|
||||
background: conic-gradient(from 0deg at 50% 50%,
|
||||
rgba(255,255,255,.1) 0deg, rgba(255,255,255,0) 14deg,
|
||||
var(--dm-t-glint) 26deg, rgba(255,255,255,0) 38deg,
|
||||
rgba(255,255,255,0) 80deg, rgba(255,255,255,.14) 100deg, rgba(255,255,255,0) 116deg,
|
||||
rgba(255,255,255,0) 146deg, rgba(255,255,255,.24) 156deg, rgba(255,255,255,0) 166deg,
|
||||
rgba(0,0,0,.1) 190deg, rgba(255,255,255,0) 214deg,
|
||||
rgba(255,255,255,.04) 250deg, rgba(255,255,255,.2) 284deg, rgba(255,255,255,.04) 306deg,
|
||||
var(--dm-t-glint) 326deg, rgba(255,255,255,.7) 333deg, var(--dm-t-glint) 340deg,
|
||||
rgba(255,255,255,.06) 350deg, rgba(255,255,255,.1) 360deg);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
opacity: var(--dm-t-glint-opacity, .8);
|
||||
}
|
||||
|
||||
/* ---- schwarzes Glas / Rand ---- */
|
||||
.dm-iphone__glass {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 2.05cqw;
|
||||
border-radius: 16.31cqw;
|
||||
background: #020203;
|
||||
box-shadow:
|
||||
0 0 0 max(.5px, .12cqw) var(--dm-t-1), /* polierte Fase */
|
||||
0 0 0 max(.6px, .3cqw) rgba(0, 0, 0, .18),
|
||||
inset 0 0 0 max(.5px, .14cqw) rgba(255, 255, 255, .07); /* Glaskante */
|
||||
}
|
||||
.dm-iphone__glass::after { /* Spiegelung */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(118deg,
|
||||
rgba(255,255,255,.07) 0%,
|
||||
rgba(255,255,255,.03) 22%,
|
||||
rgba(255,255,255,.01) 40%,
|
||||
rgba(255,255,255,0) 52%);
|
||||
}
|
||||
|
||||
/* ---- Display 402 x 874 pt ---- */
|
||||
.dm-iphone__screen {
|
||||
--dm-u: calc(100cqw / 402); /* 1 iOS-Punkt */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 402 / 874;
|
||||
overflow: hidden;
|
||||
border-radius: 14.26cqw; /* 15,3 % der Displaybreite */
|
||||
background: #000;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.dm-iphone__viewport {
|
||||
--dm-dw: 402;
|
||||
--dm-dh: 874;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Statusleiste: Uhrzeit mittig im linken "Ohr", Symbole im rechten */
|
||||
.dm-iphone__statusbar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 3;
|
||||
height: calc(59 * var(--dm-u));
|
||||
color: var(--dm-status);
|
||||
pointer-events: none;
|
||||
}
|
||||
.dm-iphone__time,
|
||||
.dm-iphone__indicators {
|
||||
position: absolute;
|
||||
top: calc(30 * var(--dm-u));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.dm-iphone__time {
|
||||
left: calc(16 * var(--dm-u));
|
||||
width: calc(110 * var(--dm-u));
|
||||
font-size: calc(17 * var(--dm-u));
|
||||
font-weight: 600;
|
||||
letter-spacing: -.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dm-iphone__indicators {
|
||||
right: calc(16 * var(--dm-u));
|
||||
width: calc(110 * var(--dm-u));
|
||||
gap: calc(5.5 * var(--dm-u));
|
||||
}
|
||||
.dm-iphone__indicators svg:nth-child(1) { width: calc(18 * var(--dm-u)); }
|
||||
.dm-iphone__indicators svg:nth-child(2) { width: calc(16.5 * var(--dm-u)); }
|
||||
.dm-iphone__indicators svg:nth-child(3) { width: calc(27.5 * var(--dm-u)); }
|
||||
|
||||
/* Dynamic Island */
|
||||
.dm-iphone__island {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: calc(11 * var(--dm-u));
|
||||
left: 50%;
|
||||
width: calc(126 * var(--dm-u));
|
||||
height: calc(37 * var(--dm-u));
|
||||
transform: translateX(-50%);
|
||||
border-radius: calc(18.5 * var(--dm-u));
|
||||
background: #000;
|
||||
}
|
||||
.dm-iphone__island i { /* Frontkamera unter dem Glas */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: calc(12.5 * var(--dm-u));
|
||||
width: calc(12 * var(--dm-u));
|
||||
height: calc(12 * var(--dm-u));
|
||||
transform: translateY(-50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 38% 34%, rgba(130,140,255,.42) 0, rgba(60,60,140,.18) 24%, rgba(0,0,0,0) 48%),
|
||||
radial-gradient(circle, #0b0d18 0, #06070c 58%, #101116 74%, #000 100%);
|
||||
}
|
||||
|
||||
/* Home-Balken 140 x 5 pt, 8 pt vom unteren Rand */
|
||||
.dm-iphone__home {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 50%;
|
||||
bottom: calc(8 * var(--dm-u));
|
||||
width: calc(140 * var(--dm-u));
|
||||
height: calc(5 * var(--dm-u));
|
||||
transform: translateX(-50%);
|
||||
border-radius: calc(3 * var(--dm-u));
|
||||
background: var(--dm-status);
|
||||
opacity: .92;
|
||||
}
|
||||
|
||||
/* ---- Seitentasten (stehen ca. 0,75 % der Gehäusebreite über) ---- */
|
||||
.dm-iphone__buttons i {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: 2.2cqw;
|
||||
border-radius: .9cqw / 1.6cqw;
|
||||
}
|
||||
.dm-iphone__btn--action,
|
||||
.dm-iphone__btn--vol-up,
|
||||
.dm-iphone__btn--vol-down {
|
||||
left: -.78cqw;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,0) 10%, rgba(255,255,255,0) 90%, rgba(0,0,0,.25)),
|
||||
linear-gradient(90deg, var(--dm-t-4) 0%, var(--dm-t-hi) 22%, var(--dm-t-2) 48%, var(--dm-t-3) 100%);
|
||||
box-shadow: inset 0 0 0 max(.4px, .1cqw) rgba(0, 0, 0, .22);
|
||||
}
|
||||
.dm-iphone__btn--side,
|
||||
.dm-iphone__btn--camera {
|
||||
right: -.78cqw;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,0) 10%, rgba(255,255,255,0) 90%, rgba(0,0,0,.25)),
|
||||
linear-gradient(270deg, var(--dm-t-4) 0%, var(--dm-t-hi) 22%, var(--dm-t-2) 48%, var(--dm-t-3) 100%);
|
||||
box-shadow: inset 0 0 0 max(.4px, .1cqw) rgba(0, 0, 0, .22);
|
||||
}
|
||||
.dm-iphone__btn--action { top: 18.2%; height: 4.3%; }
|
||||
.dm-iphone__btn--vol-up { top: 28.6%; height: 7.3%; }
|
||||
.dm-iphone__btn--vol-down { top: 38.6%; height: 7.3%; }
|
||||
.dm-iphone__btn--side { top: 30%; height: 12%; }
|
||||
/* Kamerasteuerung: fast bündig, Saphirglas in Titaneinfassung */
|
||||
.dm-iphone__buttons .dm-iphone__btn--camera {
|
||||
top: 60%;
|
||||
height: 8%;
|
||||
right: -.42cqw;
|
||||
border-radius: .8cqw / 1.2cqw;
|
||||
background: /* Saphirglanz über farblich passendem Teil */
|
||||
linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,0) 22%, rgba(255,255,255,0) 78%, rgba(255,255,255,.14)),
|
||||
linear-gradient(270deg, var(--dm-t-5) 0%, var(--dm-t-2) 28%, var(--dm-t-4) 58%, var(--dm-t-3) 100%);
|
||||
box-shadow:
|
||||
0 0 0 max(.5px, .14cqw) var(--dm-t-3),
|
||||
inset 0 0 0 max(.4px, .08cqw) rgba(255, 255, 255, .12);
|
||||
}
|
||||
|
||||
/* sehr klein: Antennenlinien weglassen */
|
||||
@container (max-width: 140px) {
|
||||
.dm-iphone__body { --dm-t-ant: transparent; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
iPad (Portrait, 11"), WEBklar-Ergaenzung im Stil der Rahmen oben
|
||||
Gehaeuse 178,5 x 247,6 mm, Display 820 x 1180 pt (Web-Viewport: Tablet-Klasse).
|
||||
Alurand 0,6 %, schwarzer Rand 5,2 % der Gehaeusebreite, Displayecke 18 pt.
|
||||
========================================================================== */
|
||||
:where(.dm-ipad) {
|
||||
position: relative;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
isolation: isolate;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
:where(.dm-ipad) :where([class^="dm-"], [class*=" dm-"]),
|
||||
:where(.dm-ipad) :where([class^="dm-"], [class*=" dm-"])::before,
|
||||
:where(.dm-ipad) :where([class^="dm-"], [class*=" dm-"])::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dm-ipad {
|
||||
--dm-al-hi: #f7f8f9;
|
||||
--dm-al-1: #e0e1e4;
|
||||
--dm-al-2: #cbcdd1;
|
||||
--dm-al-3: #b1b4b9;
|
||||
--dm-al-4: #8f9298;
|
||||
--dm-status: #000;
|
||||
}
|
||||
.dm-ipad__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: .6cqw; /* Alurand */
|
||||
border-radius: 7.8cqw;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0,0,0,.14) 0%, rgba(0,0,0,0) 6%, rgba(255,255,255,.35) 28%, rgba(255,255,255,0) 44%,
|
||||
rgba(255,255,255,0) 70%, rgba(255,255,255,.2) 86%, rgba(0,0,0,.16) 100%),
|
||||
linear-gradient(180deg, var(--dm-al-hi) 0%, var(--dm-al-1) 4%, var(--dm-al-2) 30%, var(--dm-al-1) 55%, var(--dm-al-3) 85%, var(--dm-al-4) 100%);
|
||||
box-shadow:
|
||||
inset 0 max(.5px, .08cqw) 0 rgba(255, 255, 255, .85),
|
||||
inset 0 0 0 max(.5px, .14cqw) rgba(0, 0, 0, .18),
|
||||
0 0 0 max(.5px, .08cqw) rgba(0, 0, 0, .3),
|
||||
0 .6cqw 1.4cqw rgba(0, 0, 0, .18),
|
||||
0 4cqw 10cqw -1cqw rgba(0, 0, 0, .42);
|
||||
}
|
||||
.dm-ipad__glass {
|
||||
position: relative;
|
||||
padding: 5.2cqw; /* schwarzer Rand, rundum gleich */
|
||||
border-radius: 7.2cqw;
|
||||
background: #050506;
|
||||
box-shadow:
|
||||
0 0 0 max(.5px, .1cqw) var(--dm-al-1),
|
||||
inset 0 0 0 max(.5px, .12cqw) rgba(255, 255, 255, .07);
|
||||
}
|
||||
.dm-ipad__glass::after { /* Spiegelung */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(118deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,.025) 24%, rgba(255,255,255,0) 50%);
|
||||
}
|
||||
.dm-ipad__camera { /* Frontkamera mittig im oberen Rand */
|
||||
position: absolute;
|
||||
top: 2.6cqw;
|
||||
left: 50%;
|
||||
width: 1.1cqw;
|
||||
height: 1.1cqw;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 38% 34%, rgba(130,140,255,.4) 0, rgba(60,60,140,.16) 26%, rgba(0,0,0,0) 50%),
|
||||
radial-gradient(circle, #0b0d18 0, #06070c 60%, #15161b 80%, #000 100%);
|
||||
}
|
||||
.dm-ipad__screen {
|
||||
--dm-u: calc(100cqw / 820); /* 1 iPadOS-Punkt */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 820 / 1180;
|
||||
overflow: hidden;
|
||||
border-radius: calc(18 * var(--dm-u));
|
||||
background: #fff;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.dm-ipad__viewport {
|
||||
--dm-dw: 820;
|
||||
--dm-dh: 1180;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.dm-ipad__statusbar {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 3;
|
||||
height: calc(24 * var(--dm-u));
|
||||
padding: 0 calc(20 * var(--dm-u));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--dm-status);
|
||||
pointer-events: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
.dm-ipad__time {
|
||||
font-size: calc(14 * var(--dm-u));
|
||||
font-weight: 600;
|
||||
white-space: pre;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dm-ipad__indicators { display: flex; align-items: center; gap: calc(6 * var(--dm-u)); }
|
||||
.dm-ipad__indicators svg { display: block; flex: none; overflow: visible; }
|
||||
.dm-ipad__indicators svg:nth-child(1) { width: calc(16 * var(--dm-u)); }
|
||||
.dm-ipad__indicators svg:nth-child(2) { width: calc(26 * var(--dm-u)); }
|
||||
.dm-ipad__home { /* Home-Balken */
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 50%;
|
||||
bottom: calc(8 * var(--dm-u));
|
||||
width: calc(210 * var(--dm-u));
|
||||
height: calc(5 * var(--dm-u));
|
||||
transform: translateX(-50%);
|
||||
border-radius: calc(3 * var(--dm-u));
|
||||
background: var(--dm-status);
|
||||
opacity: .85;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dm-ipad__buttons i {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
background: linear-gradient(90deg, var(--dm-al-4), var(--dm-al-hi) 30%, var(--dm-al-2) 60%, var(--dm-al-3));
|
||||
box-shadow: inset 0 0 0 max(.4px, .08cqw) rgba(0, 0, 0, .22);
|
||||
}
|
||||
.dm-ipad__btn--top { top: -.45cqw; right: 11%; width: 8%; height: 1cqw; border-radius: .5cqw .5cqw 0 0; }
|
||||
.dm-ipad__btn--vol-up,
|
||||
.dm-ipad__btn--vol-down { right: -.45cqw; width: 1cqw; height: 5.5%; border-radius: 0 .5cqw .5cqw 0; }
|
||||
.dm-ipad__btn--vol-up { top: 9%; }
|
||||
.dm-ipad__btn--vol-down { top: 15.5%; }
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Geraeterahmen fuer die Heatmap-Ansicht (Analyse): MacBook Pro 14", iPad und iPhone 16 Pro.
|
||||
MacBook und iPhone stammen aus den Rahmen der neuen WEBklar-Website (device-mockups, KNSONWS/homepage),
|
||||
das iPad ist im gleichen Stil nachgebaut. Styles: geraete.css. Reine Zeichenketten ohne DOM, damit Node-Tests sie pruefen koennen. */
|
||||
|
||||
// Je Geraeteklasse (wie vpClass in analytics/client/core.js: m < 768 <= t < 1024 <= d) die Bildschirmflaeche des Rahmens (canvas)
|
||||
// und darin die Website (site: unter der Statusleiste, in echter Breite).
|
||||
export const HEAT_DEVICES = {
|
||||
d: { label: 'Computer', frame: 'macbook', canvas: [1512, 893], site: { top: 0, width: 1512, height: 893 } },
|
||||
t: { label: 'Tablet', frame: 'ipad', canvas: [820, 1180], site: { top: 24, width: 820, height: 1156 } },
|
||||
m: { label: 'Handy', frame: 'iphone', canvas: [402, 874], site: { top: 59, width: 402, height: 815 } },
|
||||
}
|
||||
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
|
||||
/** Uhrzeit wie in Apple-Werbebildern (9:41), Datum von heute: { mac: 'Fr. 2. Okt. 9:41', ipad: '9:41 Fr. 2. Okt.' } */
|
||||
export function deviceClock(date = new Date()) {
|
||||
// "Fr., 2. Okt." => "Fr. 2. Okt." (wie die macOS-Menueleiste)
|
||||
const day = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: 'numeric', month: 'short' }).format(date).replace(',', '')
|
||||
return { mac: `${day} 9:41`, ipad: `9:41 ${day}` }
|
||||
}
|
||||
|
||||
/** Untertitel unter einem Geraet je Zustand (loading | ready | error | timeout). */
|
||||
export function heatCaption(state, count = 0, format = (n) => String(n)) {
|
||||
if (state === 'ready') {
|
||||
if (!(count > 0)) return 'Noch keine Klicks auf dieser Seite (letzte 30 Tage)'
|
||||
return `${format(count)} ${count === 1 ? 'Klick' : 'Klicks'} · letzte 30 Tage`
|
||||
}
|
||||
if (state === 'error') return 'Die Heatmap konnte nicht geladen werden.'
|
||||
if (state === 'timeout') return 'Deine Website lässt sich hier nicht anzeigen.'
|
||||
return 'Lädt …'
|
||||
}
|
||||
|
||||
/** Adresszeile im MacBook-Safari: Host + Pfad, ohne https:// und ohne Parameter. */
|
||||
export function addressOf(url) {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.host + (u.pathname === '/' ? '' : u.pathname)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Website im Rahmen: iframe in echter Breite unter der Statusleiste. Sandbox ohne Formulare, Pop-ups und Navigation des Portals. */
|
||||
function siteHtml(vp, src, title) {
|
||||
const s = HEAT_DEVICES[vp].site
|
||||
return `<div class="hs-site" style="top:${s.top}px;width:${s.width}px;height:${s.height}px"><iframe src="${esc(src)}" title="${esc(title)}" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe></div>`
|
||||
}
|
||||
|
||||
function canvasHtml(vp, inner) {
|
||||
const [w, h] = HEAT_DEVICES[vp].canvas
|
||||
return `<div class="dm-canvas" data-design-width="${w}" data-design-height="${h}" style="--dm-dw:${w};--dm-dh:${h}">${inner}</div>`
|
||||
}
|
||||
|
||||
const MENUS = ['Ablage', 'Bearbeiten', 'Darstellung', 'Verlauf', 'Lesezeichen', 'Fenster', 'Hilfe']
|
||||
|
||||
/** Rahmen fuer Geraeteklasse vp ('d' | 't' | 'm') mit der Website src. title = Name des iframes fuer Screenreader. */
|
||||
export function deviceHtml(vp, { src, title = '', clock = deviceClock() } = {}) {
|
||||
const dev = HEAT_DEVICES[vp]
|
||||
if (!dev) return ''
|
||||
const canvas = canvasHtml(vp, siteHtml(vp, src, title))
|
||||
if (dev.frame === 'macbook') {
|
||||
return `<div class="dm-macbook" data-color="silver">
|
||||
<div class="dm-macbook__lid"><div class="dm-macbook__glass"><div class="dm-macbook__screen">
|
||||
<div class="dm-macbook__menubar" aria-hidden="true">
|
||||
<div class="dm-macbook__menus">${ICON.apple}<b>Safari</b>${MENUS.map((m) => `<span>${m}</span>`).join('')}</div>
|
||||
<div class="dm-macbook__extras">${ICON.macBattery}${ICON.wifi}${ICON.search}${ICON.control}<span>${esc(clock.mac)}</span></div>
|
||||
</div>
|
||||
<div class="dm-macbook__window">
|
||||
<div class="dm-macbook__toolbar" aria-hidden="true">
|
||||
<span class="dm-macbook__lights">${ICON.lights}</span>
|
||||
<span class="dm-macbook__tool dm-macbook__tool--sidebar">${ICON.sidebar}${ICON.chevDown}</span>
|
||||
<span class="dm-macbook__tool dm-macbook__tool--nav">${ICON.back}${ICON.fwd}</span>
|
||||
<span class="dm-macbook__address">${ICON.pageMenu}<span class="dm-macbook__url">${ICON.lock}<span>${esc(addressOf(src))}</span></span>${ICON.reload}</span>
|
||||
<span class="dm-macbook__tool dm-macbook__tool--right">${ICON.share}${ICON.plus}${ICON.tabs}</span>
|
||||
</div>
|
||||
<div class="dm-macbook__viewport">${canvas}</div>
|
||||
</div>
|
||||
<div class="dm-macbook__notch" aria-hidden="true"><i class="dm-macbook__camera"></i></div>
|
||||
</div></div></div>
|
||||
<div class="dm-macbook__hinge" aria-hidden="true"></div>
|
||||
<div class="dm-macbook__base" aria-hidden="true"><i class="dm-macbook__lip"></i></div>
|
||||
<div class="dm-macbook__shadow" aria-hidden="true"></div>
|
||||
</div>`
|
||||
}
|
||||
if (dev.frame === 'ipad') {
|
||||
return `<div class="dm-ipad" data-color="silver">
|
||||
<span class="dm-ipad__buttons" aria-hidden="true"><i class="dm-ipad__btn--top"></i><i class="dm-ipad__btn--vol-up"></i><i class="dm-ipad__btn--vol-down"></i></span>
|
||||
<div class="dm-ipad__body"><div class="dm-ipad__glass">
|
||||
<i class="dm-ipad__camera" aria-hidden="true"></i>
|
||||
<div class="dm-ipad__screen">
|
||||
<div class="dm-ipad__viewport">${canvas}</div>
|
||||
<div class="dm-ipad__statusbar" aria-hidden="true"><span class="dm-ipad__time">${esc(clock.ipad)}</span><span class="dm-ipad__indicators">${ICON.wifi}${ICON.iosBattery}</span></div>
|
||||
<div class="dm-ipad__home" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>`
|
||||
}
|
||||
return `<div class="dm-iphone" data-color="natural" data-statusbar="dark">
|
||||
<span class="dm-iphone__buttons" aria-hidden="true"><i class="dm-iphone__btn--action"></i><i class="dm-iphone__btn--vol-up"></i><i class="dm-iphone__btn--vol-down"></i><i class="dm-iphone__btn--side"></i><i class="dm-iphone__btn--camera"></i></span>
|
||||
<div class="dm-iphone__body"><div class="dm-iphone__glass"><div class="dm-iphone__screen">
|
||||
<div class="dm-iphone__viewport">${canvas}</div>
|
||||
<div class="dm-iphone__statusbar" aria-hidden="true"><span class="dm-iphone__time">9:41</span><span class="dm-iphone__indicators">${ICON.cellular}${ICON.wifi}${ICON.iosBattery}</span></div>
|
||||
<div class="dm-iphone__island" aria-hidden="true"><i></i></div>
|
||||
<div class="dm-iphone__home" aria-hidden="true"></div>
|
||||
</div></div></div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
/* Symbole der Rahmen (aus DeviceFrames.tsx, alle dekorativ) */
|
||||
const ICON = {
|
||||
lights: '<svg viewBox="0 0 52 12" aria-hidden="true" focusable="false"><circle cx="6" cy="6" r="5.7" fill="#ff5f57" stroke="#000" stroke-opacity=".22" stroke-width=".6" /><circle cx="26" cy="6" r="5.7" fill="#febc2e" stroke="#000" stroke-opacity=".22" stroke-width=".6" /><circle cx="46" cy="6" r="5.7" fill="#28c840" stroke="#000" stroke-opacity=".22" stroke-width=".6" /></svg>',
|
||||
apple: '<svg class="dm-macbook__apple" viewBox="0 0 14 17" aria-hidden="true" focusable="false"><path fill="currentColor" d="M11.62 9.03c-.02-1.9 1.55-2.82 1.62-2.86-.88-1.29-2.26-1.47-2.75-1.49-1.17-.12-2.28.69-2.88.69-.59 0-1.5-.67-2.47-.65A3.67 3.67 0 0 0 2.05 6.6C.73 8.88 1.71 12.27 3 14.13c.63.91 1.38 1.93 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.15 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.08 1.03-2.13-.02-.01-1.97-.76-1.98-3.01ZM9.75 3.46c.52-.63.87-1.51.78-2.38-.75.03-1.66.5-2.2 1.13-.48.56-.9 1.45-.79 2.31.84.07 1.69-.42 2.21-1.06Z" /></svg>',
|
||||
macBattery: '<svg viewBox="0 0 27 13" aria-hidden="true" focusable="false"><rect x=".6" y=".6" width="23.2" height="11.8" rx="3.4" fill="none" stroke="currentColor" stroke-opacity=".45" stroke-width="1.2" /><rect x="2.3" y="2.3" width="16.4" height="8.4" rx="1.9" fill="currentColor" /><path d="M25 4.4c.9.2 1.5.9 1.5 2.1s-.6 1.9-1.5 2.1z" fill="currentColor" fill-opacity=".45" /></svg>',
|
||||
wifi: '<svg viewBox="0 0 17 12.6" aria-hidden="true" focusable="false"><g fill="none" stroke="currentColor" stroke-width="2.1"><path d="M1.43 4.93a10 10 0 0 1 14.14 0" /><path d="M4.05 7.55a6.3 6.3 0 0 1 8.9 0" /></g><path fill="currentColor" d="M8.5 12 5.95 9.45a3.6 3.6 0 0 1 5.1 0Z" /></svg>',
|
||||
search: '<svg viewBox="0 0 15 15" aria-hidden="true" focusable="false"><circle cx="6.2" cy="6.2" r="4.9" fill="none" stroke="currentColor" stroke-width="1.6" /><path d="m9.8 9.8 4.2 4.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>',
|
||||
control: '<svg viewBox="0 0 17 14" aria-hidden="true" focusable="false"><rect x=".8" y=".8" width="15.4" height="5.2" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.3" /><circle cx="4.1" cy="3.4" r="1.7" fill="currentColor" /><rect x=".8" y="8" width="15.4" height="5.2" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.3" /><circle cx="12.9" cy="10.6" r="1.7" fill="currentColor" /></svg>',
|
||||
sidebar: '<svg viewBox="0 0 22 17" aria-hidden="true" focusable="false"><rect x=".8" y=".8" width="20.4" height="15.4" rx="3.4" fill="none" stroke="currentColor" stroke-width="1.5" /><path d="M8 1v15" stroke="currentColor" stroke-width="1.5" /><path d="M3.3 4.6h2.4M3.3 7.1h2.4M3.3 9.6h2.4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /></svg>',
|
||||
chevDown: '<svg viewBox="0 0 9 6" aria-hidden="true" focusable="false"><path d="m1 1 3.5 3.5L8 1" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" /></svg>',
|
||||
back: '<svg viewBox="0 0 10 17" aria-hidden="true" focusable="false"><path d="M8.3 1.4 1.7 8.5l6.6 7.1" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" /></svg>',
|
||||
fwd: '<svg viewBox="0 0 10 17" aria-hidden="true" focusable="false"><path d="m1.7 1.4 6.6 7.1-6.6 7.1" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" /></svg>',
|
||||
pageMenu: '<svg viewBox="0 0 18 14" aria-hidden="true" focusable="false"><rect x=".8" y=".8" width="16.4" height="12.4" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.3" /><path d="M4 4.6h10M4 7h10M4 9.4h6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" /></svg>',
|
||||
lock: '<svg viewBox="0 0 10 13" aria-hidden="true" focusable="false"><path d="M2.6 5.6V4a2.4 2.4 0 0 1 4.8 0v1.6" fill="none" stroke="currentColor" stroke-width="1.35" /><rect x=".6" y="5.4" width="8.8" height="7" rx="1.6" fill="currentColor" /></svg>',
|
||||
reload: '<svg viewBox="0 0 14 15" aria-hidden="true" focusable="false"><path d="M12 8.4A5 5 0 1 1 9.7 3.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><path d="M8.4 1 11.2 3.5 8.2 5.9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>',
|
||||
share: '<svg viewBox="0 0 16 20" aria-hidden="true" focusable="false"><path d="M5.2 7.2H3.4A1.9 1.9 0 0 0 1.5 9.1v7.5c0 1.05.85 1.9 1.9 1.9h9.2c1.05 0 1.9-.85 1.9-1.9V9.1a1.9 1.9 0 0 0-1.9-1.9h-1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><path d="M8 12.2V1.4M4.8 4.4 8 1.2l3.2 3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>',
|
||||
plus: '<svg viewBox="0 0 15 15" aria-hidden="true" focusable="false"><path d="M7.5 1v13M1 7.5h13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg>',
|
||||
tabs: '<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false"><rect x="4.8" y=".9" width="12.3" height="12.3" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.4" /><path d="M12.9 15.3a2.4 2.4 0 0 1-2.2 1.8H3.4A2.5 2.5 0 0 1 .9 14.6V7.3a2.4 2.4 0 0 1 1.8-2.3" fill="none" stroke="currentColor" stroke-width="1.4" /></svg>',
|
||||
cellular: '<svg viewBox="0 0 18 12" aria-hidden="true" focusable="false"><rect x="0" y="7.4" width="3.1" height="4.6" rx=".9" fill="currentColor" /><rect x="4.9" y="5" width="3.1" height="7" rx=".9" fill="currentColor" /><rect x="9.8" y="2.5" width="3.1" height="9.5" rx=".9" fill="currentColor" /><rect x="14.7" y="0" width="3.1" height="12" rx=".9" fill="currentColor" /></svg>',
|
||||
iosBattery: '<svg viewBox="0 0 27.5 13" aria-hidden="true" focusable="false"><rect x=".55" y=".55" width="23.9" height="11.9" rx="3.7" fill="none" stroke="currentColor" stroke-opacity=".38" stroke-width="1.1" /><rect x="2.1" y="2.1" width="18.6" height="8.8" rx="2.2" fill="currentColor" /><path d="M25.6 4.3c.95.25 1.5 1.05 1.5 2.2s-.55 1.95-1.5 2.2z" fill="currentColor" fill-opacity=".4" /></svg>',
|
||||
}
|
||||
@@ -247,7 +247,7 @@ router.get('/analyse/stats', analyseGuard, async (req, res) => {
|
||||
|
||||
router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
const { projectId, path, vp } = req.body || {}
|
||||
const { projectId, path, vp, embed } = req.body || {}
|
||||
if (typeof path !== 'string' || !path.startsWith('/') || path.length > 300 || /[?#\\\s\u0000-\u001f\u007f]/.test(path)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Pfad' })
|
||||
}
|
||||
@@ -260,7 +260,8 @@ router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => {
|
||||
if (!domains.length) return res.status(409).json({ error: 'Keine gültige Domain' })
|
||||
const host = project.liveDomain ? domains[domains.length - 1] : domains[0]
|
||||
const token = analyseDeps.client.heatToken({ siteKey: doc.siteKey, path, vp })
|
||||
res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}` })
|
||||
// embed: Ansicht im Geraeterahmen des Portals (h.js ohne Leiste, Links gesperrt)
|
||||
res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}${embed === true ? '&__wk_embed=1' : ''}` })
|
||||
} catch (err) {
|
||||
fail(res, err, 'Der Heatmap-Link konnte gerade nicht erstellt werden.')
|
||||
}
|
||||
|
||||
@@ -188,6 +188,14 @@ test('Heatmap-Link: Prüfung, URL, Token', async () => {
|
||||
assert.equal(v.siteKey, key)
|
||||
assert.equal(v.path, '/preise')
|
||||
assert.equal(v.vp, 'm')
|
||||
assert.equal(url.searchParams.get('__wk_embed'), null, 'ohne embed kein Einbettungsparameter')
|
||||
// embed: fuer den Geraeterahmen im Portal
|
||||
const em = await link({ path: '/preise', vp: 'd', embed: true })
|
||||
assert.equal(em.status, 200)
|
||||
const eu = new URL(em.body.url)
|
||||
assert.equal(eu.searchParams.get('__wk_embed'), '1')
|
||||
assert.equal(verifyHeatToken(eu.searchParams.get('__wk_heat'), config.analyse.heatSecret, Date.now()).vp, 'd')
|
||||
assert.equal(new URL((await link({ path: '/', vp: 'd', embed: 'ja' })).body.url).searchParams.get('__wk_embed'), null, 'nur true zaehlt')
|
||||
// ohne Live-Domain: Subdomain
|
||||
await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie })
|
||||
const sub = await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie })
|
||||
|
||||
@@ -5,10 +5,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// public/funktionen.js wirklich laden (der absolute Import /analyse-view.js wird auf die Datei umgeleitet)
|
||||
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
|
||||
const viewUrl = pathToFileURL(new URL('../public/analyse-view.js', import.meta.url).pathname).href
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8').replace("'/analyse-view.js'", `'${viewUrl}'`)
|
||||
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
|
||||
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { HEAT_DEVICES, deviceHtml, heatCaption, addressOf, deviceClock } from '../public/geraete.js'
|
||||
import { vpClass } from '../analytics/client/core.js'
|
||||
|
||||
const SRC = 'https://baeckerei.de/preise?__wk_heat=abc.def&__wk_embed=1'
|
||||
|
||||
test('Geraete: Website-Breite liegt in der Geraeteklasse der Heatmap (m/t/d wie vpClass)', () => {
|
||||
assert.deepEqual(Object.keys(HEAT_DEVICES), ['d', 't', 'm'])
|
||||
for (const [vp, dev] of Object.entries(HEAT_DEVICES)) {
|
||||
assert.equal(vpClass(dev.site.width), vp, vp)
|
||||
assert.equal(dev.site.width, dev.canvas[0], 'volle Bildschirmbreite')
|
||||
assert.equal(dev.site.top + dev.site.height, dev.canvas[1], 'Website reicht bis zum unteren Rand')
|
||||
}
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(HEAT_DEVICES).map(([k, d]) => [k, d.label])), { d: 'Computer', t: 'Tablet', m: 'Handy' })
|
||||
})
|
||||
|
||||
test('deviceHtml: Rahmen je Geraet, iframe in echter Breite mit enger Sandbox', () => {
|
||||
const frames = { d: 'dm-macbook', t: 'dm-ipad', m: 'dm-iphone' }
|
||||
for (const [vp, cls] of Object.entries(frames)) {
|
||||
const html = deviceHtml(vp, { src: SRC, title: 'Heatmap <Handy>' })
|
||||
const dev = HEAT_DEVICES[vp]
|
||||
assert.match(html, new RegExp(`^<div class="${cls}"`), vp)
|
||||
assert.match(html, new RegExp(`class="dm-canvas" data-design-width="${dev.canvas[0]}" data-design-height="${dev.canvas[1]}"`))
|
||||
assert.match(html, new RegExp(`class="hs-site" style="top:${dev.site.top}px;width:${dev.site.width}px;height:${dev.site.height}px"`))
|
||||
assert.match(html, /<iframe src="https:\/\/baeckerei\.de\/preise\?__wk_heat=abc\.def&__wk_embed=1"/)
|
||||
assert.match(html, /title="Heatmap <Handy>"/)
|
||||
const sandbox = /sandbox="([^"]*)"/.exec(html)[1]
|
||||
assert.equal(sandbox, 'allow-scripts allow-same-origin', 'keine Formulare, Pop-ups oder Navigation des Portals')
|
||||
assert.match(html, /referrerpolicy="no-referrer"/)
|
||||
assert.equal((html.match(/<iframe/g) || []).length, 1)
|
||||
}
|
||||
assert.match(deviceHtml('d', { src: SRC }), /<span>baeckerei\.de\/preise<\/span>/, 'Adresszeile ohne Parameter')
|
||||
assert.match(deviceHtml('m', { src: SRC }), /data-statusbar="dark"/, 'dunkle Statusleiste auf weisser Website')
|
||||
assert.equal(deviceHtml('x', { src: SRC }), '')
|
||||
assert.doesNotMatch(deviceHtml('d', { src: '"><script>alert(1)</script>' }), /<script>/)
|
||||
})
|
||||
|
||||
test('heatCaption', () => {
|
||||
const fmt = (n) => new Intl.NumberFormat('de-DE').format(n)
|
||||
assert.equal(heatCaption('loading'), 'Lädt …')
|
||||
assert.equal(heatCaption('ready', 0), 'Noch keine Klicks auf dieser Seite (letzte 30 Tage)')
|
||||
assert.equal(heatCaption('ready', 1), '1 Klick · letzte 30 Tage')
|
||||
assert.equal(heatCaption('ready', 1234, fmt), '1.234 Klicks · letzte 30 Tage')
|
||||
assert.equal(heatCaption('error'), 'Die Heatmap konnte nicht geladen werden.')
|
||||
assert.equal(heatCaption('timeout'), 'Deine Website lässt sich hier nicht anzeigen.')
|
||||
})
|
||||
|
||||
test('addressOf und deviceClock', () => {
|
||||
assert.equal(addressOf('https://baeckerei.de/?__wk_heat=x'), 'baeckerei.de')
|
||||
assert.equal(addressOf('https://cafe.project.webklar.com/karte/?a=1'), 'cafe.project.webklar.com/karte/')
|
||||
assert.equal(addressOf('kaputt'), '')
|
||||
assert.deepEqual(deviceClock(new Date('2026-10-02T10:00:00Z')), { mac: 'Fr. 2. Okt. 9:41', ipad: '9:41 Fr. 2. Okt.' })
|
||||
})
|
||||
Reference in new issue
Block a user