feat(antrag-detail): geführte Tour mit Sprachausgabe (#185 Phase 1)
Schaltfläche „🧭 Tour" in der userrow neben „Merken". Klick öffnet ein Spotlight-Overlay mit vier Stationen, ermächtigend statt vereinfachend formuliert: 1. Die Gemeinwohl-Note (was die Zahl 0–10 sagt, was die Empfehlung ist) 2. Die GWÖ-Matrix (5 Werte × 5 Berührungsgruppen, Farbcodierung) 3. Programm-Treue pro Fraktion (Score + Belege) 4. Stimmverhalten + Marker (Heuchelei ⚠ und Opportunismus !) Audio: Web Speech API (Browser-eingebaute Stimme), de-DE, möglichst weibliche Stimme. „Stimme an / aus"-Toggle in der Bubble. Bei ESC oder Klick auf Overlay-Hintergrund: Tour-Ende, Audio stoppt. Phase 2 (separate Iteration) wird das Audio-Backend gegen ElevenLabs tauschen — Tour-Skript + UI bleiben gleich, nur ``speak()`` ruft dann einen Server-Endpoint, der eine vorgenerierte und gecachte MP3 liefert. Komponente in ``app/templates/v3/components/tour.html``, included am Ende von antrag_detail.html. CSS inline in der Komponente (1 ``<style>``- Block, keine ``style=""``-Attribute — Anti-Regression-Wache aus #184 respektiert).
This commit is contained in:
parent
57e11b3da7
commit
1c74cb8801
342
app/templates/v3/components/tour.html
Normal file
342
app/templates/v3/components/tour.html
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
{# ─────────────────────────────────────────────────────────────────────
|
||||||
|
Geführte Tour durch das Antrag-Detail (#185).
|
||||||
|
|
||||||
|
Was diese Komponente liefert:
|
||||||
|
- "Tour"-Button rendert in der userrow (siehe antrag_detail.html)
|
||||||
|
- Vier Stationen: Score-Hero, Matrix, Programm-Treue, Stimmverhalten
|
||||||
|
- Spotlight-Overlay (Klick außerhalb → Tour-Ende)
|
||||||
|
- Erklärblase mit Vor / Zurück / Schließen
|
||||||
|
- Audio: Web Speech API (Browser-eingebaute Stimme), de-DE,
|
||||||
|
stoppbar bei Schritt-Wechsel und Schließen.
|
||||||
|
|
||||||
|
Stand: Phase 1 — Browser-TTS. Phase 2 (ElevenLabs) kommt separat
|
||||||
|
und tauscht nur das Audio-Backend; das Tour-Skript bleibt gleich.
|
||||||
|
───────────────────────────────────────────────────────────────────── #}
|
||||||
|
<div id="gwoe-tour-overlay" class="gwoe-tour-overlay" hidden aria-hidden="true">
|
||||||
|
<div id="gwoe-tour-spotlight" class="gwoe-tour-spotlight"></div>
|
||||||
|
<div id="gwoe-tour-bubble" class="gwoe-tour-bubble" role="dialog"
|
||||||
|
aria-labelledby="gwoe-tour-title" aria-describedby="gwoe-tour-text">
|
||||||
|
<div class="gwoe-tour-head">
|
||||||
|
<span id="gwoe-tour-step">Schritt 1 / 4</span>
|
||||||
|
<button type="button" onclick="gwoeTourEnd()" class="gwoe-tour-close"
|
||||||
|
title="Tour beenden" aria-label="Tour beenden">×</button>
|
||||||
|
</div>
|
||||||
|
<h3 id="gwoe-tour-title" class="gwoe-tour-title">Titel</h3>
|
||||||
|
<p id="gwoe-tour-text" class="gwoe-tour-text">Erklär-Text</p>
|
||||||
|
<div class="gwoe-tour-actions">
|
||||||
|
<button type="button" id="gwoe-tour-mute"
|
||||||
|
onclick="gwoeTourToggleMute()"
|
||||||
|
class="v3-action-btn gwoe-tour-mute"
|
||||||
|
title="Sprachausgabe an / aus">
|
||||||
|
<span id="gwoe-tour-mute-icon">🔊</span>
|
||||||
|
<span id="gwoe-tour-mute-label">Stimme an</span>
|
||||||
|
</button>
|
||||||
|
<span class="gwoe-tour-spacer"></span>
|
||||||
|
<button type="button" id="gwoe-tour-prev"
|
||||||
|
onclick="gwoeTourPrev()" class="v3-action-btn">← Zurück</button>
|
||||||
|
<button type="button" id="gwoe-tour-next"
|
||||||
|
onclick="gwoeTourNext()" class="v3-action-btn primary">Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gwoe-tour-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99998;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.gwoe-tour-overlay[hidden] { display: none; }
|
||||||
|
|
||||||
|
.gwoe-tour-spotlight {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: top 0.3s ease, left 0.3s ease, width 0.3s ease, height 0.3s ease;
|
||||||
|
outline: 2px solid var(--ecg-teal);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gwoe-tour-bubble {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 99999;
|
||||||
|
background: var(--ecg-card-bg, #fff);
|
||||||
|
color: var(--ecg-dark, #1a1a1a);
|
||||||
|
border: 1px solid var(--hairline, rgba(0,0,0,0.12));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
max-width: 380px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: top 0.3s ease, left 0.3s ease;
|
||||||
|
}
|
||||||
|
.gwoe-tour-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.gwoe-tour-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ecg-dark);
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.gwoe-tour-close:hover { opacity: 1; }
|
||||||
|
.gwoe-tour-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
color: var(--ecg-teal);
|
||||||
|
}
|
||||||
|
.gwoe-tour-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.gwoe-tour-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px dashed var(--hairline, rgba(0,0,0,0.1));
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.gwoe-tour-spacer { flex: 1 1 auto; }
|
||||||
|
.gwoe-tour-mute { font-size: 12px; }
|
||||||
|
.gwoe-tour-bubble .v3-action-btn.primary {
|
||||||
|
background: var(--ecg-teal);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--ecg-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.gwoe-tour-bubble {
|
||||||
|
/* auf Mobile zentriert unten andocken statt gefloated, sonst kollidiert
|
||||||
|
mit dem Spotlight */
|
||||||
|
left: 12px !important;
|
||||||
|
right: 12px !important;
|
||||||
|
max-width: none;
|
||||||
|
bottom: 12px;
|
||||||
|
top: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Tour-Skript: 4 Stationen. Selectoren werden zur Laufzeit aufgelöst —
|
||||||
|
// wenn ein Element fehlt (z.B. keine Plenum-Votes), überspringt die
|
||||||
|
// Tour den Schritt automatisch.
|
||||||
|
const STEPS = [
|
||||||
|
{
|
||||||
|
selector: '.v3-bewertung',
|
||||||
|
title: 'Die Gemeinwohl-Note',
|
||||||
|
text: 'Diese große Zahl ist die Gemeinwohl-Note. Null ist destruktiv, Zehn vorbildlich. Sie zeigt, wie sehr der Antrag dem Allgemeinwohl dient. Daneben steht die Empfehlung — Unterstützen, Überarbeiten oder Ablehnen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '.v3-matrix-grid, .v3-matrix, [class*="matrix"]',
|
||||||
|
title: 'Die Gemeinwohl-Matrix',
|
||||||
|
text: 'Dieses Raster prüft fünf Werte: Würde, Solidarität, Nachhaltigkeit, Gerechtigkeit und Demokratie. Pro Wert wird geschaut, wie der Antrag fünf Berührungsgruppen betrifft — von Lieferanten bis zur Gesellschaft als Ganzes. Grün heißt: der Antrag fördert diesen Wert. Rot: er widerspricht ihm.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '.v3-fraktionen',
|
||||||
|
title: 'Programm-Treue pro Fraktion',
|
||||||
|
text: 'Hier sehen Sie, wie gut der Antrag zum Wahl- und Parteiprogramm jeder Fraktion passt. Die Zahl rechts ist der Programm-Score von Null bis Zehn. Ein Klick darauf zeigt die Begründung mit Zitaten aus dem Programm.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '.v3-vote-section',
|
||||||
|
title: 'Stimmverhalten und Marker',
|
||||||
|
text: 'Hier sehen Sie, wie die Fraktionen tatsächlich abgestimmt haben. Das Warnschild neben einer Fraktion bedeutet Heuchelei: Sie stimmt mit Nein, obwohl der Antrag exakt zu ihrem eigenen Wahlprogramm passt. Das Ausrufezeichen markiert Opportunismus: Ja-Stimme bei einem Antrag, der dem eigenen Programm widerspricht.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let _tourIdx = 0;
|
||||||
|
let _resolvedSteps = []; // STEPS gefiltert auf vorhandene Elemente
|
||||||
|
let _tourMuted = false;
|
||||||
|
let _tourUtter = null;
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function speak(text) {
|
||||||
|
if (_tourMuted) return;
|
||||||
|
if (!('speechSynthesis' in window)) return;
|
||||||
|
try {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(text);
|
||||||
|
u.lang = 'de-DE';
|
||||||
|
u.rate = 1.0;
|
||||||
|
u.pitch = 1.0;
|
||||||
|
// Versuche eine deutsche, möglichst weibliche Stimme zu wählen.
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
const de = voices.filter(v => /de(-DE|_DE)?/i.test(v.lang));
|
||||||
|
const female = de.find(v => /female|frau|anna|petra|katja|helga|marlene|vicki/i.test(v.name));
|
||||||
|
u.voice = female || de[0] || null;
|
||||||
|
_tourUtter = u;
|
||||||
|
window.speechSynthesis.speak(u);
|
||||||
|
} catch (_) { /* TTS optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSpeak() {
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
try { window.speechSynthesis.cancel(); } catch (_) {}
|
||||||
|
}
|
||||||
|
_tourUtter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSteps() {
|
||||||
|
_resolvedSteps = [];
|
||||||
|
for (const s of STEPS) {
|
||||||
|
// Erstes Match aus comma-separated Selector-Liste.
|
||||||
|
const parts = s.selector.split(',').map(x => x.trim());
|
||||||
|
let el = null;
|
||||||
|
for (const p of parts) {
|
||||||
|
el = document.querySelector(p);
|
||||||
|
if (el) break;
|
||||||
|
}
|
||||||
|
if (el) _resolvedSteps.push({ ...s, el });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionBubble(el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const bubble = $('gwoe-tour-bubble');
|
||||||
|
const spotlight = $('gwoe-tour-spotlight');
|
||||||
|
|
||||||
|
// Spotlight um das Element legen.
|
||||||
|
spotlight.style.top = (rect.top - 4) + 'px';
|
||||||
|
spotlight.style.left = (rect.left - 4) + 'px';
|
||||||
|
spotlight.style.width = (rect.width + 8) + 'px';
|
||||||
|
spotlight.style.height = (rect.height + 8) + 'px';
|
||||||
|
|
||||||
|
// Bubble unter oder über das Element legen, wo Platz ist.
|
||||||
|
const bubbleH = 240; // grobe Annahme
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
let top, left;
|
||||||
|
if (spaceBelow > bubbleH + 20) {
|
||||||
|
top = rect.bottom + 14;
|
||||||
|
} else if (spaceAbove > bubbleH + 20) {
|
||||||
|
top = rect.top - bubbleH - 14;
|
||||||
|
} else {
|
||||||
|
// Nichts passt vertikal — Bubble nach rechts neben das Element.
|
||||||
|
top = Math.max(20, rect.top);
|
||||||
|
}
|
||||||
|
left = Math.max(20, Math.min(window.innerWidth - 400, rect.left));
|
||||||
|
bubble.style.top = top + 'px';
|
||||||
|
bubble.style.left = left + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStep(i) {
|
||||||
|
if (i < 0 || i >= _resolvedSteps.length) {
|
||||||
|
gwoeTourEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_tourIdx = i;
|
||||||
|
const step = _resolvedSteps[i];
|
||||||
|
const overlay = $('gwoe-tour-overlay');
|
||||||
|
overlay.hidden = false;
|
||||||
|
overlay.setAttribute('aria-hidden', 'false');
|
||||||
|
|
||||||
|
$('gwoe-tour-step').textContent = `Schritt ${i + 1} / ${_resolvedSteps.length}`;
|
||||||
|
$('gwoe-tour-title').textContent = step.title;
|
||||||
|
$('gwoe-tour-text').textContent = step.text;
|
||||||
|
$('gwoe-tour-prev').disabled = (i === 0);
|
||||||
|
$('gwoe-tour-next').textContent = (i === _resolvedSteps.length - 1)
|
||||||
|
? 'Fertig ✓' : 'Weiter →';
|
||||||
|
|
||||||
|
// Element ins Sichtfeld scrollen, dann positionieren.
|
||||||
|
step.el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setTimeout(() => positionBubble(step.el), 350);
|
||||||
|
|
||||||
|
// TTS sprechen lassen
|
||||||
|
speak(step.title + '. ' + step.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globale Schnittstelle
|
||||||
|
window.gwoeTourStart = function () {
|
||||||
|
resolveSteps();
|
||||||
|
if (_resolvedSteps.length === 0) {
|
||||||
|
console.warn('Tour: keine sichtbaren Stationen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_tourIdx = 0;
|
||||||
|
showStep(0);
|
||||||
|
document.body.style.overflow = 'hidden'; // Page-Scroll blockieren
|
||||||
|
};
|
||||||
|
|
||||||
|
window.gwoeTourEnd = function () {
|
||||||
|
const overlay = $('gwoe-tour-overlay');
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
stopSpeak();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.gwoeTourNext = function () {
|
||||||
|
if (_tourIdx >= _resolvedSteps.length - 1) {
|
||||||
|
gwoeTourEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showStep(_tourIdx + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.gwoeTourPrev = function () {
|
||||||
|
if (_tourIdx <= 0) return;
|
||||||
|
showStep(_tourIdx - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.gwoeTourToggleMute = function () {
|
||||||
|
_tourMuted = !_tourMuted;
|
||||||
|
$('gwoe-tour-mute-icon').textContent = _tourMuted ? '🔇' : '🔊';
|
||||||
|
$('gwoe-tour-mute-label').textContent = _tourMuted ? 'Stimme aus' : 'Stimme an';
|
||||||
|
if (_tourMuted) stopSpeak();
|
||||||
|
else if (_resolvedSteps[_tourIdx]) {
|
||||||
|
const s = _resolvedSteps[_tourIdx];
|
||||||
|
speak(s.title + '. ' + s.text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ESC schließt; Klick auf Overlay (außerhalb der Bubble) auch.
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const overlay = $('gwoe-tour-overlay');
|
||||||
|
if (!overlay || overlay.hidden) return;
|
||||||
|
if (e.key === 'Escape') gwoeTourEnd();
|
||||||
|
else if (e.key === 'ArrowRight') gwoeTourNext();
|
||||||
|
else if (e.key === 'ArrowLeft') gwoeTourPrev();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const overlay = $('gwoe-tour-overlay');
|
||||||
|
if (!overlay || overlay.hidden) return;
|
||||||
|
// Overlay-Klick hinter dem Spotlight oder Bubble → Tour-Ende
|
||||||
|
if (e.target === overlay) gwoeTourEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bei Resize: aktive Bubble + Spotlight neu positionieren
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!$('gwoe-tour-overlay').hidden && _resolvedSteps[_tourIdx]) {
|
||||||
|
positionBubble(_resolvedSteps[_tourIdx].el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beim Verlassen der Page: Sprachausgabe stoppen.
|
||||||
|
window.addEventListener('pagehide', stopSpeak);
|
||||||
|
|
||||||
|
// Voices laden — manche Browser füllen sie asynchron.
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
window.speechSynthesis.onvoiceschanged = () => { /* trigger preload */ };
|
||||||
|
window.speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@ -75,12 +75,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Merken + Bewertung treffend — direkt unter den Metadaten ──────── #}
|
{# Merken + Bewertung treffend + Tour — direkt unter den Metadaten ──── #}
|
||||||
<section class="v3-section v3-userrow">
|
<section class="v3-section v3-userrow">
|
||||||
<button id="v2-merkliste-btn" onclick="v2DetailMerklisteToggle()" class="v3-action-btn">
|
<button id="v2-merkliste-btn" onclick="v2DetailMerklisteToggle()" class="v3-action-btn">
|
||||||
<span id="v2-merkliste-star">☆</span>
|
<span id="v2-merkliste-star">☆</span>
|
||||||
<span id="v2-merkliste-label">Merken</span>
|
<span id="v2-merkliste-label">Merken</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="gwoeTourStart()" class="v3-action-btn" id="gwoe-tour-btn"
|
||||||
|
title="Geführte Tour: Was bedeuten die Felder? Wie lese ich diese Seite?">
|
||||||
|
<span aria-hidden="true">🧭</span>
|
||||||
|
<span>Tour</span>
|
||||||
|
</button>
|
||||||
<div class="v3-userrow-vote">
|
<div class="v3-userrow-vote">
|
||||||
<span class="v3-userrow-label">Bewertung treffend?</span>
|
<span class="v3-userrow-label">Bewertung treffend?</span>
|
||||||
<div id="v2-vote-overall" class="v3-vote-buttons">
|
<div id="v2-vote-overall" class="v3-vote-buttons">
|
||||||
@ -543,6 +548,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "v3/components/tour.html" %}
|
||||||
|
|
||||||
</div>{# .v3-page #}
|
</div>{# .v3-page #}
|
||||||
{% endif %}{# antrag #}
|
{% endif %}{# antrag #}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user