Skip to content

Commit 0497a97

Browse files
GeneralJerelclaude
andcommitted
feat(ui): progressive rendering with DOM morphing for generative UI (#33)
Replace innerHTML with idiomorph DOM morphing in WidgetRenderer to eliminate flickering during streaming. Existing DOM nodes are preserved across updates — only diffs are applied. New elements fade in via .morph-enter animation; first render uses staggered entrance. Also enable Recharts entrance animations on bar/pie charts and add staggered fade-in to MeetingTimePicker time slots. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ba2b133 commit 0497a97

4 files changed

Lines changed: 91 additions & 34 deletions

File tree

apps/app/src/components/generative-ui/charts/bar-chart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function BarChart({ title, description, data }: BarChartProps) {
6262
stroke="var(--chart-axis)"
6363
/>
6464
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
65-
<Bar isAnimationActive={false} dataKey="value" radius={[4, 4, 0, 0]} />
65+
<Bar isAnimationActive={true} animationDuration={800} animationEasing="ease-out" dataKey="value" radius={[4, 4, 0, 0]} />
6666
</RechartsBarChart>
6767
</ResponsiveContainer>
6868
</div>

apps/app/src/components/generative-ui/charts/pie-chart.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ export function PieChart({ title, description, data }: PieChartProps) {
7070
cx="50%"
7171
cy="50%"
7272
outerRadius={100}
73-
isAnimationActive={false}
73+
isAnimationActive={true}
74+
animationDuration={800}
75+
animationEasing="ease-out"
76+
animationBegin={200}
7477
/>
7578
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
7679
</RechartsPieChart>

apps/app/src/components/generative-ui/meeting-time-picker.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export function MeetingTimePicker({
4646

4747
return (
4848
<div className="rounded-2xl shadow-lg max-w-md w-full border dark:border-zinc-700 mx-auto mb-6 bg-white dark:bg-zinc-800">
49+
<style>{`
50+
@keyframes fadeSlideUp {
51+
from { opacity: 0; transform: translateY(12px); }
52+
to { opacity: 1; transform: translateY(0); }
53+
}
54+
`}</style>
4955
<div className="backdrop-blur-md p-8 w-full rounded-2xl">
5056
{/* Show confirmation or prompt */}
5157
{selectedSlot ? (
@@ -98,6 +104,10 @@ export function MeetingTimePicker({
98104
hover:scale-102 active:scale-98
99105
flex justify-between items-center
100106
hover:bg-blue-50 dark:hover:bg-blue-900/30"
107+
style={{
108+
animation: "fadeSlideUp 0.4s ease-out both",
109+
animationDelay: `${index * 80}ms`,
110+
}}
101111
>
102112
<div className="text-left">
103113
<div className="font-bold text-gray-900 dark:text-zinc-100">{slot.date}</div>
@@ -115,6 +125,10 @@ export function MeetingTimePicker({
115125
text-gray-600 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-zinc-200
116126
transition-all cursor-pointer
117127
hover:bg-gray-100 dark:hover:bg-zinc-700"
128+
style={{
129+
animation: "fadeSlideUp 0.4s ease-out both",
130+
animationDelay: `${slots.length * 80}ms`,
131+
}}
118132
>
119133
None of these work
120134
</button>

apps/app/src/components/generative-ui/widget-renderer.tsx

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -302,16 +302,21 @@ input[type="checkbox"], input[type="radio"] {
302302
a { color: var(--color-text-info); text-decoration: none; }
303303
a:hover { text-decoration: underline; }
304304
305-
/* Progressive reveal for content children */
306-
#content > * {
305+
/* First render: stagger all children */
306+
#content.initial-render > * {
307+
animation: fadeSlideIn 0.4s ease-out both;
308+
}
309+
#content.initial-render > *:nth-child(1) { animation-delay: 0s; }
310+
#content.initial-render > *:nth-child(2) { animation-delay: 0.06s; }
311+
#content.initial-render > *:nth-child(3) { animation-delay: 0.12s; }
312+
#content.initial-render > *:nth-child(4) { animation-delay: 0.18s; }
313+
#content.initial-render > *:nth-child(5) { animation-delay: 0.24s; }
314+
#content.initial-render > *:nth-child(n+6) { animation-delay: 0.3s; }
315+
316+
/* Subsequent morphs: only new elements animate in */
317+
.morph-enter {
307318
animation: fadeSlideIn 0.4s ease-out both;
308319
}
309-
#content > *:nth-child(1) { animation-delay: 0s; }
310-
#content > *:nth-child(2) { animation-delay: 0.06s; }
311-
#content > *:nth-child(3) { animation-delay: 0.12s; }
312-
#content > *:nth-child(4) { animation-delay: 0.18s; }
313-
#content > *:nth-child(5) { animation-delay: 0.24s; }
314-
#content > *:nth-child(n+6) { animation-delay: 0.3s; }
315320
316321
@keyframes fadeSlideIn {
317322
from { opacity: 0; transform: translateY(8px); }
@@ -326,6 +331,11 @@ a:hover { text-decoration: underline; }
326331
}
327332
`;
328333

334+
// ─── Injected JS: Idiomorph DOM Morphing Library (Layer 0) ───────────
335+
// idiomorph v0.3.0 — https://github.com/bigskysoftware/idiomorph
336+
// Inlined to avoid CDN network request inside sandboxed iframe.
337+
const IDIOMORPH_JS = `var Idiomorph=function(){"use strict";let o=new Set;let n={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:t,afterNodeAdded:t,beforeNodeMorphed:t,afterNodeMorphed:t,beforeNodeRemoved:t,afterNodeRemoved:t,beforeAttributeUpdated:t},head:{style:"merge",shouldPreserve:function(e){return e.getAttribute("im-preserve")==="true"},shouldReAppend:function(e){return e.getAttribute("im-re-append")==="true"},shouldRemove:t,afterHeadMorphed:t}};function e(e,t,n={}){if(e instanceof Document){e=e.documentElement}if(typeof t==="string"){t=k(t)}let l=y(t);let r=p(e,l,n);return a(e,l,r)}function a(r,i,o){if(o.head.block){let t=r.querySelector("head");let n=i.querySelector("head");if(t&&n){let e=c(n,t,o);Promise.all(e).then(function(){a(r,i,Object.assign(o,{head:{block:false,ignore:true}}))});return}}if(o.morphStyle==="innerHTML"){l(i,r,o);return r.children}else if(o.morphStyle==="outerHTML"||o.morphStyle==null){let e=M(i,r,o);let t=e?.previousSibling;let n=e?.nextSibling;let l=d(r,e,o);if(e){return N(t,l,n)}else{return[]}}else{throw"Do not understand how to morph style "+o.morphStyle}}function u(e,t){return t.ignoreActiveValue&&e===document.activeElement}function d(e,t,n){if(n.ignoreActive&&e===document.activeElement){}else if(t==null){if(n.callbacks.beforeNodeRemoved(e)===false)return e;e.remove();n.callbacks.afterNodeRemoved(e);return null}else if(!g(e,t)){if(n.callbacks.beforeNodeRemoved(e)===false)return e;if(n.callbacks.beforeNodeAdded(t)===false)return e;e.parentElement.replaceChild(t,e);n.callbacks.afterNodeAdded(t);n.callbacks.afterNodeRemoved(e);return t}else{if(n.callbacks.beforeNodeMorphed(e,t)===false)return e;if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){c(t,e,n)}else{r(t,e,n);if(!u(e,n)){l(t,e,n)}}n.callbacks.afterNodeMorphed(e,t);return e}}function l(n,l,r){let i=n.firstChild;let o=l.firstChild;let a;while(i){a=i;i=a.nextSibling;if(o==null){if(r.callbacks.beforeNodeAdded(a)===false)return;l.appendChild(a);r.callbacks.afterNodeAdded(a);H(r,a);continue}if(b(a,o,r)){d(o,a,r);o=o.nextSibling;H(r,a);continue}let e=A(n,l,a,o,r);if(e){o=v(o,e,r);d(e,a,r);H(r,a);continue}let t=S(n,l,a,o,r);if(t){o=v(o,t,r);d(t,a,r);H(r,a);continue}if(r.callbacks.beforeNodeAdded(a)===false)return;l.insertBefore(a,o);r.callbacks.afterNodeAdded(a);H(r,a)}while(o!==null){let e=o;o=o.nextSibling;T(e,r)}}function f(e,t,n,l){if(e==="value"&&l.ignoreActiveValue&&t===document.activeElement){return true}return l.callbacks.beforeAttributeUpdated(e,t,n)===false}function r(t,n,l){let e=t.nodeType;if(e===1){const r=t.attributes;const i=n.attributes;for(const o of r){if(f(o.name,n,"update",l)){continue}if(n.getAttribute(o.name)!==o.value){n.setAttribute(o.name,o.value)}}for(let e=i.length-1;0<=e;e--){const a=i[e];if(f(a.name,n,"remove",l)){continue}if(!t.hasAttribute(a.name)){n.removeAttribute(a.name)}}}if(e===8||e===3){if(n.nodeValue!==t.nodeValue){n.nodeValue=t.nodeValue}}if(!u(n,l)){s(t,n,l)}}function i(t,n,l,r){if(t[l]!==n[l]){let e=f(l,n,"update",r);if(!e){n[l]=t[l]}if(t[l]){if(!e){n.setAttribute(l,t[l])}}else{if(!f(l,n,"remove",r)){n.removeAttribute(l)}}}}function s(n,l,r){if(n instanceof HTMLInputElement&&l instanceof HTMLInputElement&&n.type!=="file"){let e=n.value;let t=l.value;i(n,l,"checked",r);i(n,l,"disabled",r);if(!n.hasAttribute("value")){if(!f("value",l,"remove",r)){l.value="";l.removeAttribute("value")}}else if(e!==t){if(!f("value",l,"update",r)){l.setAttribute("value",e);l.value=e}}}else if(n instanceof HTMLOptionElement){i(n,l,"selected",r)}else if(n instanceof HTMLTextAreaElement&&l instanceof HTMLTextAreaElement){let e=n.value;let t=l.value;if(f("value",l,"update",r)){return}if(e!==t){l.value=e}if(l.firstChild&&l.firstChild.nodeValue!==e){l.firstChild.nodeValue=e}}}function c(e,t,l){let r=[];let i=[];let o=[];let a=[];let u=l.head.style;let d=new Map;for(const n of e.children){d.set(n.outerHTML,n)}for(const s of t.children){let e=d.has(s.outerHTML);let t=l.head.shouldReAppend(s);let n=l.head.shouldPreserve(s);if(e||n){if(t){i.push(s)}else{d.delete(s.outerHTML);o.push(s)}}else{if(u==="append"){if(t){i.push(s);a.push(s)}}else{if(l.head.shouldRemove(s)!==false){i.push(s)}}}}a.push(...d.values());m("to append: ",a);let f=[];for(const c of a){m("adding: ",c);let n=document.createRange().createContextualFragment(c.outerHTML).firstChild;m(n);if(l.callbacks.beforeNodeAdded(n)!==false){if(n.href||n.src){let t=null;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});f.push(e)}t.appendChild(n);l.callbacks.afterNodeAdded(n);r.push(n)}}for(const h of i){if(l.callbacks.beforeNodeRemoved(h)!==false){t.removeChild(h);l.callbacks.afterNodeRemoved(h)}}l.head.afterHeadMorphed(t,{added:r,kept:o,removed:i});return f}function m(){}function t(){}function h(e){let t={};Object.assign(t,n);Object.assign(t,e);t.callbacks={};Object.assign(t.callbacks,n.callbacks);Object.assign(t.callbacks,e.callbacks);t.head={};Object.assign(t.head,n.head);Object.assign(t.head,e.head);return t}function p(e,t,n){n=h(n);return{target:e,newContent:t,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,ignoreActiveValue:n.ignoreActiveValue,idMap:C(e,t),deadIds:new Set,callbacks:n.callbacks,head:n.head}}function b(e,t,n){if(e==null||t==null){return false}if(e.nodeType===t.nodeType&&e.tagName===t.tagName){if(e.id!==""&&e.id===t.id){return true}else{return L(n,e,t)>0}}return false}function g(e,t){if(e==null||t==null){return false}return e.nodeType===t.nodeType&&e.tagName===t.tagName}function v(t,e,n){while(t!==e){let e=t;t=t.nextSibling;T(e,n)}H(n,e);return e.nextSibling}function A(n,e,l,r,i){let o=L(i,l,e);let t=null;if(o>0){let e=r;let t=0;while(e!=null){if(b(l,e,i)){return e}t+=L(i,e,n);if(t>o){return null}e=e.nextSibling}}return t}function S(e,t,n,l,r){let i=l;let o=n.nextSibling;let a=0;while(i!=null){if(L(r,i,e)>0){return null}if(g(n,i)){return i}if(g(o,i)){a++;o=o.nextSibling;if(a>=2){return null}}i=i.nextSibling}return i}function k(n){let l=new DOMParser;let e=n.replace(/<svg(\\s[^>]*>|>)([\\s\\S]*?)<\\/svg>/gim,"");if(e.match(/<\\/html>/)||e.match(/<\\/head>/)||e.match(/<\\/body>/)){let t=l.parseFromString(n,"text/html");if(e.match(/<\\/html>/)){t.generatedByIdiomorph=true;return t}else{let e=t.firstChild;if(e){e.generatedByIdiomorph=true;return e}else{return null}}}else{let e=l.parseFromString("<body><template>"+n+"</template></body>","text/html");let t=e.body.querySelector("template").content;t.generatedByIdiomorph=true;return t}}function y(e){if(e==null){const t=document.createElement("div");return t}else if(e.generatedByIdiomorph){return e}else if(e instanceof Node){const t=document.createElement("div");t.append(e);return t}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}function N(e,t,n){let l=[];let r=[];while(e!=null){l.push(e);e=e.previousSibling}while(l.length>0){let e=l.pop();r.push(e);t.parentElement.insertBefore(e,t)}r.push(t);while(n!=null){l.push(n);r.push(n);n=n.nextSibling}while(l.length>0){t.parentElement.insertBefore(l.pop(),t.nextSibling)}return r}function M(e,t,n){let l;l=e.firstChild;let r=l;let i=0;while(l){let e=w(l,t,n);if(e>i){r=l;i=e}l=l.nextSibling}return r}function w(e,t,n){if(g(e,t)){return.5+L(n,e,t)}return 0}function T(e,t){H(t,e);if(t.callbacks.beforeNodeRemoved(e)===false)return;e.remove();t.callbacks.afterNodeRemoved(e)}function E(e,t){return!e.deadIds.has(t)}function x(e,t,n){let l=e.idMap.get(n)||o;return l.has(t)}function H(e,t){let n=e.idMap.get(t)||o;for(const l of n){e.deadIds.add(l)}}function L(e,t,n){let l=e.idMap.get(t)||o;let r=0;for(const i of l){if(E(e,i)&&x(e,i,n)){++r}}return r}function R(e,n){let l=e.parentElement;let t=e.querySelectorAll("[id]");for(const r of t){let t=r;while(t!==l&&t!=null){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(r.id);t=t.parentElement}}}function C(e,t){let n=new Map;R(e,n);R(t,n);return n}return{morph:e,defaults:n}}();`;
338+
329339
// ─── Injected JS: Bridge Functions + Auto-Resize (Layers 1 & 6) ─────
330340
const BRIDGE_JS = `
331341
// Bridge: sendPrompt
@@ -352,32 +362,61 @@ window.addEventListener('message', function(e) {
352362
if (e.source !== window.parent) return;
353363
if (e.data && e.data.type === 'update-content') {
354364
var content = document.getElementById('content');
355-
if (content) {
356-
// Strip script tags from HTML before inserting — scripts are handled separately below
357-
var tmp = document.createElement('div');
358-
tmp.innerHTML = e.data.html;
359-
var incomingScripts = [];
360-
tmp.querySelectorAll('script').forEach(function(s) {
361-
incomingScripts.push({ src: s.src, text: s.textContent });
362-
s.remove();
363-
});
364-
content.innerHTML = tmp.innerHTML;
365+
if (!content) return;
366+
367+
// Strip script tags from HTML before inserting — scripts are handled separately below
368+
var tmp = document.createElement('div');
369+
tmp.innerHTML = e.data.html;
370+
var incomingScripts = [];
371+
tmp.querySelectorAll('script').forEach(function(s) {
372+
incomingScripts.push({ src: s.src, text: s.textContent });
373+
s.remove();
374+
});
375+
376+
// First render: add stagger class for initial entrance animation
377+
var isFirstRender = !content.hasAttribute('data-has-content');
378+
if (isFirstRender) {
379+
content.classList.add('initial-render');
380+
content.setAttribute('data-has-content', '1');
381+
setTimeout(function() { content.classList.remove('initial-render'); }, 800);
382+
}
365383
366-
// Execute only new scripts (not previously executed)
367-
incomingScripts.forEach(function(scriptInfo) {
368-
var key = scriptInfo.src || scriptInfo.text;
369-
if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return;
370-
content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1');
371-
var newScript = document.createElement('script');
372-
if (scriptInfo.src) {
373-
newScript.src = scriptInfo.src;
374-
} else {
375-
newScript.textContent = scriptInfo.text;
376-
}
377-
content.appendChild(newScript);
378-
});
379-
reportHeight();
384+
// Use idiomorph to diff/patch DOM (preserves existing nodes, no flicker)
385+
if (window.Idiomorph) {
386+
try {
387+
Idiomorph.morph(content, tmp.innerHTML, {
388+
morphStyle: 'innerHTML',
389+
callbacks: {
390+
beforeNodeAdded: function(node) {
391+
// Tag new element nodes for entrance animation
392+
if (node.nodeType === 1) {
393+
node.classList.add('morph-enter');
394+
}
395+
}
396+
}
397+
});
398+
} catch (err) {
399+
// Fallback: full replacement on morph failure
400+
content.innerHTML = tmp.innerHTML;
401+
}
402+
} else {
403+
content.innerHTML = tmp.innerHTML;
380404
}
405+
406+
// Execute only new scripts (not previously executed)
407+
incomingScripts.forEach(function(scriptInfo) {
408+
var key = scriptInfo.src || scriptInfo.text;
409+
if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return;
410+
content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1');
411+
var newScript = document.createElement('script');
412+
if (scriptInfo.src) {
413+
newScript.src = scriptInfo.src;
414+
} else {
415+
newScript.textContent = scriptInfo.text;
416+
}
417+
content.appendChild(newScript);
418+
});
419+
reportHeight();
381420
}
382421
});
383422
@@ -425,6 +464,7 @@ function assembleShell(initialHtml: string = ""): string {
425464
<div id="content">
426465
${initialHtml}
427466
</div>
467+
<script>${IDIOMORPH_JS}</script>
428468
<script>
429469
${BRIDGE_JS}
430470
</script>

0 commit comments

Comments
 (0)