openvibers/openvibe.actor Commits
Actor's answers stream in, word by word, the moment they land (#227)
Changes
@@@ -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; }@@@ -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) {@@@ -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');});
@@ -106,7 +106,8 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -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;
.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; }
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 .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 { 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); }
.ct-copy-code:hover { opacity: 1; color: var(--text-primary, #e6edf7); }
@@ -185,10 +186,11 @@ html.ov-has-appbar .cc { padding-bottom: 8px; }
@@ -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; } }
@keyframes ct-reveal { from { opacity: 0; transform: translateY(4px); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.cs, .cw-chev, .cc-box, .cc-send { transition: none; }
.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; }
.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. */
/* 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; }
.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; }
@@ -220,7 +220,7 @@
@@ -220,7 +220,7 @@
stopScreen(turn);
stopScreen(turn);
turn.replaceWith(next);
turn.replaceWith(next);
var ans = next.querySelector('[data-answer]');
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);
decorate(next);
var dot = chat.querySelector('.cs-list a[aria-current] .cs-dot'); if (dot) dot.remove();
var dot = chat.querySelector('.cs-list a[aria-current] .cs-dot'); if (dot) dot.remove();
if (note) note.textContent = '';
if (note) note.textContent = '';
@@ -289,6 +289,38 @@
@@ -289,6 +289,38 @@
}
}
chat.querySelectorAll('.ct-turn[data-open]').forEach(function (t) { follow(t); });
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.
// ── Copy: an answer, and each code block in it.
function decorate(rootEl) {
function decorate(rootEl) {
(rootEl || chat).querySelectorAll('[data-copy]').forEach(function (b) {
(rootEl || chat).querySelectorAll('[data-copy]').forEach(function (b) {
@@ -149,6 +149,10 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -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, /'\/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, /\(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, /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');
assert.ok(!/innerHTML = [^;]*(text\.value|e\.text|title)/.test(js), 'what a person typed is never set as HTML');
});
});