| """ |
| Battle Trace: an experimental temporal replay of a session as a war between the |
| Agent and the Environment. |
| |
| This is the LITERAL, time-based telling of the session — complementary to the |
| shell's byobu battle layer, which is the frozen, artistic telling. The shell |
| remembers the campaign as a folding screen; Battle Trace replays it as it |
| happened. |
| |
| It reuses the SAME structured extraction the rest of the app already produces |
| (approaches, dead_ends, breakthroughs, gotchas, sentiment), so there is no |
| second parser and no new model call. Events map to combat: |
| |
| approach tried -> the Agent makes a move (cmd) |
| dead end -> the Environment strikes (err): Agent resolve drops |
| gotcha -> a clash of blades (clash) |
| breakthrough -> a blow lands true (win): Environment resistance drops |
| final breakthrough -> the battle resolves (done) |
| |
| The renderer is self-contained Canvas 2D (fine for session-sized traces), |
| sandboxed inside an iframe so it can never affect the main pipeline. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import html |
| import json |
|
|
|
|
| def extraction_to_events(extraction: dict) -> list[dict]: |
| """Convert a session extraction into an ordered Battle Trace event stream. |
| |
| Events are ordered by their position along the session (0..1) where we have |
| it (dead ends, breakthroughs), and interleaved sensibly otherwise. |
| """ |
| events: list[dict] = [] |
|
|
| approaches = extraction.get("approaches_tried", []) or [] |
| dead_ends = extraction.get("dead_ends", []) or [] |
| breakthroughs = extraction.get("breakthroughs", []) or [] |
| gotchas = extraction.get("gotchas", []) or [] |
|
|
| |
| for i, a in enumerate(approaches): |
| if not isinstance(a, dict): |
| continue |
| pos = 0.05 + (i / max(1, len(approaches))) * 0.4 |
| label = str(a.get("approach", "a move"))[:72] |
| events.append({"pos": pos, "side": "A", "kind": "cmd", "label": label}) |
|
|
| |
| for d in dead_ends: |
| if not isinstance(d, dict): |
| continue |
| pos = float(d.get("position", 0.5)) |
| label = str(d.get("what_happened", "a wall"))[:72] |
| events.append({"pos": pos, "side": "E", "kind": "err", "label": label}) |
|
|
| |
| for i, g in enumerate(gotchas): |
| pos = 0.3 + (i / max(1, len(gotchas))) * 0.5 |
| label = str(g)[:72] |
| events.append({"pos": pos, "side": "A", "kind": "clash", "label": label}) |
|
|
| |
| for i, b in enumerate(breakthroughs): |
| if not isinstance(b, dict): |
| continue |
| pos = float(b.get("position", 0.85)) |
| label = str(b.get("what_worked", "a breakthrough"))[:72] |
| kind = "done" if i == len(breakthroughs) - 1 else "win" |
| events.append({"pos": pos, "side": "A", "kind": kind, "label": label}) |
|
|
| events.sort(key=lambda e: e["pos"]) |
| |
| for i, e in enumerate(events): |
| e["t"] = round(1.0 + e["pos"] * 12.0, 2) |
| if not events: |
| events = [{"pos": 0.5, "side": "A", "kind": "info", "label": "a quiet session", "t": 6.0}] |
| |
| if events[-1]["kind"] not in ("done", "err"): |
| events[-1]["kind"] = "done" |
| return events |
|
|
|
|
| |
| |
| _BATTLE_HTML = r"""<!DOCTYPE html><html><head><meta charset="utf-8"/> |
| <style> |
| :root{--bg:#0a0e14;--ink:#e6edf3;--muted:#8b98a9;--agent:#6ee7ff;--env:#ff7a59; |
| --ok:#7ee787;--warn:#f0c674;--magic:#d2a8ff;--line:#1c2430;} |
| html,body{margin:0;height:100%;background:var(--bg);color:var(--ink); |
| font-family:ui-monospace,Menlo,Consolas,monospace;overflow:hidden} |
| #wrap{position:relative;height:100vh;width:100vw} |
| #stage{display:block;width:100%;height:100%} |
| .hud{position:absolute;top:0;left:0;right:0;padding:12px 18px;display:flex; |
| justify-content:space-between;pointer-events:none;font-size:12px} |
| .hud .agent{color:var(--agent);font-weight:600}.hud .env{color:var(--env);font-weight:600} |
| .hud .hp{font-size:11px;color:var(--muted)} |
| .caption{position:absolute;left:0;right:0;bottom:0;padding:10px 18px; |
| background:linear-gradient(transparent,rgba(7,10,15,.92));font-size:12px;min-height:38px} |
| .caption .t{color:var(--muted);margin-right:8px} |
| .controls{position:absolute;bottom:46px;left:50%;transform:translateX(-50%);display:flex;gap:8px} |
| button{background:transparent;color:var(--ink);border:1px solid var(--line); |
| border-radius:8px;padding:6px 12px;font-family:inherit;font-size:12px;cursor:pointer} |
| button:hover{border-color:var(--agent);color:var(--agent)} |
| </style></head><body> |
| <div id="wrap"> |
| <div class="hud"> |
| <div><span class="agent">⬡ THE AGENT (you)</span><br/><span class="hp" id="hpA">resolve 100</span></div> |
| <div style="text-align:right"><span class="env">THE ENVIRONMENT ⬡</span><br/><span class="hp" id="hpE">resistance 100</span></div> |
| </div> |
| <canvas id="stage"></canvas> |
| <div class="controls"><button id="replay">↻ replay the battle</button></div> |
| <div class="caption"><span class="t" id="capT">t+0.0s</span><span id="capX">the slug replays the battle, from the first move...</span></div> |
| </div> |
| <script> |
| const EVENTS = __EVENTS_JSON__; |
| const C={cmd:"#6ee7ff",read:"#7ee787",clash:"#f0c674",err:"#ff7a59",retry:"#d2a8ff",win:"#7ee787",done:"#6ee7ff",info:"#8b98a9"}; |
| const cv=document.getElementById("stage"),ctx=cv.getContext("2d"); |
| let W=0,H=0,DPR=Math.min(2,window.devicePixelRatio||1); |
| function resize(){ |
| const r=cv.getBoundingClientRect(); |
| W=r.width||window.innerWidth||640; |
| H=r.height||window.innerHeight||440; |
| cv.width=W*DPR;cv.height=H*DPR;ctx.setTransform(DPR,0,0,DPR,0,0); |
| } |
| addEventListener("resize",resize); |
| const stars=[];for(let i=0;i<70;i++)stars.push({x:Math.random(),y:Math.random()*0.55,r:Math.random()*1.2,a:.3+Math.random()*.5}); |
| let particles=[],bolts=[],rings=[],markers=[],figures=[],idx=0,clock=0,running=true,lastTs=0,hpA=100,hpE=100; |
| const span=Math.max(8,EVENTS.length?EVENTS[EVENTS.length-1].t:10); |
| function godA(){return{x:W*0.12,y:H*0.54}}function godE(){return{x:W*0.88,y:H*0.54}} |
| function spark(x,y,c,n){for(let i=0;i<n;i++){const a=Math.random()*6.28,s=40+Math.random()*120;particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:1,color:c,size:1.5+Math.random()*2})}} |
| function bolt(f,t,c){bolts.push({from:f,to:t,color:c,life:1})} |
| function ring(x,y,c){rings.push({x,y,r:18,color:c,life:1})} |
| function play(ev){const a=godA(),e=godE();const mx=W*0.5+(ev.t/span-0.5)*W*0.12,my=H*0.46+(Math.random()*40-20); |
| const src=ev.side==="A"?a:e;ring(src.x,src.y,C[ev.kind]);bolt(src,{x:mx,y:my},C[ev.kind]); |
| if(["clash","err","win","done"].includes(ev.kind))setTimeout(()=>spark(mx,my,C[ev.kind],ev.kind==="done"?30:16),130); |
| if(ev.kind==="err")figures.push({type:"fallen",x:mx,y:my,born:clock}); |
| if(ev.kind==="done"||ev.kind==="win")figures.push({type:"dragon",x:mx,y:my,born:clock,grow:0}); |
| if(ev.kind==="err")hpA=Math.max(0,hpA-12);if(ev.kind==="clash")hpE=Math.max(0,hpE-8); |
| if(ev.kind==="win"||ev.kind==="done")hpE=Math.max(0,hpE-15); |
| markers.push({t:ev.t,color:C[ev.kind],r:0}); |
| document.getElementById("hpA").textContent="resolve "+Math.round(hpA); |
| document.getElementById("hpE").textContent="resistance "+Math.round(hpE); |
| document.getElementById("capT").textContent="t+"+ev.t.toFixed(1)+"s"; |
| document.getElementById("capX").textContent=ev.label;} |
| function aura(g,c,r){const gr=ctx.createRadialGradient(g.x,g.y,4,g.x,g.y,r);gr.addColorStop(0,c+"66");gr.addColorStop(1,c+"00");ctx.fillStyle=gr;ctx.beginPath();ctx.arc(g.x,g.y,r,0,7);ctx.fill();} |
| function general(g,c){// the Agent: a detailed samurai general |
| aura(g,c,62); |
| ctx.save();ctx.translate(g.x,g.y);ctx.scale(1.15,1.15);ctx.lineJoin="round";ctx.lineCap="round"; |
| const ink=c,fill="#0c1118",plate="#11202b",gold="#e6c870";ctx.lineWidth=2; |
| // sashimono back banner |
| ctx.strokeStyle=ink;ctx.beginPath();ctx.moveTo(14,-6);ctx.lineTo(20,-54);ctx.stroke(); |
| ctx.fillStyle=ink;ctx.globalAlpha=.22;ctx.beginPath();ctx.moveTo(20,-54);ctx.lineTo(40,-50);ctx.lineTo(38,-30);ctx.lineTo(20,-34);ctx.closePath();ctx.fill(); |
| ctx.globalAlpha=1;ctx.strokeStyle=gold;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(20,-54);ctx.lineTo(40,-50);ctx.lineTo(38,-30);ctx.lineTo(20,-34);ctx.closePath();ctx.stroke(); |
| // hakama legs |
| ctx.lineWidth=2;ctx.strokeStyle=ink;ctx.fillStyle=fill; |
| ctx.beginPath();ctx.moveTo(-10,40);ctx.lineTo(-8,18);ctx.lineTo(0,16);ctx.lineTo(-2,40);ctx.closePath();ctx.fill();ctx.stroke(); |
| ctx.beginPath();ctx.moveTo(10,40);ctx.lineTo(8,18);ctx.lineTo(0,16);ctx.lineTo(2,40);ctx.closePath();ctx.fill();ctx.stroke(); |
| // torso armor (do) |
| ctx.fillStyle=plate;ctx.beginPath();ctx.moveTo(-12,2);ctx.quadraticCurveTo(-14,-14,0,-18);ctx.quadraticCurveTo(14,-14,12,2);ctx.lineTo(10,20);ctx.lineTo(-10,20);ctx.closePath();ctx.fill();ctx.stroke(); |
| ctx.strokeStyle=ink;ctx.globalAlpha=.7;for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(-11,2+i*6);ctx.lineTo(11,2+i*6);ctx.stroke();}ctx.globalAlpha=1; |
| // sode shoulder guards |
| ctx.fillStyle=plate; |
| ctx.beginPath();ctx.moveTo(-12,-12);ctx.lineTo(-22,-8);ctx.lineTo(-20,6);ctx.lineTo(-12,2);ctx.closePath();ctx.fill();ctx.stroke(); |
| ctx.beginPath();ctx.moveTo(12,-12);ctx.lineTo(22,-8);ctx.lineTo(20,6);ctx.lineTo(12,2);ctx.closePath();ctx.fill();ctx.stroke(); |
| // head + menpo |
| ctx.fillStyle=fill;ctx.beginPath();ctx.arc(0,-26,8,0,7);ctx.fill();ctx.stroke(); |
| ctx.strokeStyle=ink;ctx.beginPath();ctx.moveTo(-5,-24);ctx.lineTo(5,-24);ctx.stroke(); |
| // kabuto bowl |
| ctx.fillStyle=plate;ctx.beginPath();ctx.arc(0,-30,10,Math.PI,0);ctx.lineTo(9,-30);ctx.lineTo(-9,-30);ctx.closePath();ctx.fill();ctx.stroke(); |
| // golden crescent crest |
| ctx.strokeStyle=gold;ctx.lineWidth=2.4; |
| ctx.beginPath();ctx.moveTo(-7,-38);ctx.quadraticCurveTo(-18,-52,-4,-46);ctx.stroke(); |
| ctx.beginPath();ctx.moveTo(7,-38);ctx.quadraticCurveTo(18,-52,4,-46);ctx.stroke(); |
| ctx.fillStyle=gold;ctx.beginPath();ctx.arc(0,-44,2.2,0,7);ctx.fill(); |
| // arm + raised katana |
| ctx.strokeStyle=ink;ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(8,-4);ctx.lineTo(16,-2);ctx.stroke(); |
| ctx.strokeStyle="#cfe8ff";ctx.lineWidth=2.6;ctx.beginPath();ctx.moveTo(16,-2);ctx.lineTo(52,-44);ctx.stroke(); |
| ctx.strokeStyle=gold;ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(13,2);ctx.lineTo(18,-4);ctx.stroke(); |
| ctx.restore(); |
| } |
| function adversary(g,c){// the Environment: a detailed oni |
| aura(g,c,66); |
| ctx.save();ctx.translate(g.x,g.y);ctx.scale(1.15,1.15);ctx.lineJoin="round";ctx.lineCap="round";ctx.lineWidth=2; |
| const ink=c,fill="#0c1118",hide="#241016"; |
| ctx.fillStyle=hide;ctx.strokeStyle=ink; |
| ctx.beginPath();ctx.moveTo(-22,40);ctx.quadraticCurveTo(-30,-2,-14,-14);ctx.quadraticCurveTo(0,-22,14,-14);ctx.quadraticCurveTo(30,-2,22,40);ctx.closePath();ctx.fill();ctx.stroke(); |
| ctx.globalAlpha=.5;ctx.beginPath();ctx.moveTo(-10,6);ctx.quadraticCurveTo(0,12,10,6);ctx.stroke();ctx.beginPath();ctx.moveTo(-8,18);ctx.quadraticCurveTo(0,24,8,18);ctx.stroke();ctx.globalAlpha=1; |
| // arms + claws |
| ctx.beginPath();ctx.moveTo(-18,-6);ctx.lineTo(-30,12);ctx.lineTo(-26,20);ctx.stroke(); |
| ctx.beginPath();ctx.moveTo(18,-6);ctx.lineTo(30,12);ctx.lineTo(26,20);ctx.stroke(); |
| for(let i=-1;i<2;i++){ctx.beginPath();ctx.moveTo(-27+i*2,20);ctx.lineTo(-29+i*2,26);ctx.stroke();} |
| for(let i=-1;i<2;i++){ctx.beginPath();ctx.moveTo(27+i*2,20);ctx.lineTo(29+i*2,26);ctx.stroke();} |
| // head + horns |
| ctx.fillStyle=fill;ctx.beginPath();ctx.arc(0,-24,11,0,7);ctx.fill();ctx.stroke(); |
| ctx.strokeStyle=ink;ctx.lineWidth=2.4; |
| ctx.beginPath();ctx.moveTo(-8,-32);ctx.quadraticCurveTo(-18,-46,-12,-50);ctx.stroke(); |
| ctx.beginPath();ctx.moveTo(8,-32);ctx.quadraticCurveTo(18,-46,12,-50);ctx.stroke(); |
| // eyes + fangs |
| ctx.fillStyle=c;ctx.beginPath();ctx.arc(-4,-25,2.2,0,7);ctx.fill();ctx.beginPath();ctx.arc(4,-25,2.2,0,7);ctx.fill(); |
| ctx.strokeStyle="#cfe8ff";ctx.lineWidth=1.4;ctx.beginPath();ctx.moveTo(-4,-18);ctx.lineTo(-3,-14);ctx.stroke();ctx.beginPath();ctx.moveTo(4,-18);ctx.lineTo(3,-14);ctx.stroke(); |
| // tetsubo iron club |
| ctx.strokeStyle=ink;ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(26,16);ctx.lineTo(40,-20);ctx.stroke(); |
| ctx.fillStyle=hide;ctx.beginPath();ctx.ellipse(42,-24,6,9,-0.5,0,7);ctx.fill();ctx.stroke(); |
| ctx.fillStyle=ink;for(let i=0;i<4;i++){ctx.beginPath();ctx.arc(40+(i%2)*3,-22-i*2,1,0,7);ctx.fill();} |
| ctx.restore(); |
| } |
| function drawFallen(f){ctx.save();ctx.translate(f.x,f.y);ctx.scale(1.2,1.2);ctx.lineJoin="round";ctx.lineCap="round"; |
| ctx.strokeStyle="#ff7a59";ctx.lineWidth=1.6;ctx.globalAlpha=.9; |
| // prone body |
| ctx.beginPath();ctx.moveTo(-14,6);ctx.quadraticCurveTo(-2,10,12,8);ctx.stroke(); |
| ctx.beginPath();ctx.arc(-16,4,4,0,7);ctx.stroke();// head |
| ctx.beginPath();ctx.moveTo(-4,7);ctx.lineTo(-2,1);ctx.stroke();// bent arm |
| // broken banner |
| ctx.beginPath();ctx.moveTo(8,8);ctx.lineTo(11,-10);ctx.stroke(); |
| ctx.fillStyle="#ff7a59";ctx.globalAlpha=.3;ctx.beginPath();ctx.moveTo(11,-10);ctx.lineTo(20,-8);ctx.lineTo(18,-2);ctx.lineTo(11,-4);ctx.closePath();ctx.fill(); |
| ctx.globalAlpha=1;ctx.restore();} |
| function drawDragon(f,dt){f.grow=Math.min(1,f.grow+dt*1.5);const s=f.grow*2.2;ctx.save();ctx.translate(f.x,f.y);ctx.scale(s,s);ctx.lineJoin="round";ctx.lineCap="round"; |
| const gold="#e6c870",dark="#0c1118",bright="#f3dd9a"; |
| // filled tapering coil body |
| ctx.fillStyle=dark;ctx.strokeStyle=gold;ctx.lineWidth=2.4; |
| ctx.beginPath();ctx.moveTo(36,-2);ctx.bezierCurveTo(20,-20,-6,-16,-16,2);ctx.bezierCurveTo(-24,16,-6,30,8,24);ctx.bezierCurveTo(2,20,-4,12,4,8);ctx.bezierCurveTo(14,2,26,8,30,2);ctx.closePath();ctx.fill();ctx.stroke(); |
| // back ridge highlight |
| ctx.strokeStyle=bright;ctx.lineWidth=1;ctx.globalAlpha=.8;ctx.beginPath();ctx.moveTo(34,-2);ctx.bezierCurveTo(18,-15,-4,-11,-12,3);ctx.stroke();ctx.globalAlpha=1; |
| // dorsal spikes |
| ctx.strokeStyle=gold;ctx.lineWidth=2; |
| for(const p of [[28,-6],[16,-12],[2,-11],[-9,-4]]){ctx.beginPath();ctx.moveTo(p[0],p[1]);ctx.lineTo(p[0]+2,p[1]-8);ctx.stroke();} |
| // head |
| ctx.fillStyle=dark;ctx.beginPath();ctx.moveTo(36,-2);ctx.lineTo(52,-8);ctx.lineTo(48,-2);ctx.lineTo(54,2);ctx.lineTo(44,6);ctx.lineTo(36,4);ctx.closePath();ctx.fill();ctx.stroke(); |
| ctx.beginPath();ctx.moveTo(46,-6);ctx.lineTo(50,-16);ctx.stroke();// horn |
| ctx.beginPath();ctx.moveTo(52,-2);ctx.quadraticCurveTo(64,0,66,8);ctx.stroke();// whisker |
| ctx.fillStyle=bright;ctx.beginPath();ctx.arc(45,-1,1.6,0,7);ctx.fill();// eye |
| ctx.strokeStyle=bright;ctx.beginPath();ctx.moveTo(52,2);ctx.lineTo(50,5);ctx.stroke();// fang |
| // clawed foot |
| ctx.strokeStyle=gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(-6,26);ctx.lineTo(-9,33);ctx.moveTo(-1,27);ctx.lineTo(-1,34);ctx.moveTo(4,26);ctx.lineTo(7,33);ctx.stroke(); |
| ctx.restore();} |
| function frame(ts){if(!W||!H)resize();if(!lastTs)lastTs=ts;const dt=Math.min(0.05,(ts-lastTs)/1000);lastTs=ts; |
| if(running){clock+=dt;while(idx<EVENTS.length&&EVENTS[idx].t<=clock){play(EVENTS[idx]);idx++;} |
| if(idx>=EVENTS.length&&particles.length===0&&bolts.length===0)running=false;} |
| ctx.clearRect(0,0,W,H); |
| for(const s of stars){ctx.globalAlpha=s.a;ctx.fillStyle="#fff";ctx.fillRect(s.x*W,s.y*H,s.r,s.r);}ctx.globalAlpha=1; |
| const y0=H*0.84,x0=W*0.1,x1=W*0.9,g=ctx.createLinearGradient(x0,0,x1,0);g.addColorStop(0,"#6ee7ff");g.addColorStop(1,"#ff7a59"); |
| ctx.strokeStyle=g;ctx.globalAlpha=.45;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x0,y0);ctx.lineTo(x1,y0);ctx.stroke();ctx.globalAlpha=1; |
| for(const m of markers){m.r=Math.min(4,m.r+dt*16);const mx=x0+(m.t/span)*(x1-x0);ctx.fillStyle=m.color;ctx.beginPath();ctx.arc(mx,y0,m.r,0,7);ctx.fill();} |
| general(godA(),"#6ee7ff");adversary(godE(),"#ff7a59"); |
| for(const f of figures){if(f.type==="fallen")drawFallen(f);else if(f.type==="dragon")drawDragon(f,dt);} |
| for(let i=bolts.length-1;i>=0;i--){const b=bolts[i];b.life-=dt*4;if(b.life<=0){bolts.splice(i,1);continue;} |
| ctx.strokeStyle=b.color;ctx.globalAlpha=Math.max(0,b.life);ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(b.from.x,b.from.y); |
| const mx=(b.from.x+b.to.x)/2+(Math.random()*8-4),my=(b.from.y+b.to.y)/2+(Math.random()*8-4);ctx.quadraticCurveTo(mx,my,b.to.x,b.to.y);ctx.stroke();ctx.globalAlpha=1;} |
| for(let i=rings.length-1;i>=0;i--){const r=rings[i];r.r+=dt*90;r.life-=dt*1.8;if(r.life<=0){rings.splice(i,1);continue;} |
| ctx.strokeStyle=r.color;ctx.globalAlpha=Math.max(0,r.life);ctx.lineWidth=2;ctx.beginPath();ctx.arc(r.x,r.y,r.r,0,7);ctx.stroke();ctx.globalAlpha=1;} |
| for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.life-=dt*1.5;p.vy+=dt*30;p.x+=p.vx*dt;p.y+=p.vy*dt; |
| if(p.life<=0){particles.splice(i,1);continue;}ctx.fillStyle=p.color;ctx.globalAlpha=Math.max(0,p.life);ctx.fillRect(p.x,p.y,p.size,p.size);ctx.globalAlpha=1;} |
| requestAnimationFrame(frame);} |
| document.getElementById("replay").onclick=()=>{idx=0;clock=0;hpA=100;hpE=100;particles=[];bolts=[];rings=[];markers=[];figures=[];running=true;lastTs=0;}; |
| window.addEventListener("load",resize); |
| setTimeout(resize,50); |
| resize();requestAnimationFrame(frame); |
| </script></body></html>""" |
|
|
|
|
| def render_battle_trace(extraction: dict, height: int = 420) -> str: |
| """Return an iframe hosting the Battle Trace replay for this extraction. |
| |
| Sandboxed iframe so it cannot affect the main app. Best-effort: any failure |
| returns an empty string and the accordion simply shows nothing. |
| """ |
| try: |
| events = extraction_to_events(extraction) |
| doc = _BATTLE_HTML.replace("__EVENTS_JSON__", json.dumps(events)) |
| escaped = html.escape(doc, quote=True) |
| return ( |
| f'<iframe srcdoc="{escaped}" ' |
| f'style="width:100%;height:{height}px;border:1px solid #1c2430;' |
| f'border-radius:10px;background:#0a0e14;" ' |
| f'sandbox="allow-scripts"></iframe>' |
| ) |
| except Exception: |
| return "" |
|
|