openvibers/openvibe.actor Commits

Actor's answers stream in, word by word, the moment they land (#227)

OP OpenVibers committed 12 days ago · 9a9dde16cb · 3 files, +42 −4

The owner (2026-11-01): 'text streams in, with nice effects… like ChatGPT or Gemini'. Actor shows an answer only once a
second model has checked it, so its words arrive together; the chat now reveals them word by word, fast (about 45 frames
whatever the length), with code blocks and tables arriving whole when the words before them are shown. Only opacity
changes, so a screen reader reads it at once; nothing moves under reduced motion.

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 1a27d21..85f032c 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -106,7 +106,8 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.ct-answer .ev { display: inline-grid; place-items: center; min-width: 1.4em; height: 1.4em; padding: 0 .3em; margin: 0 .08em; border-radius: 6px; font-size: .72em; font-weight: 700; vertical-align: .18em;
background: color-mix(in srgb, var(--accent, #f97316) 16%, transparent); color: var(--text-secondary, #b4c0d4); text-decoration: none; }
.ct-answer .ev:hover { background: color-mix(in srgb, var(--accent, #f97316) 32%, transparent); color: var(--text-primary, #e6edf7); }
--.ct-answer.is-reveal > * { animation: ct-reveal .5s cubic-bezier(.2, .8, .2, 1) both; }
++.ct-answer.is-streaming .sw { opacity: 0; transition: opacity .28s ease-out; }
++.ct-answer.is-streaming .sw.on { opacity: 1; }
.ct-copy-code { position: absolute; top: 6px; right: 6px; padding: 3px 9px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); font-size: 12px; opacity: .85; }
.ct-copy-code:hover { opacity: 1; color: var(--text-primary, #e6edf7); }
@@@ -185,10 +186,11 @@ html.ov-has-appbar .cc { padding-bottom: 8px; }
@keyframes ct-reveal { from { opacity: 0; transform: translateY(4px); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }
@media (prefers-reduced-motion: reduce) {
.cs, .cw-chev, .cc-box, .cc-send { transition: none; }
-- .cs-dot, .ct-avatar, .cw-live, .cw-tool.is-run b, .ct-hello-mark, .ct-turn.is-new .ct-bubble, .cw-list > li, .ct-answer.is-reveal > * { animation: none !important; }
++ .cs-dot, .ct-avatar, .cw-live, .cw-tool.is-run b, .ct-hello-mark, .ct-turn.is-new .ct-bubble, .cw-list > li { animation: none !important; }
++ .ct-answer.is-streaming .sw { opacity: 1 !important; transition: none !important; }
.cw-live, .cw-tool.is-run b { color: var(--text-primary, #e6edf7); background: none; }
}
--html[data-ov-motion=reduced] .ct-answer.is-reveal > * { animation: none !important; }
++html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important; transition: none !important; }
/* What the person said while it worked, in its list. */
.cw-you p { margin: 0; padding: 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--accent, #f97316) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; justify-self: start; }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 566d5fa..0f01edf 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -220,7 +220,7 @@
stopScreen(turn);
turn.replaceWith(next);
var ans = next.querySelector('[data-answer]');
-- if (ans && !reduce) { ans.classList.add('is-reveal'); Array.prototype.forEach.call(ans.children, function (c, i) { c.style.animationDelay = Math.min(i, 14) * 55 + 'ms'; }); }
++ if (ans && !reduce && !document.documentElement.hasAttribute('data-ov-motion')) streamIn(ans);
decorate(next);
var dot = chat.querySelector('.cs-list a[aria-current] .cs-dot'); if (dot) dot.remove();
if (note) note.textContent = '';
@@@ -289,6 +289,38 @@
}
chat.querySelectorAll('.ct-turn[data-open]').forEach(function (t) { follow(t); });
++ // ── The answer streams in (the owner, 2026-11-01: "text streams in, with nice effects"). Actor shows an answer
++ // only once a second model has checked it, so the words arrive together; they are revealed word by word, fast
++ // (about a second for a long answer), the way a chat app types. Code blocks and tables come in whole. Nothing
++ // is hidden from a screen reader: only opacity changes.
++ function streamIn(ans) {
++ var words = [];
++ var walker = document.createTreeWalker(ans, NodeFilter.SHOW_TEXT, { acceptNode: function (n) { return n.parentNode.closest('pre, table, .evs') ? NodeFilter.FILTER_REJECT : (/\S/.test(n.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT); } });
++ var nodes = [];
++ while (walker.nextNode()) nodes.push(walker.currentNode);
++ nodes.forEach(function (n) {
++ var frag = document.createDocumentFragment();
++ n.nodeValue.split(/(\s+)/).forEach(function (part) {
++ if (!part) return;
++ if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); return; }
++ var w = document.createElement('span'); w.className = 'sw'; w.textContent = part; frag.appendChild(w); words.push(w);
++ });
++ n.parentNode.replaceChild(frag, n);
++ });
++ var blocks = Array.prototype.slice.call(ans.querySelectorAll('pre, table, .evs, img'));
++ blocks.forEach(function (b) { b.classList.add('sw'); });
++ var all = words.concat(blocks);
++ if (!all.length) return;
++ ans.classList.add('is-streaming');
++ var per = Math.max(2, Math.ceil(words.length / 45)), i = 0; // about 45 frames, whatever the length
++ (function step() {
++ for (var k = 0; k < per && i < words.length; k++, i++) words[i].classList.add('on');
++ blocks.forEach(function (b) { if (!b.classList.contains('on') && (b.compareDocumentPosition(words[Math.max(0, i - 1)]) & Node.DOCUMENT_POSITION_FOLLOWING)) b.classList.add('on'); });
++ if (i < words.length) requestAnimationFrame(step);
++ else { all.forEach(function (w) { w.classList.add('on'); }); setTimeout(function () { ans.classList.remove('is-streaming'); }, 300); }
++ })();
++ }
++
// ── Copy: an answer, and each code block in it.
function decorate(rootEl) {
(rootEl || chat).querySelectorAll('[data-copy]').forEach(function (b) {
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex ee32cbf..b929579 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -149,6 +149,10 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(id\) \+ '\/look\?at='/, 'the screen refreshes from the last picture');
assert.match(js, /\(hover: hover\) and \(pointer: fine\)/, 'Enter sends only with a keyboard and mouse; a phone\'s Enter is a new line');
assert.match(js, /prefers-reduced-motion: reduce/);
++ assert.match(js, /function streamIn\(ans\)/, 'the checked answer streams in word by word');
++ assert.match(js, /if \(ans && !reduce && !document\.documentElement\.hasAttribute\('data-ov-motion'\)\) streamIn\(ans\)/, 'not under reduced motion');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
++ assert.match(css, /\.ct-answer\.is-streaming \.sw \{ opacity: 0;/, 'only opacity: a screen reader reads it all at once');
assert.ok(!/innerHTML = [^;]*(text\.value|e\.text|title)/.test(js), 'what a person typed is never set as HTML');
});