diff --git a/app/web/src/main.js b/app/web/src/main.js
index 90f7c87..35ebc66 100644
--- a/app/web/src/main.js
+++ b/app/web/src/main.js
@@ -592,25 +592,46 @@ async function openRecording(id) {
let idx = -1;
for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
if (idx < 0) return;
+ const tu = turns[idx];
if (idx !== bubbleTurnIdx) {
bubbleTurnIdx = idx;
- const tu = turns[idx];
const i = spkOrder.indexOf(tu.sp);
- const seat = tableSeats(Math.min(persons.length, spkOrder.length))[i >= 0 ? Math.min(i, persons.length - 1) : 0];
+ const seats = tableSeats(Math.min(persons.length, spkOrder.length));
+ const seat = seats[i >= 0 ? Math.min(i, persons.length - 1) : 0];
if (bubble && seat) {
- bubble.classList.remove('hidden');
+ // Classic speech bubble AT the speaker: hang it between their mouth and
+ // the table centre, tail (arrow) pointing back at them.
+ const bx = seat.x + (50 - seat.x) * 0.52;
+ const by = seat.y + (50 - seat.y) * 0.60;
bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`;
+ bubble.style.left = bx + '%';
+ bubble.style.top = by + '%';
bubble.querySelector('.tb-name').textContent = spkLabel(tu.sp, i >= 0 ? i : 0);
- bubble.querySelector('.tb-text').textContent = tu.text.length > 220 ? tu.text.slice(0, 220) + '…' : tu.text;
+ // The full turn as clickable word spans — highlighted + scrolled below.
+ bubble.querySelector('.tb-text').innerHTML =
+ tu.words.map(w => `${escapeHtml(w.w)}`).join(' ');
}
if (tIdle) tIdle.classList.add('hidden');
- persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused));
lookAt(tu.sp);
- } else {
- // Same turn: keep the mouth in sync with play/pause.
- persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === turns[idx].sp && !audio.paused));
+ }
+ persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused));
+ // Word highlight + follow-scroll inside the bubble (like the transcript).
+ if (bubble && !bubble.classList.contains('hidden')) {
+ const scroller = bubble.querySelector('.tb-scroll');
+ const bws = bubble.querySelectorAll('.bw');
+ let act = null;
+ for (const s of bws) { if (+s.dataset.s <= t) act = s; else break; }
+ bws.forEach(s => s.classList.toggle('active', s === act));
+ if (act && scroller) {
+ const target = act.offsetTop - scroller.clientHeight / 2 + 10;
+ if (Math.abs(scroller.scrollTop - target) > 24) scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
+ }
}
};
+ if (bubble) bubble.addEventListener('click', (e) => {
+ const s = e.target.closest('.bw');
+ if (s) { audio.currentTime = (+s.dataset.s) / 1000 + 0.001; audio.play().catch(() => {}); }
+ });
// Lane visualizer wiring: playhead + speaking lane + live-text box.
const lanesEl = detail.querySelector('#lanes');
@@ -755,7 +776,7 @@ function buildTableScene(spkOrder, scls, names, spkLabel) {
${escapeHtml(spkLabel(sp, i))}
`;
}).join('')}
-
+
▶ Abspielen — die Runde beginnt zu sprechen.
`;
}
@@ -773,13 +794,14 @@ function speakerSegments(words) {
}
return by;
}
-// Turn list (speaker + joined text) for the live-text box.
+// Turn list for the table scene: speaker, joined text AND the single words
+// (with start times) so the bubble can highlight word-by-word like the transcript.
function wordTurns(words) {
const turns = [];
for (const w of words) {
const last = turns[turns.length - 1];
- if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; }
- else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms });
+ if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; last.words.push({ w: w.word, s: w.t_start_ms }); }
+ else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms, words: [{ w: w.word, s: w.t_start_ms }] });
}
return turns;
}
diff --git a/app/web/src/style.css b/app/web/src/style.css
index 67a2883..a4c6241 100644
--- a/app/web/src/style.css
+++ b/app/web/src/style.css
@@ -296,11 +296,15 @@ audio { width: 100%; margin-bottom: 10px; }
.tperson.talking .tname { color: var(--fg); font-weight: 650; }
.tbubble {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
- width: 52%; max-height: 38%; overflow: hidden; z-index: 3;
+ width: 46%; z-index: 3;
background: var(--surface); border: 2px solid var(--line); border-radius: 12px;
- padding: 8px 10px; font-size: 13px; line-height: 1.4;
+ padding: 8px 10px; font-size: 13px; line-height: 1.55;
}
-.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; }
+.tb-scroll { max-height: 132px; overflow-y: auto; }
+.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; position: sticky; top: 0; background: var(--surface); padding: 2px 0; z-index: 1; }
+.tbubble .bw { cursor: pointer; padding: 1px 2px; border-radius: 4px; }
+.tbubble .bw:hover { background: var(--surface-2); }
+.tbubble .bw.active { background: var(--accent); color: var(--accent-fg); }
.tbubble.sp0 { border-color: var(--sp0); } .tbubble.sp0 .tb-name { color: var(--sp0); }
.tbubble.sp1 { border-color: var(--sp1); } .tbubble.sp1 .tb-name { color: var(--sp1); }
.tbubble.sp2 { border-color: var(--sp2); } .tbubble.sp2 .tb-name { color: var(--sp2); }
diff --git a/docs/08-changelog.md b/docs/08-changelog.md
index 5557bcc..1dcf3d5 100644
--- a/docs/08-changelog.md
+++ b/docs/08-changelog.md
@@ -3,6 +3,14 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
+## 2026-07-17 — GUI: Sprechblase am Sprecher mit Wort-Highlight + Mitscrollen
+
+Tisch-Ansicht verfeinert: Die Blase haengt jetzt klassisch am MUND des Sprechers (dynamisch zwischen
+Sitz und Tischmitte positioniert, Schwanz zeigt zum Kopf) statt zentral auf dem Tisch. Inhalt = der
+komplette Redebeitrag als einzelne Wort-Spans: aktives Wort wird synchron zum Audio gehighlightet
+(wie im Transkript), die Blase scrollt intern mit (innere .tb-scroll-Huelle, damit der Pfeil nicht
+mitscrollt), Klick auf ein Wort springt im Audio dorthin. Bundle `index-BK0gpRp9.js`.
+
## 2026-07-17 — GUI: Konferenztisch-Ansicht (Umschalter neben "Analyse neu")
Neuer Umschalt-Button (Icon: drei Personen am Tisch <-> Textblatt) in der Status-Zeile: ersetzt das