loop-canvas 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # Loop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A canvas of live screens for your coding agent. Point at anything, draw on it, comment, and the agent that launched Loop (Claude Code or Codex) updates the screen while the frame shows it working.
4
+
5
+ Ask your agent:
6
+
7
+ > Run `npx skills add amalenezi/loop-skill -g -y`, then use the Loop canvas skill to put this project's screens on a canvas.
8
+
9
+ Or by hand: `npm install -g loop-canvas`, then `loop scan` in your project.
package/cli.mjs ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import h from"node:fs";import i from"node:path";import U from"node:os";import{spawn as te,execFileSync as B}from"node:child_process";import{fileURLToPath as oe}from"node:url";import g from"node:fs";import p from"node:path";import{spawn as z,execFileSync as F}from"node:child_process";var V=new Set(["node_modules",".git",".loop",".next",".nuxt",".svelte-kit",".output",".vercel","dist","build","out","coverage",".turbo",".cache"]);function k(e,t=6,o=[]){if(t<0||!g.existsSync(e))return o;for(let r of g.readdirSync(e,{withFileTypes:!0})){if(V.has(r.name)||r.name.startsWith("."))continue;let n=p.join(e,r.name);r.isDirectory()?k(n,t-1,o):o.push(n)}return o}var j=(e,...t)=>t.map(o=>p.join(e,o)).find(o=>g.existsSync(o)&&g.statSync(o).isDirectory()),Y=e=>/^\[|^\$|^:|\*/.test(e);function E(e){let t=[];for(let o of e)if(!(!o||o==="index"||o==="_index")&&!(/^\(.*\)$/.test(o)||o.startsWith("@")||o.startsWith("_"))&&!/^\[\[\.\.\..+\]\]$/.test(o)){if(Y(o))return null;t.push(o)}return"/"+t.join("/")}var W={"next-app":e=>{let t=j(e,"app","src/app");return t?k(t).filter(o=>/[\\/]page\.(t|j)sx?$|[\\/]page\.mdx$/.test(o)).map(o=>E(p.relative(t,p.dirname(o)).split(p.sep))):null},"next-pages":e=>{let t=j(e,"pages","src/pages");return t?k(t).filter(o=>/\.(t|j)sx?$|\.mdx$/.test(o)).map(o=>p.relative(t,o).replace(/\.(t|j)sx?$|\.mdx$/,"")).filter(o=>!o.startsWith("api")&&!/^_(app|document|error)$/.test(o)&&!/^(404|500)$/.test(o)).map(o=>E(o.split(p.sep))):null},sveltekit:e=>{let t=j(e,"src/routes");return t?k(t).filter(o=>/\+page\.svelte$/.test(o)).map(o=>E(p.relative(t,p.dirname(o)).split(p.sep))):null},nuxt:e=>{let t=j(e,"pages","app/pages");return t?k(t).filter(o=>o.endsWith(".vue")).map(o=>E(p.relative(t,o).replace(/\.vue$/,"").split(p.sep))):null},astro:e=>{let t=j(e,"src/pages");return t?k(t).filter(o=>/\.(astro|md|mdx|html)$/.test(o)).map(o=>E(p.relative(t,o).replace(/\.(astro|md|mdx|html)$/,"").split(p.sep))):null},remix:e=>{let t=j(e,"app/routes");return t?k(t,1).filter(o=>/\.(t|j)sx?$/.test(o)).map(o=>p.relative(t,o).replace(/\/route\.(t|j)sx?$|\.(t|j)sx?$/,"")).filter(o=>!/^(api|resources)\b/.test(o)&&!o.includes("[.")).map(o=>E(o.split("."))):null},spa:e=>{let t=j(e,"src","app")||e,o=new Set(["/"]),r=[/\bpath\s*:\s*["'`]([^"'`]*)["'`]/g,/<Route\b[^>]*\bpath=["']([^"']*)["']/g,/createRoute\([^)]*path\s*:\s*["']([^"']*)["']/g];for(let n of k(t).filter(a=>/\.(t|j)sx?$|\.vue$/.test(a)&&!/\.(test|spec|stories)\./.test(a)).slice(0,2e3)){let a;try{a=g.readFileSync(n,"utf8")}catch{continue}if(!(a.length>4e5))for(let c of r)for(let m of a.matchAll(c)){let f=m[1].trim();f===""||f.includes(":")||f.includes("*")||f.includes("$")||/\s/.test(f)||f.length>80||!f.startsWith("/")&&/\.\w{2,4}$/.test(f)||o.add("/"+f.replace(/^\/+/,""))}}return[...o]}};function G(e){let t=null;try{t=JSON.parse(g.readFileSync(p.join(e,"package.json"),"utf8"))}catch{}let o={...t?.dependencies||{},...t?.devDependencies||{}},r=l=>l in o,n=null,a=null,c=null;r("next")?(n="Next.js",a=j(e,"app","src/app")?"next-app":"next-pages",c=3e3):r("@sveltejs/kit")?(n="SvelteKit",a="sveltekit",c=5173):r("nuxt")?(n="Nuxt",a="nuxt",c=3e3):r("astro")?(n="Astro",a="astro",c=4321):r("@remix-run/react")||r("@remix-run/dev")?(n="Remix",a="remix",c=5173):r("@angular/core")?(n="Angular",a="spa",c=4200):r("react-router")||r("react-router-dom")||r("@tanstack/react-router")||r("vue-router")?(n="SPA",a="spa",c=r("vite")?5173:3e3):r("vite")?(n="Vite",a="spa",c=5173):r("react-scripts")?(n="Create React App",a="spa",c=3e3):(r("expo")||r("react-native"))&&(n="React Native");let m=[],f=0;if(a){let l=[...W[a](e)||[]];a==="next-app"&&j(e,"pages","src/pages")&&l.push(...W["next-pages"](e)||[]),f=l.filter($=>$==null).length,m=l.filter($=>$!=null)}m=[...new Set(m)].sort((l,$)=>l==="/"?-1:$==="/"?1:l.split("/").length-$.split("/").length||l.localeCompare($));let b=!a&&n!=="React Native"?k(e,4).filter(l=>l.endsWith(".html")).slice(0,60):[],w=t?.scripts||{},_=["dev","start","serve"].find(l=>w[l]),T=g.existsSync(p.join(e,"pnpm-lock.yaml"))?"pnpm":g.existsSync(p.join(e,"yarn.lock"))?"yarn":g.existsSync(p.join(e,"bun.lockb"))||g.existsSync(p.join(e,"bun.lock"))?"bun":"npm";return{framework:n,router:a,routes:m,dynamic:f,html:b,port:c,script:_,pm:T,hasPackage:!!t}}function R(e){return e==="/"?"Home":e.split("/").filter(Boolean).map(t=>t.replace(/[-_]+/g," ").replace(/^\w/,o=>o.toUpperCase())).join(" / ")}async function Q(e){try{return(await fetch(`http://localhost:${e}/__loop/api/ping`,{signal:AbortSignal.timeout(800)})).ok}catch{return!1}}async function M(e){try{return(await fetch(e,{signal:AbortSignal.timeout(1200),redirect:"manual"})).status<500}catch{return!1}}async function H(e,t,{url:o,log:r=console.error}={}){try{e=g.realpathSync(e)}catch{}if(o)return{url:o.replace(/\/$/,""),started:!1};for(let w of Z())if(w.cwd&&(w.cwd===e||w.cwd.startsWith(e+p.sep))&&!await Q(w.port)&&await M(`http://localhost:${w.port}`))return{url:`http://localhost:${w.port}`,started:!1,reused:!0};if(!t.script)return null;let n=p.join(e,".loop");g.mkdirSync(n,{recursive:!0});let a=p.join(n,"dev.log");g.writeFileSync(a,"");let c=g.openSync(a,"a"),m=t.pm==="pnpm"&&!ee("pnpm")?["corepack",["pnpm","run",t.script]]:[t.pm,["run",t.script]];r(`starting the dev server: ${m[0]} ${m[1].join(" ")} (log: .loop/dev.log)`);let f=z(m[0],m[1],{cwd:e,detached:!0,stdio:["ignore",c,c],env:{...process.env,BROWSER:"none",FORCE_COLOR:"0"}});f.unref(),g.writeFileSync(p.join(n,"dev.json"),JSON.stringify({pid:f.pid,cmd:m.flat().join(" ")}));let b=Date.now()+12e4;for(;Date.now()<b;){await new Promise(T=>setTimeout(T,700));let w=g.readFileSync(a,"utf8").replace(/\x1b\[[0-9;]*m/g,""),_=w.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d+)/);if(_&&await M(`http://localhost:${_[1]}`))return{url:`http://localhost:${_[1]}`,started:!0,pid:f.pid};try{process.kill(f.pid,0)}catch{throw new Error(`the dev server exited \u2014 see .loop/dev.log:
3
+ ${w.split(`
4
+ `).slice(-12).join(`
5
+ `)}`)}}throw new Error("the dev server took more than 2 minutes to start \u2014 see .loop/dev.log")}function Z(){try{let e=F("lsof",["-nP","-iTCP","-sTCP:LISTEN","-Fpn"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}),t=new Map,o=null;for(let n of e.split(`
6
+ `))if(n[0]==="p")o=n.slice(1);else if(n[0]==="n"&&o){let a=n.match(/:(\d+)$/);a&&(t.get(o)||t.set(o,new Set).get(o)).add(+a[1])}let r=[];for(let[n,a]of t){let c=null;try{c=F("lsof",["-a","-p",n,"-d","cwd","-Fn"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).split(`
7
+ `).find(m=>m[0]==="n")?.slice(1)||null}catch{}if(c)try{c=g.realpathSync(c)}catch{}for(let m of a)m>=1024&&m!==4848&&r.push({port:m,pid:+n,cwd:c})}return r}catch{return[]}}function ee(e){return(process.env.PATH||"").split(":").some(t=>{try{return g.accessSync(p.join(t,e),g.constants.X_OK),!0}catch{return!1}})}var P=i.dirname(h.realpathSync(oe(import.meta.url))),[ne="help",...re]=process.argv.slice(2),{flags:s,pos:d}=ae(re),se=`loop \u2014 a canvas of live frames you can draw on, point at, and comment on.
8
+ Comments go to your agent; the frame shows it working and reloads when it's done.
9
+
10
+ loop open start the canvas for this project and open it
11
+ loop scan [--url --mobile --landscape --both --w W --h H] [--all --limit N]
12
+ find every screen in this project and put it on the canvas
13
+ (starts the dev server if needed; pages render live, element-pickable)
14
+ loop add-route </path> [--name] add one app route with real values, e.g. /posts/hello-world
15
+ loop new <name> [--w --h] create an empty frame folder, print its path (write index.html there)
16
+ loop add <path|url> [--name --w --h --x --y]
17
+ place a folder, an HTML file, or a running page (http://localhost:3000/x)
18
+ loop create "<brief>" [--name] have the agent build a brand-new frame from a prompt
19
+ loop list frames, their folders and status
20
+ loop rm <frameId> remove a frame from the canvas (files stay)
21
+ loop share [--public] print the share link (LAN; --public opens a cloudflared tunnel)
22
+ loop agent headless|inbox headless: Loop runs the agent that launched it (Claude Code or Codex) on each comment
23
+ inbox: the launching chat handles comments (via loop watch)
24
+ loop watch send this canvas's comments to the current chat (run as a background watcher)
25
+ loop note <commentId> "<msg>" add a message to a comment thread on the canvas
26
+ loop thread <commentId> print a comment thread
27
+ loop inbox [--wait] claim the next comment as JSON (inbox mode)
28
+ loop step <commentId> "<text>" show progress on the frame's title
29
+ loop reply <commentId> "<msg>" [--error] [--model <model id>] finish a comment; the frame reloads
30
+ loop stop stop the canvas server
31
+ loop install-skill install the Claude Code skill (~/.claude/skills/loop-canvas)
32
+
33
+ Options: --dir <path> uses that .loop folder instead of ./.loop (searched upward).`;function ae(e){let t={},o=[];for(let r=0;r<e.length;r++)if(e[r].startsWith("--")){let n=e[r].slice(2);t[n]=e[r+1]&&!e[r+1].startsWith("--")?e[++r]:!0}else o.push(e[r]);return{flags:t,pos:o}}function ie(){if(s.dir)return i.resolve(s.dir);let e=process.cwd();for(;;){if(h.existsSync(i.join(e,".loop","canvas.json"))||h.existsSync(i.join(e,".loop","server.json")))return i.join(e,".loop");let t=i.dirname(e);if(t===e)return i.join(process.cwd(),".loop");e=t}}var S=ie();async function A(e){try{return(await fetch(`http://127.0.0.1:${e}/__loop/api/ping`,{signal:AbortSignal.timeout(800)})).ok}catch{return!1}}function N(){try{return JSON.parse(h.readFileSync(i.join(S,"server.json"),"utf8"))}catch{return null}}function le(){let e=String(s.agent||process.env.LOOP_AGENT||"").toLowerCase();if(e.includes("codex"))return"codex";if(e.includes("claude"))return"claude";let t=Object.keys(process.env);if(process.env.CLAUDECODE||process.env.CLAUDE_CODE_ENTRYPOINT)return"claude";if(t.some(o=>o.startsWith("CODEX_")))return"codex";try{let o=process.ppid;for(let r=0;r<14&&o>1;r++){let n=B("ps",["-o","ppid=,command=","-p",String(o)],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim(),a=/^(\d+)\s+(.*)$/.exec(n);if(!a)break;let c=a[2].toLowerCase();if(/(^|\/)codex(\s|$)|codex-cli|codex\.app/.test(c))return"codex";if(/(^|\/)claude(\s|$)|claude-code|claude\.app/.test(c))return"claude";o=+a[1]}}catch{}return null}var J=!1;async function L(){if(J)return;J=!0;let e=le();if(e)try{await u("POST","/__loop/api/settings",{runner:e})}catch{}}function ce(){return(process.env.PATH||"").split(":").some(e=>{try{return h.realpathSync(i.join(e,"loop"))===h.realpathSync(i.join(P,"cli.mjs"))}catch{return!1}})}async function O(){let e=N();if(e&&await A(e.port))return e.port;h.mkdirSync(S,{recursive:!0}),pe();let t=h.openSync(i.join(S,"server.log"),"a"),o=te(process.execPath,[i.join(P,"server.mjs"),"--dir",S,...s.port?["--port",String(s.port)]:[]],{detached:!0,stdio:["ignore",t,t]});o.unref();for(let r=0;r<50;r++){await new Promise(a=>setTimeout(a,100));let n=N();if(n&&n.pid===o.pid&&await A(n.port))return n.port}throw new Error(`Loop server didn't start \u2014 see ${i.join(S,"server.log")}`)}function pe(){let e=i.join(S,".gitignore");h.existsSync(e)||h.writeFileSync(e,`server.json
34
+ server.log
35
+ history/
36
+ annotations/
37
+ *.tmp
38
+ `)}async function u(e,t,o){let r=await O(),n=await fetch(`http://127.0.0.1:${r}${t}`,{method:e,headers:{"content-type":"application/json"},body:o?JSON.stringify(o):void 0}),a=await n.json().catch(()=>({}));if(!n.ok)throw new Error(a.error||`HTTP ${n.status}`);return a}var v=e=>e==null||e===!0?void 0:+e;function K(e){try{B(process.platform==="darwin"?"open":"xdg-open",[e],{stdio:"ignore"})}catch{}}async function de(){switch(ne){case"open":case"start":{let e=await O();await L();let t=`http://localhost:${e}/__loop/`;console.log(`Loop canvas \u25B8 ${t}`),s["no-open"]||K(t);break}case"new":{await O(),await L();let e=d.join(" ")||"Untitled",t=await u("POST","/__loop/api/frames",{new:!0,name:e,w:v(s.w),h:v(s.h),x:v(s.x),y:v(s.y),near:s.near});console.log(t.dir),console.error(`frame ${t.id} "${t.name}" \u2014 write ${i.join(t.dir,"index.html")}; the canvas reloads it live`);break}case"add":{if(!d[0])throw new Error("usage: loop add <path|url> [--name ...]");let e=/^https?:\/\//.test(d[0])?d[0]:i.resolve(d[0]),t=await u("POST","/__loop/api/frames",{src:e,name:s.name,w:v(s.w),h:v(s.h),x:v(s.x),y:v(s.y)});console.log(`added ${t.id} "${t.name}" (${t.kind}: ${t.src})`);break}case"create":{let e=d.join(" ");if(!e)throw new Error('usage: loop create "<brief>"');let t=await u("POST","/__loop/api/frames",{prompt:e,name:s.name,w:v(s.w),h:v(s.h)});console.log(`frame ${t.id} "${t.name}" \u2014 the agent is building it in ${t.dir}`);break}case"list":case"ls":{let e=await u("GET","/__loop/api/state");if(!e.frames.length)return console.log("No frames yet.");for(let t of e.frames){let o=e.live[t.id],r=e.comments.filter(n=>n.frameId===t.id&&["queued","waiting","working"].includes(n.status)).length;console.log(`${t.id} ${t.name.padEnd(28)} ${o?`\u27F3 ${o.step}`:r?`${r} open`:"\xB7"} ${t.src}`)}break}case"rm":await u("DELETE",`/__loop/api/frames/${d[0]}`),console.log("removed");break;case"share":{let e=(await u("GET","/__loop/api/state")).share;s.public&&(e=await u("POST","/__loop/api/share/public")),e.lan&&console.log(`Same network: ${e.lan}`),e.public?console.log(`Public: ${e.public}`):s.public||console.log(e.tunnelAvailable?"Public link: loop share --public":"Public link: brew install cloudflared, then loop share --public");break}case"agent":{if(!["headless","inbox"].includes(d[0]))throw new Error("usage: loop agent headless|inbox");await u("POST","/__loop/api/settings",{mode:d[0]}),console.log(`agent mode: ${d[0]}`);break}case"watch":{await O(),await L(),await u("POST","/__loop/api/settings",{mode:"inbox"});let e=i.join(S,"inbox");h.mkdirSync(e,{recursive:!0});let t=ce()?"loop":`node ${i.join(P,"cli.mjs")}`;for(console.log("Loop is sending this canvas's comments to this chat. Each comment arrives as a line starting with COMMENT.");;){let o;try{o=await u("GET","/__loop/api/inbox?wait=1")}catch{await new Promise(n=>setTimeout(n,2e3));continue}if(!o.comment)continue;let r=i.join(e,`${o.comment.id}.md`);h.writeFileSync(r,o.prompt),console.log(`COMMENT ${o.comment.id} on "${o.frame.name}": ${JSON.stringify(o.comment.text)} | full instructions: ${r} | when done: ${t} reply ${o.comment.id} "<one sentence on what you changed>" --model <your model id>`)}}case"note":{if(!d[0]||!d[1])throw new Error('usage: loop note <commentId> "<message>" [--model <id>] (adds your message to that comment thread on the canvas)');await u("POST",`/__loop/api/comments/${d[0]}/note`,{text:d.slice(1).join(" "),model:s.model}),console.log("noted on the canvas");break}case"thread":{let e=await u("GET","/__loop/api/state"),t=e.comments.find(r=>r.id===d[0]);if(!t)throw new Error("no such comment");let o=t.parentId||t.id;for(let r of e.comments.filter(n=>n.id===o||n.parentId===o).sort((n,a)=>n.createdAt-a.createdAt))console.log(`[${r.id}] ${r.author?.agent?"Agent":r.author?.name||"User"}: ${r.text}${r.reply?`
39
+ \u21B3 ${r.reply}`:""}`);break}case"inbox":{let e=await u("GET",`/__loop/api/inbox${s.wait?"?wait=1":""}`);console.log(JSON.stringify(e,null,2));break}case"step":await u("POST",`/__loop/api/comments/${d[0]}/step`,{text:d.slice(1).join(" ")});break;case"reply":{await u("POST",`/__loop/api/comments/${d[0]}/reply`,{text:d.slice(1).join(" ")||"Done.",error:!!s.error,model:s.model}),console.log("replied");break}case"add-route":{if(!d[0])throw new Error("usage: loop add-route </path> [--name ...] (needs a dev server \u2014 run loop scan first)");if(!(await u("GET","/__loop/api/state")).proxy)throw new Error("no dev server is connected yet \u2014 run loop scan (or loop scan --url http://localhost:PORT) first");let t=await u("POST","/__loop/api/frames",{route:d[0],name:s.name||R(d[0]),w:v(s.w),h:v(s.h),near:s.near});console.log(`added ${t.id} "${t.name}" (route ${t.src})`);break}case"scan":{let e=i.dirname(S);await O(),await L();let t=G(e);if(t.framework==="React Native")throw new Error("this looks like a React Native app \u2014 it can't render in a browser canvas yet");console.log(`${t.framework||(t.html.length?"Static HTML":"No framework found")} \xB7 ${t.routes.length||t.html.length} screen${(t.routes.length||t.html.length)===1?"":"s"}${t.dynamic?` (+${t.dynamic} that need a URL parameter, skipped)`:""}`),await O();let o=await u("GET","/__loop/api/state"),n=(v(s.w)&&v(s.h)?[[v(s.w),v(s.h)]]:null)||(s.landscape?[[852,393]]:s.mobile?[[393,852]]:s.both?[[1440,1024],[393,852]]:[[1440,1024]]),a=Math.max(...n.map(l=>l[0]))+(n.length>1?n[1][0]+80:0),c=Math.max(...n.map(l=>l[1])),m=+s.cols||(n[0][0]<600?6:4),f=o.frames.length?Math.max(...o.frames.map(l=>l.y+l.h))+280:0,b=+s.limit||40,w=0,_=(l,$)=>({x:l%m*(a+160)+($?n[0][0]+80:0),y:f+Math.floor(l/m)*(c+220)});if(t.routes.length){let l=await H(e,t,{url:s.url});if(!l)throw new Error("couldn't find or start a dev server. Start it yourself, then run: loop scan --url http://localhost:<port>");l.reused&&console.log(`using this project's dev server at ${l.url}`),await u("POST","/__loop/api/settings",{proxy:l.url});let $=new Set(o.frames.filter(x=>x.kind==="route").map(x=>`${x.src}@${x.w}`)),y=t.routes.includes("/")?"/":t.routes[0],C=s.all?t.routes.slice(0,b):[y];!s.all&&t.routes.length>1&&(console.log(`placed the app as one live frame (${y}). Click around inside it; duplicate it (\u2318D or \u2325-drag the title) to keep a screen side by side.`),console.log(`other screens: ${t.routes.filter(x=>x!==y).slice(0,12).join(" ")}${t.routes.length>13?" \u2026":""}`),console.log("add one directly: loop add-route /path \xB7 every screen at once: loop scan --all")),C.forEach((x,X)=>n.forEach(([I,q],D)=>{$.has(`${x}@${I}`)||(w++,u("POST","/__loop/api/frames",{route:x,name:R(x)+(n.length>1?D?" \xB7 mobile":" \xB7 desktop":""),w:I,h:q,..._(X,D)}))})),s.all&&t.routes.length>b&&console.log(`showing the first ${b} \u2014 pass --limit ${t.routes.length} for all`)}else if(t.html.length){let l=new Set(o.frames.filter(y=>y.kind==="folder").map(y=>i.join(y.src,y.entry))),$=t.html.find(y=>/(^|\/)index\.html?$/i.test(i.relative(e,y))&&!i.relative(e,y).includes("/"))||t.html[0];!s.all&&t.html.length>1&&console.log(`placed ${i.relative(e,$)} as one frame. Click through it, or add another page with: loop add <file> \xB7 every page: loop scan --all`),(s.all?t.html.slice(0,b):[$]).forEach((y,C)=>{l.has(y)||(w++,u("POST","/__loop/api/frames",{src:y,name:i.relative(e,y),w:n[0][0],h:n[0][1],..._(C,0)}))})}else console.log('Nothing to scan here. Ask your agent to create frames (loop new "<name>"), or pass --url for a running app.');await new Promise(l=>setTimeout(l,300));let T=N().port;console.log(`added ${w} frame${w===1?"":"s"} \u25B8 http://localhost:${T}/__loop/`),!s["no-open"]&&w&&K(`http://localhost:${T}/__loop/`);break}case"stop":{let e=N();e&&await A(e.port)?(await fetch(`http://127.0.0.1:${e.port}/__loop/api/shutdown`,{method:"POST"}).catch(()=>{}),console.log("stopped")):console.log("not running");try{let t=JSON.parse(h.readFileSync(i.join(S,"dev.json"),"utf8"));process.kill(-t.pid,"SIGTERM"),h.rmSync(i.join(S,"dev.json")),console.log("stopped the dev server Loop started")}catch{}break}case"install-skill":{let e=i.join(U.homedir(),".claude","skills","loop-canvas");h.mkdirSync(e,{recursive:!0}),h.copyFileSync(i.join(P,"skills","loop-canvas","SKILL.md"),i.join(e,"SKILL.md"));let t=i.join(U.homedir(),".local","bin","loop");try{h.mkdirSync(i.dirname(t),{recursive:!0});try{h.unlinkSync(t)}catch{}h.symlinkSync(i.join(P,"cli.mjs"),t),h.chmodSync(i.join(P,"cli.mjs"),493)}catch(o){console.error(`(couldn't link ${t}: ${o.message})`)}console.log(`Skill installed \u2192 ${e}/SKILL.md
40
+ CLI linked \u2192 ${t}`);break}default:console.log(se)}}de().catch(e=>{console.error(`margin: ${e.message}`),process.exit(1)});
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "loop-canvas",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A canvas of live screens you can point at, draw on and comment on. Comments go straight to your coding agent (Claude Code or Codex), which updates the screen.",
5
+ "type": "module",
6
+ "bin": {
7
+ "loop": "cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "server.mjs",
12
+ "public",
13
+ "skills"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "canvas",
20
+ "design",
21
+ "agent",
22
+ "claude-code",
23
+ "codex",
24
+ "comments",
25
+ "prototype"
26
+ ],
27
+ "homepage": "https://amalenezi.github.io/loop-app/",
28
+ "license": "UNLICENSED"
29
+ }
package/public/app.css ADDED
@@ -0,0 +1 @@
1
+ :root{--paper: #F4F3EF;--grid: #D6D3CA;--surface: #FFFFFF;--surface-2: #FAF9F6;--line: #E4E1D9;--line-strong: #CFCBC1;--ink: #17171A;--ink-2: #4A4945;--muted: #8B8880;--accent: #2F5BFF;--accent-soft: rgba(47, 91, 255, .12);--mark: #E5484D;--ok: #1F9D55;--shadow: 0 1px 2px rgba(23,23,26,.06), 0 8px 24px rgba(23,23,26,.08);--shadow-lg: 0 2px 6px rgba(23,23,26,.08), 0 18px 48px rgba(23,23,26,.14);--font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;color-scheme:light}@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--paper: #141416;--grid: #2C2C30;--surface: #1D1D20;--surface-2: #232327;--line: #2E2E33;--line-strong: #3C3C42;--ink: #EDEDEA;--ink-2: #C4C3BE;--muted: #85847F;--accent: #6E8BFF;--accent-soft: rgba(110,139,255,.16);--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);--shadow-lg: 0 2px 6px rgba(0,0,0,.35), 0 18px 48px rgba(0,0,0,.5);color-scheme:dark}}:root[data-theme=dark]{--paper: #141416;--grid: #2C2C30;--surface: #1D1D20;--surface-2: #232327;--line: #2E2E33;--line-strong: #3C3C42;--ink: #EDEDEA;--ink-2: #C4C3BE;--muted: #85847F;--accent: #6E8BFF;--accent-soft: rgba(110,139,255,.16);color-scheme:dark}*{box-sizing:border-box}html,body{overscroll-behavior:none;margin:0;height:100%;overflow:hidden;background:var(--paper);color:var(--ink);font:13px/1.45 var(--font);-webkit-font-smoothing:antialiased}button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}input,textarea{font:inherit;color:inherit}kbd{font:500 10.5px/1 var(--mono);padding:2px 4px;border-radius:4px;border:1px solid var(--line);background:var(--surface-2);color:var(--ink-2)}code{font:12px/1.5 var(--mono)}.muted{color:var(--muted)}[hidden]{display:none!important}#viewport{position:fixed;inset:0;overflow:hidden;touch-action:none;user-select:none;background-color:var(--paper);background-image:radial-gradient(var(--grid) calc(var(--dot, 1px)),transparent 0);background-size:var(--gs, 24px) var(--gs, 24px);background-position:var(--gx, 0) var(--gy, 0)}#viewport[data-tool=hand],#viewport.panning{cursor:grab}#viewport.panning:active,#viewport.dragging{cursor:grabbing}#viewport[data-tool=pen],#viewport[data-tool=pen] .shield{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cpath d='M4.5 27.5l2.2-7.4L20.6 6.2a3.2 3.2 0 014.5 0l.7.7a3.2 3.2 0 010 4.5L11.9 25.3z' fill='%2317171A' stroke='white' stroke-width='2.2' stroke-linejoin='round'/%3E%3Cpath d='M18.3 8.5l5.2 5.2' stroke='white' stroke-width='1.8'/%3E%3Cpath d='M4.5 27.5l2.2-7.4 5.2 5.2z' fill='%23E5484D' stroke='white' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E") 4 28,crosshair}#viewport[data-tool=deep],#viewport[data-tool=deep] .shield{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Crect x='11' y='11' width='13' height='13' rx='2' fill='rgba(47,91,255,.15)' stroke='%232F5BFF' stroke-width='1.6' stroke-dasharray='2.6 2.2'/%3E%3Cpath d='M3 3l10.5 5.4-4.4 1.4-1.9 4.2z' fill='%2317171A' stroke='white' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E") 3 3,default}#viewport[data-tool=comment]{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath d='M3 23V8a5 5 0 015-5h10a5 5 0 015 5v6a5 5 0 01-5 5H8z' fill='%232F5BFF' stroke='white' stroke-width='2'/%3E%3C/svg%3E") 3 23,crosshair}#viewport[data-tool=erase],#viewport[data-tool=erase] .shield,#viewport[data-tool=erase] #ink path{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Ccircle cx='13' cy='13' r='11' fill='rgba(255,255,255,.35)' stroke='%2317171A' stroke-width='1.5'/%3E%3Ccircle cx='13' cy='13' r='12' fill='none' stroke='white' stroke-width='1'/%3E%3C/svg%3E") 13 13,cell}#world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}.frame{position:absolute}.frame-label{position:absolute;left:0;bottom:100%;display:flex;align-items:center;gap:6px;height:28px;padding:0 2px 6px;transform-origin:0 100%;scale:calc(1 / var(--z, 1));white-space:nowrap;color:var(--muted);font-weight:500;font-size:12.5px;cursor:default;max-width:calc(var(--fw) * var(--z, 1));min-width:0;overflow:hidden}#viewport[data-tool=select] .frame-label{cursor:move}.frame-label .name{overflow:hidden;text-overflow:ellipsis;min-width:24px;flex:0 1 auto}.frame-label .dims{flex:none}.frame.narrow .frame-label .dims,.frame.narrow .frame-label .kind{display:none}.frame .parked-hint{position:absolute;inset:0;display:none;align-items:center;justify-content:center;padding:16px;text-align:center;color:var(--muted);font:500 13px/1.4 var(--font);background:var(--surface);pointer-events:none}.frame.parked .parked-hint{display:flex}.frame.parked iframe{visibility:hidden}.frame.selected .frame-label{color:var(--accent)}.frame-label .kind{font:500 10px/1 var(--mono);color:var(--muted);padding:3px 5px;border-radius:4px;background:var(--surface);border:1px solid var(--line);text-transform:uppercase;letter-spacing:.04em;flex:0 2 auto;min-width:0;max-width:45%;overflow:hidden;text-overflow:ellipsis}.frame-label .count{font:500 11px/1 var(--mono);color:var(--accent)}.frame-label .rename{font:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--accent);border-radius:5px;padding:2px 6px;outline:0;width:220px}.frame-label .spinner{display:none;width:12px;height:12px;border-radius:50%;border:1.6px solid var(--accent-soft);border-top-color:var(--accent);animation:spin .7s linear infinite;flex:none}.frame-label .step{display:none}.frame-label .stop{display:none;font-size:11px;color:var(--muted);padding:2px 7px;border-radius:99px;border:1px solid var(--line);background:var(--surface)}.frame-label .stop:hover{color:var(--mark);border-color:var(--mark)}.frame.working .frame-label{color:var(--ink)}.frame.working .frame-label .spinner,.frame.working .frame-label .stop{display:inline-block}.frame.working .frame-label .step{display:inline;overflow:hidden;text-overflow:ellipsis;background:linear-gradient(90deg,var(--muted) 0%,var(--muted) 35%,var(--ink) 50%,var(--muted) 65%,var(--muted) 100%);background-size:250% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:shimmer 1.8s linear infinite}.frame.working .frame-label .step:before{content:"\b7 "}.frame.working .frame-label .elapsed{display:inline;font:11px/1 var(--mono);color:var(--muted)}.frame-label .elapsed{display:none}.frame-label .updated{display:none;font-size:11px;font-weight:600;color:var(--ok)}.frame.updated .frame-label .updated{display:inline;animation:fadeout 2.8s ease forwards}.frame.failed .frame-label .updated{display:inline;color:var(--mark);animation:fadeout 4s ease forwards}.frame-body{position:relative;width:100%;height:100%;background:#fff;border-radius:2px;box-shadow:0 0 0 1px var(--line),var(--shadow);overflow:hidden}.frame.selected .frame-body{box-shadow:0 0 0 calc(1.5px / var(--z, 1)) var(--accent),var(--shadow)}.frame.working .frame-body{box-shadow:0 0 0 calc(1.5px / var(--z, 1)) var(--accent-soft),var(--shadow)}.frame iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff;display:block}.frame .shield{position:absolute;inset:0}.frame.live .shield{display:none}.frame .progress{position:absolute;left:0;right:0;top:0;height:calc(3px / var(--z, 1));overflow:hidden;pointer-events:none;opacity:0;transition:opacity .2s}.frame.working .progress{opacity:1}.frame .progress:after{content:"";position:absolute;top:0;bottom:0;width:35%;background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:sweep 1.4s ease-in-out infinite}.frame .resize{position:absolute;display:none;z-index:7;--t: calc(10px / var(--z, 1))}#viewport[data-tool=select] .frame.selected .resize{display:block}.frame .rz-n,.frame .rz-s{left:var(--t);right:var(--t);height:var(--t);cursor:ns-resize}.frame .rz-n{top:calc(var(--t) / -2)}.frame .rz-s{bottom:calc(var(--t) / -2)}.frame .rz-e,.frame .rz-w{top:var(--t);bottom:var(--t);width:var(--t);cursor:ew-resize}.frame .rz-e{right:calc(var(--t) / -2)}.frame .rz-w{left:calc(var(--t) / -2)}.frame .rz-ne,.frame .rz-nw,.frame .rz-se,.frame .rz-sw{width:calc(var(--t) * 1.4);height:calc(var(--t) * 1.4)}.frame .rz-nw{left:calc(var(--t) * -.7);top:calc(var(--t) * -.7);cursor:nwse-resize}.frame .rz-se{right:calc(var(--t) * -.7);bottom:calc(var(--t) * -.7);cursor:nwse-resize}.frame .rz-ne{right:calc(var(--t) * -.7);top:calc(var(--t) * -.7);cursor:nesw-resize}.frame .rz-sw{left:calc(var(--t) * -.7);bottom:calc(var(--t) * -.7);cursor:nesw-resize}.frame .rz-ne:after,.frame .rz-nw:after,.frame .rz-se:after,.frame .rz-sw:after{content:"";position:absolute;inset:30%;background:#fff;border:calc(1.5px / var(--z, 1)) solid var(--accent);border-radius:calc(1.5px / var(--z, 1))}.frame .pins{position:absolute;inset:0;pointer-events:none;overflow:visible;z-index:6}.frame .areas{position:absolute;inset:0;pointer-events:none;overflow:hidden}.pin{position:absolute;width:0;height:0;pointer-events:auto}.pin-dot{position:absolute;left:0;bottom:0;transform-origin:0 100%;scale:calc(1 / var(--z, 1));min-width:26px;height:26px;padding:0 7px;border-radius:13px 13px 13px 2px;display:grid;place-items:center;background:var(--accent);color:#fff;font:600 11.5px/1 var(--font);box-shadow:0 0 0 2px #fff,var(--shadow);cursor:pointer}.pin.done .pin-dot{background:#17171a}.pin.error .pin-dot{background:var(--mark)}.pin.waiting .pin-dot{background:#fff;color:#17171a;box-shadow:0 0 0 1.5px #cfcbc1,var(--shadow)}.pin.resolved .pin-dot{opacity:.45}.pin.working .pin-dot:after{content:"";position:absolute;inset:-5px;border-radius:inherit;border:2px solid var(--accent);animation:ring 1.4s ease-out infinite}.pin.active .pin-dot{box-shadow:0 0 0 2px #fff,0 0 0 4px var(--accent),var(--shadow)}.pin.draft .pin-dot{background:var(--accent)}#ink{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}#ink path{fill:none;stroke-linecap:round;stroke-linejoin:round}#ink path.used{opacity:.28}#viewport[data-tool=erase] #ink path{pointer-events:stroke}#viewport[data-tool=erase] #ink path:hover{opacity:.4}#hover-box{position:fixed;pointer-events:none;border:1.5px solid var(--accent);background:var(--accent-soft);border-radius:3px;z-index:5;transition:all .06s ease-out}#hover-box:after{content:attr(data-tag);position:absolute;left:-1.5px;top:-20px;font:500 10.5px/1 var(--mono);color:#fff;background:var(--accent);padding:4px 5px;border-radius:3px 3px 3px 0;white-space:nowrap}.cursor{position:absolute;left:0;top:0;pointer-events:none;transition:transform .08s linear}.cursor-inner{transform-origin:0 0;scale:calc(1 / var(--z, 1))}.cursor svg{width:18px;height:18px;display:block;filter:drop-shadow(0 1px 1px rgba(0,0,0,.2))}.cursor span{display:inline-block;margin:2px 0 0 12px;padding:3px 7px;border-radius:99px;color:#fff;font:500 11px/1.2 var(--font);white-space:nowrap}#topbar{position:fixed;left:12px;right:12px;top:12px;display:flex;justify-content:space-between;align-items:center;pointer-events:none;z-index:20}#topbar>*{pointer-events:auto}.brand{display:flex;align-items:center;gap:8px;padding:6px 12px 6px 7px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)}.logo svg{width:22px;height:22px;display:block}.logo rect{fill:var(--ink)}.logo circle{stroke:var(--paper);stroke-width:2.6;fill:none}.wordmark{font-weight:600;letter-spacing:-.01em}.sep{color:var(--line-strong)}.canvas-name{color:var(--ink-2);max-width:30vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.top-right{display:flex;align-items:center;gap:8px}.pill{display:flex;align-items:center;gap:7px;height:34px;padding:0 12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);color:var(--ink-2);font-weight:500}.pill:hover{border-color:var(--line-strong)}.agent-dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 3px #1f9d5524}.pill.busy .agent-dot{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);animation:pulse 1.2s ease-in-out infinite}.pill.inbox .agent-dot{background:#d9a100;box-shadow:0 0 0 3px #d9a10029}.btn-primary{height:34px;padding:0 14px;border-radius:10px;background:var(--ink);color:var(--paper);font-weight:500;display:inline-flex;align-items:center;gap:8px;box-shadow:var(--shadow)}.btn-primary:hover{opacity:.9}.btn-primary kbd{background:transparent;border-color:#ffffff40;color:inherit}.btn-secondary{height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--line);background:var(--surface);font-weight:500}.btn-secondary:hover{border-color:var(--line-strong)}.link-btn{color:var(--muted);font-size:12px;text-decoration:underline;text-underline-offset:2px}.icon-btn{width:26px;height:26px;border-radius:6px;color:var(--muted);font-size:18px;line-height:1}.icon-btn:hover{background:var(--surface-2);color:var(--ink)}.presence{display:flex}.avatar{width:28px;height:28px;border-radius:50%;color:#fff;display:grid;place-items:center;font:600 11px/1 var(--font);border:2px solid var(--paper);margin-left:-6px}.menu{position:fixed;z-index:30;width:320px;padding:8px;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-lg)}.menu-title{padding:6px 8px 8px;font-weight:600}.menu-copy,.menu-note{margin:0 8px 10px;color:var(--muted);font-size:12px}.menu-note:empty{display:none}.menu-item{display:flex;gap:10px;text-align:left;width:100%;padding:9px 8px;border-radius:8px}.menu-item:hover{background:var(--surface-2)}.menu-item b{display:block;font-weight:500}.menu-item small{display:block;color:var(--muted);font-size:12px;margin-top:2px}.menu-item code{font-size:11px}.radio{flex:none;width:14px;height:14px;margin-top:2px;border-radius:50%;border:1.5px solid var(--line-strong)}.menu-item.on .radio{border:4px solid var(--accent)}#share-menu{padding:12px}#share-menu .menu-title{padding:0 0 6px}#share-menu .menu-copy,#share-menu .menu-note{margin:0 0 12px}.field-label{display:block;font-size:11.5px;color:var(--muted);font-weight:500;margin:0 0 5px}.copy-row{display:flex;gap:6px;margin-bottom:12px}.copy-row input{flex:1;min-width:0;height:32px;padding:0 9px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);font:11.5px var(--mono);color:var(--ink-2);outline:0}.copy-row button{height:32px;padding:0 11px;border-radius:8px;border:1px solid var(--line);background:var(--surface);font-weight:500}#share-public-btn{width:100%;margin-bottom:8px}#toolbar{position:fixed;left:50%;bottom:16px;transform:translate(-50%);z-index:20;display:flex;align-items:center;gap:2px;padding:5px;background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-lg)}#toolbar button{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;color:var(--ink-2)}#toolbar button:hover{background:var(--surface-2);color:var(--ink)}#toolbar button.on{background:var(--ink);color:var(--paper)}#toolbar button[data-tool=comment].on{background:var(--accent);color:#fff}#toolbar svg{width:19px;height:19px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}#toolbar .tb-sep{width:1px;height:22px;background:var(--line);margin:0 4px}#toolbar .tb-new{width:auto;padding:0 12px 0 9px;display:flex;gap:5px;font-weight:500}#zoom{position:fixed;right:16px;bottom:16px;z-index:20;display:flex;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);overflow:hidden}#zoom button{height:32px;min-width:32px;padding:0 8px;color:var(--ink-2);font:500 12px var(--mono)}#zoom button:hover{background:var(--surface-2)}#zoom-level{min-width:56px;border-left:1px solid var(--line)!important;border-right:1px solid var(--line)!important}.pop{position:fixed;z-index:25;width:340px;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-lg)}#composer{padding:10px}.composer-target{display:flex;align-items:center;gap:6px;margin-bottom:8px;font:11px var(--mono);color:var(--muted);overflow:hidden;white-space:nowrap}.composer-target b{color:var(--accent);font-weight:500}.composer-target span{overflow:hidden;text-overflow:ellipsis}#composer textarea,.thread-reply textarea{width:100%;resize:none;border:0;outline:0;background:transparent;font-size:13.5px;line-height:1.45;max-height:180px;padding:2px 0}#composer textarea::placeholder,.thread-reply textarea::placeholder{color:var(--muted)}.composer-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--line)}.hint{color:var(--muted);font-size:11px;display:flex;gap:4px;align-items:center;margin-left:auto}.chip{font-size:11px;font-weight:500;color:var(--mark);background:#e5484d1a;padding:3px 7px;border-radius:99px}#thread{max-height:min(70vh,560px);display:flex;flex-direction:column}.thread-head{display:flex;align-items:center;justify-content:space-between;padding:8px 8px 8px 12px;border-bottom:1px solid var(--line)}.thread-head .t-title{font-weight:600;font-size:12.5px}.thread-head .t-actions{display:flex;gap:2px}.thread-body{overflow:auto;padding:4px 0}.msg{padding:10px 12px}.msg+.msg{border-top:1px solid var(--line)}.msg-head{display:flex;align-items:center;gap:7px;margin-bottom:4px}.msg-head .av{width:18px;height:18px;border-radius:50%;color:#fff;display:grid;place-items:center;font:600 9px/1 var(--font)}.msg-head b{font-weight:600;font-size:12.5px}.msg-head time{color:var(--muted);font-size:11px}.msg-text{white-space:pre-wrap;word-wrap:break-word}.agent-line{display:flex;gap:8px;align-items:flex-start;margin-top:8px;padding:8px 10px;background:var(--surface-2);border-radius:8px;font-size:12.5px;color:var(--ink-2)}.agent-line .a-ic{flex:none;width:16px;height:16px;margin-top:1px;border-radius:4px;background:var(--ink);display:grid;place-items:center}.agent-line .a-ic svg{width:10px;height:10px;stroke:var(--paper);fill:none;stroke-width:2.4}.agent-line.working .a-ic{background:none;border:1.6px solid var(--accent-soft);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}.agent-line.working .a-ic svg{display:none}.agent-line.working .a-text{background:linear-gradient(90deg,var(--muted) 0%,var(--muted) 35%,var(--ink) 50%,var(--muted) 65%,var(--muted) 100%);background-size:250% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:shimmer 1.8s linear infinite}.agent-line.error{background:#e5484d14;color:var(--mark)}.agent-line.error .a-ic{background:var(--mark)}.agent-line.waiting .a-ic{background:var(--line-strong)}.agent-line .a-actions{display:flex;gap:10px;margin-top:6px}.agent-line .a-actions button{font-size:12px;font-weight:500;color:var(--accent)}.agent-line .a-actions button.quiet{color:var(--muted)}.thread-reply{display:flex;align-items:flex-end;gap:8px;padding:10px 12px;border-top:1px solid var(--line)}#ink-chip{position:fixed;z-index:22}#ink-chip button{height:30px;padding:0 11px;border-radius:99px;background:var(--ink);color:var(--paper);font-weight:500;font-size:12px;box-shadow:var(--shadow-lg)}.modal{position:fixed;inset:0;z-index:40;display:grid;place-items:start center;padding-top:18vh;background:#1414162e}.modal-card{width:min(560px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-lg);padding:14px}.modal-card.narrow{width:min(380px,calc(100vw - 32px))}.modal-head{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:10px}.modal-card textarea,.modal-card input{width:100%;border:1px solid var(--line);background:var(--surface-2);border-radius:10px;padding:10px 12px;outline:0;resize:none;font-size:14px}.modal-card textarea:focus,.modal-card input:focus{border-color:var(--accent)}.modal-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}.size-pick{display:flex;gap:2px;padding:3px;border-radius:9px;background:var(--surface-2);border:1px solid var(--line)}.size-pick button{height:26px;padding:0 10px;border-radius:6px;color:var(--muted);font-weight:500;font-size:12px}.size-pick button.on{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #00000014}#empty{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}.empty-card{max-width:420px;text-align:center;pointer-events:auto;padding:0 16px}.empty-mark{width:44px;height:44px;margin:0 auto 16px;border-radius:12px;border:1.5px dashed var(--line-strong)}.empty-card h1{font-size:18px;font-weight:600;margin:0 0 6px;letter-spacing:-.01em}.empty-card p{margin:0 0 10px;color:var(--ink-2)}.empty-card code{display:block;margin:0 0 14px;padding:10px 12px;border-radius:10px;background:var(--surface);border:1px solid var(--line);color:var(--ink);text-align:left;user-select:all}#toast{position:fixed;left:50%;bottom:76px;transform:translate(-50%);z-index:50;padding:8px 14px;border-radius:10px;background:var(--ink);color:var(--paper);font-weight:500;box-shadow:var(--shadow-lg)}#share-menu{width:360px;max-height:calc(100vh - 80px);overflow:auto}.btn-google{width:100%;height:38px;display:flex;align-items:center;justify-content:center;gap:10px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface);font-weight:500;margin-bottom:12px}.btn-google:hover{background:var(--surface-2)}.btn-google svg{width:18px;height:18px}.btn-dark{height:32px;padding:0 12px;border-radius:8px;background:var(--ink);color:var(--paper);font-weight:500}.btn-dark:disabled{opacity:.5}.members{margin:0 0 12px;display:flex;flex-direction:column;gap:2px}.member{display:flex;align-items:center;gap:9px;padding:6px 2px}.member .m-av{width:26px;height:26px;border-radius:50%;background:var(--surface-2);border:1px solid var(--line);display:grid;place-items:center;font:600 10px/1 var(--font);color:var(--ink-2);overflow:hidden;flex:none}.member .m-av img,.av-sm img{width:100%;height:100%;object-fit:cover}.member .m-text{flex:1;min-width:0}.member .m-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.member .m-sub{font-size:11.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.member .m-x{color:var(--muted);font-size:16px;width:22px;height:22px;border-radius:6px}.member .m-x:hover{background:var(--surface-2);color:var(--mark)}.signed-as{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);margin:2px 0 12px}.signed-as #me-email{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.av-sm{width:18px;height:18px;border-radius:50%;overflow:hidden;background:var(--surface-2);flex:none}.lan-share{border-top:1px solid var(--line);padding-top:10px}.lan-share.only{border-top:0;padding-top:0}.lan-share summary{cursor:pointer;color:var(--ink-2);font-weight:500;font-size:12.5px;margin-bottom:10px}.agent-line .a-meta{font:500 11px/1.4 var(--mono);color:var(--muted);margin-top:5px}.agent-line .a-model{font:500 10.5px/1 var(--mono);color:var(--muted);padding:2px 5px;border-radius:4px;border:1px solid var(--line);margin-right:4px}.msg-agent{background:var(--surface-2)}.av-agent{background:var(--ink)!important}.av-agent svg{width:10px;height:10px;stroke:var(--paper);fill:none;stroke-width:2.4}.sc-card{width:min(720px,calc(100vw - 32px))}.sc-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 28px}.sc-group{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px}.sc-row{display:flex;justify-content:space-between;gap:12px;padding:5px 0;border-bottom:1px solid var(--line);font-size:12.5px}.sc-keys{display:flex;gap:4px;flex:none}@media(max-width:640px){.sc-grid{grid-template-columns:1fr}}#composer.soft{animation:softIn .22s ease-out}#composer.soft textarea::placeholder{color:var(--accent)}@keyframes softIn{0%{opacity:0;transform:translateY(4px) scale(.98)}to{opacity:1;transform:none}}.prompt-head,.prompt-ideas{display:none}#composer.prompt{width:400px;padding:14px}#composer.prompt .prompt-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;margin-bottom:10px}#composer.prompt .prompt-head b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.spark{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;background:var(--accent);color:#fff;font-size:13px;flex:none;animation:spark 2.4s ease-in-out infinite}@keyframes spark{50%{transform:rotate(12deg) scale(1.06)}}#composer.prompt .prompt-ideas{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}.prompt-ideas button{height:26px;padding:0 10px;border-radius:99px;border:1px solid var(--line);background:var(--surface-2);font-size:12px;font-weight:500;color:var(--ink-2)}.prompt-ideas button:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}#composer.prompt textarea{min-height:44px;font-size:14px}.frame.empty .empty-hint b{color:var(--accent)}.frame.empty .shield{cursor:pointer}.canvas-name.editable{cursor:text;border-radius:6px;padding:2px 4px;margin:-2px -4px}.canvas-name.editable:hover{background:var(--surface-2)}.canvas-rename{font:inherit;color:var(--ink);background:var(--surface-2);border:1px solid var(--accent);border-radius:6px;padding:1px 5px;outline:0;width:220px}#fp-name{width:100%;font:600 13px var(--font);color:var(--ink);border:1px solid transparent;background:transparent;border-radius:6px;padding:3px 5px;margin-left:-5px;outline:0}#fp-name:hover{border-color:var(--line)}#fp-name:focus{border-color:var(--accent);background:var(--surface-2)}.deep-box{position:absolute;pointer-events:none;border:calc(2px / var(--z, 1)) solid var(--accent);background:#2f5bff14;border-radius:calc(2px / var(--z, 1))}.deep-box:before{content:attr(data-tag);position:absolute;left:calc(-2px / var(--z, 1));bottom:100%;transform-origin:0 100%;scale:calc(1 / var(--z, 1));font:500 10.5px/1 var(--mono);color:#fff;background:var(--accent);padding:4px 5px;border-radius:3px 3px 3px 0;margin-bottom:calc(2px / var(--z, 1));white-space:nowrap}.area{position:absolute;pointer-events:none;border:calc(2px / var(--z, 1)) solid var(--accent);background:#2f5bff1a;border-radius:calc(3px / var(--z, 1));opacity:0;transition:opacity .15s}.area.show,.area.hover{opacity:1}.area:before{content:"Area";position:absolute;left:calc(-2px / var(--z, 1));bottom:100%;transform-origin:0 100%;scale:calc(1 / var(--z, 1));font:600 10px/1 var(--font);letter-spacing:.02em;color:#fff;background:var(--accent);padding:4px 6px;border-radius:4px 4px 4px 0;margin-bottom:calc(2px / var(--z, 1));white-space:nowrap}.frame .area.draft,.frame .area.picking{box-shadow:0 0 0 100000px #0a0c1438}.area.picking{border-style:dashed}.chip-area{color:var(--accent);background:var(--accent-soft)}.scope-pick{display:flex;gap:2px;padding:3px;margin-bottom:8px;border-radius:8px;background:var(--surface-2);border:1px solid var(--line)}.scope-pick button{flex:1;height:24px;border-radius:6px;font-size:11.5px;font-weight:500;color:var(--muted)}.scope-pick button.on{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #00000014}.scope-pick button:disabled{opacity:.4;cursor:default}#canvas-pins{position:absolute;left:0;top:0;width:0;height:0}#canvas-pins .pin-dot{background:var(--surface);color:var(--ink);box-shadow:0 0 0 1.5px var(--ink),var(--shadow)}#canvas-pins .pin.working .pin-dot,#canvas-pins .pin.draft .pin-dot{background:var(--accent);color:#fff;box-shadow:0 0 0 2px #fff,var(--shadow)}#marquee{position:absolute;pointer-events:none;border:calc(1px / var(--z, 1)) solid var(--accent);background:#2f5bff14}#align-bar{position:fixed;z-index:22;display:flex;align-items:center;gap:2px;padding:4px;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-lg);animation:softIn .15s ease-out}#align-bar button{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;color:var(--ink-2)}#align-bar button:hover{background:var(--surface-2);color:var(--ink)}#align-bar button:disabled{opacity:.35;cursor:default;background:none}#align-bar svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}#align-bar .ab-count{font:500 12px var(--font);color:var(--ink-2);padding:0 8px 0 6px;white-space:nowrap}#align-bar .ab-sep{width:1px;height:20px;background:var(--line);margin:0 3px}#align-bar .ab-spacing{display:flex;align-items:center;gap:6px;padding-right:4px;animation:softIn .18s ease-out}#align-bar .ab-spacing[hidden]{display:none}#align-bar .ab-scrub{width:26px;height:28px;display:grid;place-items:center;color:var(--accent);cursor:ew-resize;border-radius:6px}#align-bar .ab-scrub:hover{background:var(--accent-soft)}#align-bar .ab-range{width:120px;accent-color:var(--accent);cursor:pointer}#align-bar .ab-num{display:flex;align-items:center;gap:2px;height:28px;padding:0 6px;border-radius:7px;border:1px solid var(--line);background:var(--surface-2)}#align-bar .ab-num:focus-within{border-color:var(--accent)}#align-bar .ab-val{width:38px;border:0;outline:0;background:transparent;font:12px var(--mono);color:var(--ink);text-align:right}#align-bar .ab-num small{font:11px var(--mono);color:var(--muted)}#guides{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;z-index:8}#guides line{stroke:#ff2d55;stroke-width:calc(.6px / var(--z, 1));shape-rendering:crispEdges}#dup-ghost{position:absolute;pointer-events:none;border:calc(1.5px / var(--z, 1)) dashed var(--accent);background:var(--accent-soft);border-radius:2px}#dup-ghost span{position:absolute;left:0;bottom:100%;transform-origin:0 100%;scale:calc(1 / var(--z, 1));padding-bottom:6px;font:500 12.5px var(--font);color:var(--accent);white-space:nowrap}#viewport.alt[data-tool=select] .frame-label{cursor:copy}#viewport[data-tool=frame],#viewport[data-tool=frame] .shield{cursor:crosshair}#draw-box{position:absolute;border:calc(1.5px / var(--z, 1)) solid var(--accent);background:var(--accent-soft);pointer-events:none}#draw-box span{position:absolute;left:50%;top:100%;transform-origin:50% 0;translate:-50% 0;scale:calc(1 / var(--z, 1));margin-top:6px;font:500 11px/1 var(--mono);color:#fff;background:var(--accent);padding:4px 6px;border-radius:4px;white-space:nowrap}.frame-label .dims{flex:none;display:none;font:500 10.5px/1 var(--mono);color:var(--muted)}.frame.selected .frame-label .dims{display:inline}.frame .empty-hint{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:6px;text-align:center;padding:24px;pointer-events:none;color:#9a978f;font:14px/1.4 var(--font)}.frame .empty-hint b{color:#6b6964;font-weight:600;font-size:15px}.frame.empty .empty-hint{display:flex}.frame.empty .frame-body{box-shadow:0 0 0 1px var(--line-strong),var(--shadow)}.panel{position:fixed;right:12px;top:60px;z-index:21;width:268px;max-height:calc(100vh - 140px);overflow:auto;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-lg);padding:10px}.panel-head{display:flex;align-items:center;justify-content:space-between;font-weight:600;padding:2px 2px 10px}.panel-head span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fp-quick{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.fp-q{display:flex;flex-direction:column;align-items:center;gap:3px;padding:10px 4px 8px;border-radius:10px;border:1px solid var(--line);background:var(--surface-2)}.fp-q:hover{border-color:var(--accent);background:var(--accent-soft)}.fp-q svg{width:22px;height:22px;fill:none;stroke:var(--ink-2);stroke-width:1.6;stroke-linecap:round;margin-bottom:2px}.fp-q b{font-weight:500;font-size:12.5px}.fp-q small,.fp-row small{font:10.5px/1 var(--mono);color:var(--muted)}.fp-hint{color:var(--muted);font-size:12px;margin:10px 2px}.fp-group{border-top:1px solid var(--line);padding:6px 0}.fp-group summary{cursor:pointer;font-weight:500;font-size:12.5px;color:var(--ink-2);padding:4px 2px}.fp-row{display:flex;justify-content:space-between;align-items:center;width:100%;padding:7px 6px;border-radius:7px;font-size:12.5px;text-align:left}.fp-row:hover{background:var(--surface-2)}.fp-custom,.fp-size{display:flex;gap:6px;align-items:center;border-top:1px solid var(--line);padding-top:10px;margin-top:4px}.fp-size{border-top:0;padding-top:0;margin:0 0 8px}.fp-custom label,.fp-size label{flex:1;display:flex;align-items:center;gap:6px;height:32px;padding:0 8px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);font:500 11px var(--mono);color:var(--muted)}.fp-custom input,.fp-size input{width:100%;min-width:0;border:0;outline:0;background:transparent;font:12.5px var(--mono);color:var(--ink)}.fp-custom label:focus-within,.fp-size label:focus-within{border-color:var(--accent)}.fp-chips{display:flex;gap:4px;margin-bottom:6px}.fp-chip{flex:1;height:28px;border-radius:7px;border:1px solid var(--line);font-size:12px;font-weight:500;color:var(--ink-2)}.fp-chip:hover{background:var(--surface-2)}.fp-chip.on{border-color:var(--ink);color:var(--ink)}#fp-describe{display:block;margin:12px 2px 2px;text-align:left}#viewport.dragging iframe,#viewport.panning iframe{pointer-events:none}#agent-pill.offline .agent-dot{background:var(--muted);box-shadow:none}@keyframes spin{to{transform:rotate(360deg)}}@keyframes shimmer{0%{background-position:125% 0}to{background-position:-125% 0}}@keyframes sweep{0%{left:-35%}to{left:100%}}@keyframes ring{0%{opacity:.9;transform:scale(.9)}to{opacity:0;transform:scale(1.35)}}@keyframes pulse{50%{opacity:.45}}@keyframes fadeout{0%,70%{opacity:1}to{opacity:0}}@media(max-width:640px){.canvas-name,.sep{display:none}#toolbar{bottom:12px}#zoom{display:none}.pop,.menu{width:calc(100vw - 32px)}}
package/public/app.js ADDED
@@ -0,0 +1,40 @@
1
+ const d=e=>document.querySelector(e),x=d("#viewport"),pt=d("#world"),rn=d("#frames"),ye=d("#ink"),Ge=d("#cursors"),G=d("#hover-box"),$=d("#composer"),k=d("#composer-input"),B=d("#thread"),w=e=>String(e??"").replace(/[&<>"']/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t]),ht=()=>Math.random().toString(36).slice(2,10),ve={get(e,t){try{const n=localStorage.getItem(e);return n==null?t:JSON.parse(n)}catch{return t}},set(e,t){try{localStorage.setItem(e,JSON.stringify(t))}catch{}}},gt=["#2F5BFF","#E5484D","#1F9D55","#D9A100","#8E4EC6","#0E9AA7","#E8590C"],Ve="#E5484D";let c=null,u={x:120,y:140,z:.5},yt=!1,T="select",ne=!1,M=null,A=null,y=null,xe=null;const E=new Set;let Ae=null,F=null;const vt=new Set,_=new Map,ee=new Map,de=new Map,P=window.LOOP_ADAPTER||null,C=ve.get("loop.me",null)||{id:ht(),name:"",color:gt[Math.floor(Math.random()*gt.length)]};P?.me&&Object.assign(C,P.me),C.id=C.id||ht();async function p(e,t,n){if(P)return P.api(e,t,n);const a=await fetch(t,{method:e,headers:{"content-type":"application/json"},body:n?JSON.stringify(n):void 0}),i=await a.json().catch(()=>({}));if(!a.ok)throw new Error(i.error||`HTTP ${a.status}`);return i}function v(e,t=2200){const n=d("#toast");n.textContent=e,n.hidden=!1,clearTimeout(v.t),v.t=setTimeout(()=>n.hidden=!0,t)}const b=()=>c?.role==="owner";function ln(){if(P)return P.connect(xt);const e=new EventSource("/__loop/api/events");e.onmessage=t=>xt(JSON.parse(t.data)),e.onerror=()=>{d("#agent-pill").classList.add("offline")},e.onopen=()=>{d("#agent-pill").classList.remove("offline")}}function xt(e){if(e.type==="state"){const t=!c;c=e.state,t&&cn(),dn()}else if(e.type==="activity")c?.live[e.frameId]&&(c.live[e.frameId].step=e.step),D(c.frames.find(t=>t.id===e.frameId)),document.querySelectorAll(`[data-step-for="${e.commentId}"]`).forEach(t=>t.textContent=e.step);else if(e.type==="frame-changed")un(e.frameId),e.done&&mn(e.frameId,e.status==="done"?"Updated":e.status==="error"?"Failed":"",e.status!=="done"),e.done&&e.status==="done"&&Kn(e.frameId);else if(e.type==="presence"){if(e.id===C.id)return;e.leave?de.delete(e.id):de.set(e.id,{...e,t:Date.now()}),$t(),Pe()}}function cn(){const e=ve.get(`loop.view.${c.name}`,null);e?(u=e,X()):we(!1),yt=!0,C.name||(b()?C.name="Host":(d("#name-modal").hidden=!1,setTimeout(()=>d("#name-input").focus(),30))),ve.set("loop.me",C),document.body.classList.toggle("guest",!b()),d("#new-frame-btn").hidden=!b(),document.querySelector("#toolbar .tb-sep").hidden=!b()}function X(){pt.style.transform=`translate(${u.x}px, ${u.y}px) scale(${u.z})`,pt.style.setProperty("--z",u.z);let e=24*u.z;for(;e<12;)e*=4;x.style.setProperty("--gs",`${e}px`),x.style.setProperty("--gx",`${u.x}px`),x.style.setProperty("--gy",`${u.y}px`),d("#zoom-level").textContent=`${Math.round(u.z*100)}%`,kt(),Z(),G.hidden=!0,yt&&c&&(clearTimeout(X.t),X.t=setTimeout(()=>ve.set(`loop.view.${c.name}`,u),300))}function V(e,t,n){const a=Math.min(4,Math.max(.04,u.z*n));u.x=e-(e-u.x)*(a/u.z),u.y=t-(t-u.y)*(a/u.z),u.z=a,wt()}let Ze=!1;function wt(){Ze||(Ze=!0,requestAnimationFrame(()=>{Ze=!1,X()}))}function bt(e,t,n){e.preventDefault();const a=Math.max(-30,Math.min(30,e.deltaMode===1?e.deltaY*16:e.deltaY));V(t,n,Math.exp(-a*(e.ctrlKey&&!e.metaKey?.01:.004)))}const K=(e,t)=>({x:(e-u.x)/u.z,y:(t-u.y)/u.z});function we(e=!0,t){const n=(c?.frames||[]).filter(L=>!t||t.includes(L.id));if(!n.length)return u={x:innerWidth/2,y:innerHeight/2,z:.5},X();const a=Math.min(...n.map(L=>L.x)),i=Math.min(...n.map(L=>L.y)),o=Math.max(...n.map(L=>L.x+L.w)),s=Math.max(...n.map(L=>L.y+L.h)),r=80,l=110,f=110,m=Math.min(1,(innerWidth-r*2)/(o-a),(innerHeight-l-f)/(s-i)),h={z:m,x:(innerWidth-(o-a)*m)/2-a*m,y:l+(innerHeight-l-f-(s-i)*m)/2-i*m};if(!e)return u=h,X();const g={...u},Y=performance.now(),_e=L=>{const ce=Math.min(1,(L-Y)/280),Ue=1-Math.pow(1-ce,3);u={x:g.x+(h.x-g.x)*Ue,y:g.y+(h.y-g.y)*Ue,z:g.z+(h.z-g.z)*Ue},X(),ce<1&&requestAnimationFrame(_e)};requestAnimationFrame(_e)}function dn(){if(!c)return;d("#canvas-name").querySelector("input")||(d("#canvas-name").textContent=c.name),d("#canvas-name").classList.toggle("editable",b()&&!P),document.title=`${c.name} \u2014 Loop`,d("#empty").hidden=c.frames.length>0,hn(),Pe();const e=new Set(c.frames.map(t=>t.id));for(const[t,n]of _)e.has(t)||(n.el.remove(),_.delete(t));for(const t of c.frames)D(t);M&&!e.has(M)&&(M=null),Lt(),Ee(),A&&It(),y&&y.frameId!==z&&!e.has(y.frameId)&&q();for(const t of[...E])e.has(t)||E.delete(t);re(),Ke(),kt()}function Je(e){try{const t=e.iframe.contentWindow.location;return t.href==="about:blank"?null:t.pathname+t.search+t.hash}catch{return null}}let Qe=0;function kt(){Qe||!c||(Qe=requestAnimationFrame(()=>{Qe=0;const e=innerWidth,t=innerHeight,n=c.frames.length>6;for(const a of c.frames){const i=_.get(a.id);if(!i?.iframe)continue;const o=fe(a),s=u.z,r=u.x+o.x*s,l=u.y+o.y*s,f=o.w*s,m=o.h*s,h=Y=>Math.max(0,r-e-Y,-(r+f)-Y,l-t-Y,-(l+m)-Y),g=!n||c.live[o.id]||M===o.id||E.has(o.id);i.el.classList.toggle("narrow",f<200),i.parked?(g||h(e*.5)===0&&Math.max(f,m)>160)&&(i.parked=!1,i.el.classList.remove("parked"),i.iframe.src=i.resume||i.iframe.dataset.src||"about:blank",i.resume=null):!g&&(h(e)>0||Math.max(f,m)<110)&&(i.resume=Je(i),i.parked=!0,i.el.classList.add("parked"),i.iframe.src="about:blank")}}))}function fe(e){return{...e,...ee.get(e.id)||{}}}function fn(e){const t=document.createElement("div");t.className="frame",t.dataset.id=e.id,t.innerHTML=`
2
+ <div class="frame-label">
3
+ <span class="spinner"></span><span class="name"></span><span class="dims"></span><span class="kind" hidden></span><span class="count"></span>
4
+ <span class="step"></span><span class="elapsed"></span><span class="updated"></span>
5
+ <button class="stop" title="Stop the agent">Stop</button>
6
+ </div>
7
+ <div class="frame-body"><iframe title="${w(e.name)}"></iframe><div class="parked-hint"></div><div class="empty-hint"><b>\u2726 Describe this screen</b><span>Click to tell the agent what goes here</span></div><div class="shield"></div><div class="areas"></div><div class="progress"></div></div><div class="pins"></div>
8
+ ${["n","s","e","w","ne","nw","se","sw"].map(a=>`<div class="resize rz-${a}" data-dir="${a}"></div>`).join("")}`,rn.appendChild(t);const n={el:t,iframe:t.querySelector("iframe"),label:t.querySelector(".frame-label"),name:t.querySelector(".name"),dims:t.querySelector(".dims"),kind:t.querySelector(".kind"),count:t.querySelector(".count"),step:t.querySelector(".step"),elapsed:t.querySelector(".elapsed"),updated:t.querySelector(".updated"),pins:t.querySelector(".pins"),restore:null,parked:!0,hint:t.querySelector(".parked-hint")};return t.classList.add("parked"),n.iframe.addEventListener("load",()=>{try{const a=n.iframe.contentWindow;if(n.parked)return;const i=()=>{n.cur=Je(n);const s=c?.frames.find(r=>r.id===e.id);s?.kind==="route"&&(n.kind.textContent=(n.cur||s.src).split("?")[0])};i();for(const s of["pushState","replaceState"]){const r=a.history[s];a.history[s]=function(...l){const f=r.apply(this,l);return setTimeout(i),f}}a.addEventListener("popstate",i),a.addEventListener("hashchange",i),n.restore&&(a.scrollTo(n.restore.sx,n.restore.sy),n.restore=null),n.el.classList.toggle("empty",!!a.document.querySelector('meta[name="loop-empty"]')),a.addEventListener("scroll",()=>ie(e.id),{passive:!0}),a.addEventListener("resize",()=>ie(e.id));const o=s=>{const r=n.iframe.getBoundingClientRect(),l=r.width/(n.iframe.offsetWidth||1);return[r.left+s.clientX*l,r.top+s.clientY*l]};a.addEventListener("wheel",s=>{(s.ctrlKey||s.metaKey)&&bt(s,...o(s))},{passive:!1}),a.addEventListener("gesturestart",s=>{s.preventDefault(),qe=u.z}),a.addEventListener("gesturechange",s=>{s.preventDefault();const[r,l]=o(s);V(r,l,qe*s.scale/u.z)}),a.addEventListener("keydown",s=>{s.key==="Escape"&&(W(null),window.focus());const r=s.target,l=r&&(r.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(r.tagName));(s.metaKey||s.ctrlKey)&&!l&&(s.key.toLowerCase()==="z"||s.ctrlKey&&s.key.toLowerCase()==="y")&&(s.preventDefault(),s.shiftKey||s.key.toLowerCase()==="y"?dt():Zt())})}catch{}setTimeout(()=>ie(e.id),60)}),t.querySelector(".stop").addEventListener("pointerdown",a=>a.stopPropagation()),t.querySelector(".stop").addEventListener("click",()=>{const a=c.live[e.id];a&&p("POST",`/__loop/api/comments/${a.commentId}/cancel`).catch(i=>v(i.message))}),n.label.addEventListener("dblclick",()=>b()&&pn(e.id)),_.set(e.id,n),n}function D(e){if(!e)return;const t=fe(e),n=_.get(t.id)||fn(t),a=n.el.style;a.left=`${t.x}px`,a.top=`${t.y}px`,a.width=`${t.w}px`,a.height=`${t.h}px`,a.setProperty("--fw",`${t.w}px`);const i=P?.frameSrc?.(t)||(t.kind==="folder"?`/__loop/f/${t.id}/${encodeURIComponent(t.entry||"index.html")}`:t.src);n.iframe.dataset.src!==i&&(n.iframe.dataset.src=i,n.resume=null,n.parked||(n.iframe.src=i)),n.hint.textContent=t.name,n.el.classList.toggle("narrow",t.w*u.z<200),n.renaming||(n.name.textContent=t.name),n.kind.hidden=t.kind==="folder"||t.kind==="static",n.kind.textContent=t.kind==="route"?(n.cur||t.src).split("?")[0]:"live url";const o=c.live[t.id],s=c.comments.filter(r=>r.frameId===t.id&&!r.parentId&&r.kind!=="create"&&!r.resolved).length;if(n.count.textContent=s&&!o?`${s}`:"",n.el.classList.toggle("working",!!o),o){const r=c.comments.find(l=>l.id===o.commentId);n.step.textContent=o.step||(r?.kind==="create"?"Building the frame\u2026":"Working\u2026"),n.elapsed.textContent=et(o.startedAt)}if(n.el.classList.toggle("selected",M===t.id||E.has(t.id)),n.el.classList.toggle("live",T==="select"&&M===t.id&&!ne),n.dims.textContent=`${Math.round(t.w)} \xD7 ${Math.round(t.h)}`,xe===t.id){const r=Ae===t.id;xe=Ae=null,queueMicrotask(()=>{W(t.id),st(t.id),r&&setTimeout(()=>De(t.id),340)})}}function et(e){const t=Math.max(0,Math.round((Date.now()-e)/1e3));return t<60?`${t}s`:`${Math.floor(t/60)}m ${String(t%60).padStart(2,"0")}s`}setInterval(()=>{if(!c)return;for(const[n,a]of Object.entries(c.live)){const i=_.get(n);i&&(i.elapsed.textContent=et(a.startedAt))}document.querySelectorAll("[data-elapsed-from]").forEach(n=>n.textContent=et(+n.dataset.elapsedFrom));const e=Date.now();let t=!1;for(const[n,a]of de)e-a.t>15e3&&(de.delete(n),t=!0);t&&($t(),Pe())},1e3);function un(e){const t=_.get(e);if(!t)return;try{const i=t.iframe.contentWindow;t.restore={sx:i.scrollX,sy:i.scrollY}}catch{t.restore=null}if(t.parked){t.resume=null;return}const n=t.iframe.dataset.src,a=c.frames.find(i=>i.id===e);if(a?.kind!=="static")if(a?.kind==="folder")t.iframe.src=`${n}?_m=${Date.now()}`;else if(a?.kind==="route")try{t.iframe.contentWindow.location.reload()}catch{t.iframe.src=n}else try{t.iframe.contentWindow.location.reload()}catch{t.iframe.src=n}}function mn(e,t,n){const a=_.get(e);!a||!t||(a.updated.textContent=t,a.el.classList.remove("updated","failed"),a.el.offsetWidth,a.el.classList.add(n?"failed":"updated"),clearTimeout(a.flashT),a.flashT=setTimeout(()=>a.el.classList.remove("updated","failed"),4200))}function pn(e){const t=_.get(e),n=c.frames.find(o=>o.id===e);t.renaming=!0;const a=document.createElement("input");a.className="rename",a.value=n.name,t.name.replaceChildren(a),a.focus(),a.select(),a.addEventListener("pointerdown",o=>o.stopPropagation());const i=o=>{if(!t.renaming)return;t.renaming=!1;const s=a.value.trim();if(t.name.textContent=o&&s?s:n.name,o&&s&&s!==n.name){const r=n.name;p("PATCH",`/__loop/api/frames/${e}`,{name:s}),O("Rename frame",()=>te(e,{name:r}),()=>te(e,{name:s}))}};a.addEventListener("keydown",o=>{o.stopPropagation(),o.key==="Enter"&&i(!0),o.key==="Escape"&&i(!1)}),a.addEventListener("blur",()=>i(!0))}function hn(){const e=d("#agent-pill"),t=Object.keys(c.live).length,n=c.comments.filter(r=>r.status==="queued").length,a=c.comments.filter(r=>r.status==="waiting").length,i=c.agent.lastModel&&(c.agentBin==="codex"?!/^claude/.test(c.agent.lastModel):/^claude/.test(c.agent.lastModel));let s=c.agent.mode==="inbox"?c.chatListening?`Your chat \xB7 ${c.agentLabel||"agent"}`:"Chat away \xB7 background":(c.agentLabel||"Claude Code")+(i?` \xB7 ${Ie(c.agent.lastModel)}`:"");c.hostOnline===!1&&(s="Host offline \xB7 comments wait"),t?s+=` \xB7 ${t} working`:n&&(s+=` \xB7 ${n} queued`),a&&b()&&(s+=` \xB7 ${a} to review`),d("#agent-label").textContent=s,e.classList.toggle("busy",t>0),e.classList.toggle("inbox",c.agent.mode==="inbox"&&!t&&!c.chatListening),document.querySelectorAll("#agent-menu .menu-item").forEach(r=>{r.classList.toggle("on",r.dataset.mode===c.agent.mode&&(!r.dataset.runner||r.dataset.runner===c.agentBin)),r.dataset.runner&&(r.hidden=c.runners&&!c.runners[r.dataset.runner])}),d("#share-menu").hidden||Oe()}function tt(e){return(e||"?").trim().split(/\s+/).map(t=>t[0]).join("").slice(0,2).toUpperCase()}function Pe(){const e=[{name:C.name||"You",color:C.color},...de.values()];d("#presence").innerHTML=e.length>1?e.slice(0,6).map(t=>`<span class="avatar" style="background:${w(t.color)}" title="${w(t.name)}">${w(tt(t.name))}</span>`).join(""):""}function $t(){const e=new Set;for(const t of de.values()){e.add(t.id);let n=Ge.querySelector(`[data-id="${CSS.escape(t.id)}"]`);n||(n=document.createElement("div"),n.className="cursor",n.dataset.id=t.id,n.innerHTML=`<div class="cursor-inner"><svg viewBox="0 0 18 18"><path d="M2 1.5l13 6.2-5.6 1.5-2.4 5.6z" fill="${w(t.color)}" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"/></svg><span style="background:${w(t.color)}">${w(t.name)}</span></div>`,Ge.appendChild(n)),n.style.transform=`translate(${t.x}px, ${t.y}px)`}Ge.querySelectorAll(".cursor").forEach(t=>{e.has(t.dataset.id)||t.remove()})}let Et=0;function gn(e,t){const n=Date.now();if(n-Et<70||!c)return;Et=n;const a=K(e,t);p("POST","/__loop/api/presence",{id:C.id,name:C.name||"Guest",color:C.color,avatar:C.avatar,x:a.x,y:a.y}).catch(()=>{})}addEventListener("beforeunload",()=>!P&&navigator.sendBeacon?.("/__loop/api/presence",new Blob([JSON.stringify({id:C.id,leave:!0})],{type:"application/json"})));function Mt(e){if(e.length<4)return`M${e[0]} ${e[1]}l.01 0`;let t=`M${e[0]} ${e[1]}`;for(let n=2;n<e.length-2;n+=2)t+=`Q${e[n]} ${e[n+1]} ${(e[n]+e[n+2])/2} ${(e[n+1]+e[n+3])/2}`;return t+`L${e[e.length-2]} ${e[e.length-1]}`}function Lt(){const e=new Set(c.strokes.map(t=>t.id));ye.querySelectorAll("path[data-id]").forEach(t=>{e.has(t.dataset.id)||t.remove()});for(const t of c.strokes){let n=ye.querySelector(`path[data-id="${t.id}"]`);n||(n=document.createElementNS("http://www.w3.org/2000/svg","path"),n.dataset.id=t.id,n.setAttribute("d",Mt(t.points)),ye.appendChild(n)),n.setAttribute("stroke",t.color),n.setAttribute("stroke-width",t.width),n.classList.toggle("used",!!t.commentId)}}function yn(e,t){for(let n=c.frames.length-1;n>=0;n--){const a=c.frames[n];if(e>=a.x&&e<=a.x+a.w&&t>=a.y&&t<=a.y+a.h)return a}return null}function U(e){try{return _.get(e)?.iframe.contentDocument||null}catch{return null}}function be(e,t,n){const a=_.get(e).iframe.getBoundingClientRect();return{lx:(t-a.left)/u.z,ly:(n-a.top)/u.z}}function ue(e,t,n){const a=U(e);if(!a)return null;try{return a.elementFromPoint(t,n)}catch{return null}}function ke(e){if(!e||!e.ownerDocument)return"";const t=e.ownerDocument,n=[];for(let a=e;a&&a.nodeType===1&&a!==t.documentElement;a=a.parentElement){if(a.id&&/^[A-Za-z][\w-]*$/.test(a.id)){n.unshift(`#${a.id}`);break}let i=a.tagName.toLowerCase();const o=a.parentElement?[...a.parentElement.children].filter(s=>s.tagName===a.tagName):[];if(o.length>1&&(i+=`:nth-of-type(${o.indexOf(a)+1})`),n.unshift(i),a.tagName==="BODY"||n.length>7)break}return n.join(" > ")}function $e(e){const t=typeof e.className=="string"&&e.className.trim()?"."+e.className.trim().split(/\s+/)[0]:"";return e.tagName.toLowerCase()+t}function St(e,t,n){const a=ue(e,t,n),i=U(e);let o=0,s=0;try{o=i.defaultView.scrollX,s=i.defaultView.scrollY}catch{}const r={x:t,y:n,sx:o,sy:s};if(a&&a!==i.documentElement){const l=a.getBoundingClientRect();Object.assign(r,{selector:ke(a),tag:a.tagName.toLowerCase(),label:$e(a),text:(a.innerText||a.getAttribute?.("aria-label")||a.getAttribute?.("alt")||"").replace(/\s+/g," ").trim().slice(0,140),html:a.outerHTML.length>1500?a.outerHTML.slice(0,1500)+"\u2026":a.outerHTML,fx:l.width?(t-l.left)/l.width:0,fy:l.height?(n-l.top)/l.height:0})}return r}const z="__canvas";function ze(e,t){if(e===z)return{x:t.wx,y:t.wy};if(t.scope==="frame"||t.scope==="point"||t.scope==="area"||t.scope==="elements"){let o=0,s=0;try{const r=U(e).defaultView;o=r.scrollX-(t.sx||0),s=r.scrollY-(t.sy||0)}catch{}return{x:t.x-(t.scope!=="frame"?o:0),y:t.y-(t.scope!=="frame"?s:0)}}const n=U(e);if(n&&t.selector)try{const o=n.querySelector(t.selector);if(o){const s=o.getBoundingClientRect();if(s.width||s.height)return{x:s.left+t.fx*s.width,y:s.top+t.fy*s.height}}}catch{}let a=0,i=0;try{a=n.defaultView.scrollX-(t.sx||0),i=n.defaultView.scrollY-(t.sy||0)}catch{}return{x:t.x-a,y:t.y-i}}function vn(e,t,n){const{lx:a,ly:i}=be(e,t,n),o=ue(e,a,i),s=U(e);if(!o||o===s?.documentElement){G.hidden=!0;return}const r=_.get(e).iframe.getBoundingClientRect(),l=o.getBoundingClientRect(),f=u.z,m=Math.max(r.left,r.left+l.left*f),h=Math.max(r.top,r.top+l.top*f),g=Math.min(r.right,r.left+l.right*f),Y=Math.min(r.bottom,r.top+l.bottom*f);Object.assign(G.style,{left:`${m}px`,top:`${h}px`,width:`${Math.max(0,g-m)}px`,height:`${Math.max(0,Y-h)}px`}),G.dataset.tag=$e(o),G.hidden=!1}function Ct(e){return c.comments.filter(t=>t.id===e||t.parentId===e).sort((t,n)=>t.createdAt-n.createdAt)}function xn(e){const t=Ct(e).filter(n=>n.kind!=="note");return t.some(n=>n.status==="working")?"working":t[t.length-1]?.status||"done"}function Tt(e){return c.comments.filter(t=>t.frameId===e&&!t.parentId&&t.kind!=="create").sort((t,n)=>t.createdAt-n.createdAt)}function Ee(){for(const e of c.frames)Me(e.id);Me(z)}function _t(e){return e===z?d("#canvas-pins"):_.get(e)?.pins}function ae(e){return e===z?d("#canvas-pins"):_.get(e)?.el.querySelector(".areas")}function Me(e){const t=_t(e);if(!t)return;const n={pins:t},a=Tt(e),i=a.map((f,m)=>({key:f.id,n:m+1,c:f}));y?.frameId===e&&!y.prompt&&i.push({key:"__draft",n:a.length+1,draft:!0});const o=new Set(i.map(f=>f.key));n.pins.querySelectorAll(".pin").forEach(f=>{o.has(f.dataset.key)||f.remove()});for(const f of i){let m=n.pins.querySelector(`.pin[data-key="${f.key}"]`);m||(m=document.createElement("div"),m.className="pin",m.dataset.key=f.key,m.innerHTML='<div class="pin-dot"></div>',m.addEventListener("pointerdown",g=>g.stopPropagation()),f.draft||m.addEventListener("click",g=>{g.stopPropagation(),Cn(f.key)}),m.addEventListener("pointerenter",()=>ae(e)?.querySelector(`.area[data-key="${f.key}"]`)?.classList.add("hover")),m.addEventListener("pointerleave",()=>ae(e)?.querySelector(`.area[data-key="${f.key}"]`)?.classList.remove("hover")),n.pins.appendChild(m)),m.querySelector(".pin-dot").textContent=f.n;const h=f.draft?"draft":xn(f.key);m.className=`pin ${h}${f.c?.resolved?" resolved":""}${A===f.key||f.draft?" active":""}`}const s=i.filter(f=>f.draft?y?.area:f.c?.anchor?.scope==="area"||f.c?.anchor?.area),r=new Set(s.map(f=>f.key)),l=ae(e);l.querySelectorAll(".area:not(.picking)").forEach(f=>{r.has(f.dataset.key)||f.remove()});for(const f of s){let m=l.querySelector(`.area[data-key="${f.key}"]`);m||(m=document.createElement("div"),m.dataset.key=f.key,l.prepend(m)),m.className=`area${f.draft?" draft show":""}${A===f.key?" show":""}`}ie(e)}function ie(e){const t=_t(e);!t||!c||(H?.frameId===e&&Jt(),ae(e)?.querySelectorAll(".area[data-key]").forEach(n=>{const a=n.dataset.key==="__draft"?y?.anchor:c.comments.find(l=>l.id===n.dataset.key)?.anchor;if(!a)return;const{x:i,y:o}=ze(e,a),s=a.area?a.area.w:a.w,r=a.area?a.area.h:a.h;Object.assign(n.style,{left:`${i}px`,top:`${o}px`,width:`${s}px`,height:`${r}px`})}),t.querySelectorAll(".pin").forEach(n=>{const a=n.dataset.key==="__draft"?y?.anchor:c.comments.find(r=>r.id===n.dataset.key)?.anchor;if(!a){n.hidden=!0;return}const{x:i,y:o}=ze(e,a);n.style.left=`${i}px`,n.style.top=`${o}px`;const s=c.frames.find(r=>r.id===e);n.hidden=!!s&&(i<-4||o<-4||i>s.w+4||o>s.h+4)}),(y?.frameId===e||A&&c.comments.find(n=>n.id===A)?.frameId===e)&&Z())}function nt(e,t,n){if(e===z)return{x:u.x+t*u.z,y:u.y+n*u.z};const a=fe(c.frames.find(i=>i.id===e));return{x:u.x+(a.x+t)*u.z,y:u.y+(a.y+n)*u.z}}function at(e,t,n){const a=e.offsetWidth||340,i=e.offsetHeight||140;let o=t+18,s=n-18;o+a>innerWidth-12&&(o=t-a-18),o=Math.max(12,Math.min(o,innerWidth-a-12)),s=Math.max(60,Math.min(s,innerHeight-i-76)),e.style.left=`${o}px`,e.style.top=`${s}px`}function Z(){if(c){if(y?.prompt&&!$.hidden){const e=fe(c.frames.find(t=>t.id===y.frameId));if(e){const t=$.offsetWidth||380,n=$.offsetHeight||200,a=u.x+(e.x+e.w/2)*u.z,i=u.y+(e.y+e.h/2)*u.z;e.w*u.z<t+40?at($,u.x+(e.x+e.w)*u.z,i-n/2+18):($.style.left=`${Math.max(12,Math.min(a-t/2,innerWidth-t-12))}px`,$.style.top=`${Math.max(60,Math.min(i-n/2,innerHeight-n-76))}px`)}}else if(y&&!$.hidden){const e=ze(y.frameId,y.anchor),t=nt(y.frameId,e.x,e.y);at($,t.x,t.y)}if(A&&!B.hidden){const e=c.comments.find(t=>t.id===A);if(e?.anchor){const t=ze(e.frameId,e.anchor),n=nt(e.frameId,t.x,t.y);at(B,n.x,n.y)}}if(he(),F){const e=nt(F.frameId,F.lx,F.ly),t=d("#ink-chip");t.style.left=`${e.x+10}px`,t.style.top=`${e.y+10}px`}}}function it(e){return c.strokes.filter(t=>t.frameId===e&&!t.commentId&&vt.has(t.id))}function se(e,t,n,a,i){N(),Le(),$.classList.remove("prompt");const o=e===z?null:i?St(e,i.x+i.w/2,i.y+i.h/2):St(e,t,n),s=it(e);y={frameId:e,picked:o,lx:t,ly:n,area:i||null,inkStrokes:s.map(f=>f.id)},$n(e===z?"canvas":i?"area":"point");const r=d("#composer-area");r.hidden=!i,i&&(r.textContent=e===z?`Area \xB7 ${y.anchor.near.length||"no"} frame${y.anchor.near.length===1?"":"s"}`:`Area \xB7 ${y.anchor.elements.length} element${y.anchor.elements.length===1?"":"s"}`);const l=d("#composer-ink");l.hidden=!s.length,l.textContent=`${s.length} mark${s.length===1?"":"s"} attached`,k.placeholder=b()?c.agent.mode==="inbox"?"Comment, then \u21B5 to queue it for your agent":`Comment, then \u21B5 to send to ${c.agentLabel||"the agent"}`:"Comment, then \u21B5 \u2014 the host sends it to the agent",$.hidden=!1,k.value="",oe(k),Me(e),Z(),setTimeout(()=>k.focus(),0)}function At(e,t){return c.frames.map(n=>({f:n,d:Math.hypot(Math.max(n.x-e,0,e-n.x-n.w),Math.max(n.y-t,0,t-n.y-n.h))})).sort((n,a)=>n.d-a.d).slice(0,2).filter(n=>n.d<1200).map(n=>n.f.name)}function wn(e){const t=c.frames.filter(n=>n.x<e.x+e.w&&n.x+n.w>e.x&&n.y<e.y+e.h&&n.y+n.h>e.y).map(n=>n.name);return t.length?t:At(e.x+e.w/2,e.y+e.h/2)}function bn(e,t){const n=U(e);if(!n?.body)return[];const a=Math.max(12,Math.min(t.w,t.h)*.08),i=t.x-a,o=t.y-a,s=t.x+t.w+a,r=t.y+t.h+a,l=[];for(const h of n.body.querySelectorAll("*")){if(l.length>600)break;const g=h.getBoundingClientRect();!g.width||!g.height||["SCRIPT","STYLE","LINK","META"].includes(h.tagName)||g.left>=i&&g.top>=o&&g.right<=s&&g.bottom<=r&&l.push(h)}const f=new Set(l);let m=l.filter(h=>{for(let g=h.parentElement;g;g=g.parentElement)if(f.has(g))return!1;return!0});if(!m.length){const h=ue(e,t.x+t.w/2,t.y+t.h/2);h&&h!==n.documentElement&&(m=[h])}return m.slice(0,14).map(h=>({selector:ke(h),tag:h.tagName.toLowerCase(),text:(h.innerText||h.getAttribute("aria-label")||h.getAttribute("alt")||"").replace(/\s+/g," ").trim().slice(0,80)}))}function kn(e,t){const n=U(e);let a=ue(e,t.x+t.w/2,t.y+t.h/2);for(;a&&a!==n?.body;){const i=a.getBoundingClientRect();if(i.left<=t.x+2&&i.top<=t.y+2&&i.right>=t.x+t.w-2&&i.bottom>=t.y+t.h-2)break;a=a.parentElement}return a&&a!==n?.documentElement?{selector:ke(a),tag:a.tagName.toLowerCase(),text:(a.innerText||"").replace(/\s+/g," ").trim().slice(0,80)}:null}function $n(e){const t=y;if(!t)return;const n=c.frames.find(a=>a.id===t.frameId);t.scope=e,e==="canvas"&&t.area?t.anchor={scope:e,wx:t.area.x,wy:t.area.y,area:{w:t.area.w,h:t.area.h},near:wn(t.area)}:e==="canvas"?t.anchor={scope:e,wx:t.lx,wy:t.ly,near:At(t.lx,t.ly)}:e==="area"?t.anchor={scope:e,x:t.area.x,y:t.area.y,w:t.area.w,h:t.area.h,sx:t.picked?.sx||0,sy:t.picked?.sy||0,elements:bn(t.frameId,t.area),container:kn(t.frameId,t.area)}:t.anchor={...t.picked||{},scope:"point",x:t.lx,y:t.ly,fx:void 0,fy:void 0},Me(t.frameId),Z()}const En={Mobile:["Login","Onboarding","Home feed","Profile","Settings","Checkout"],Tablet:["Dashboard","Reader","Calendar","Settings","Kanban board"],Desktop:["Landing page","Dashboard","Pricing","Sign up","Settings","Blog post"]};function De(e){const t=c?.frames.find(a=>a.id===e);if(!t)return;N(),Le();const n=t.w<=480?"Mobile":t.w<=1024?"Tablet":"Desktop";y={frameId:e,picked:null,lx:24,ly:24,area:null,inkStrokes:[],prompt:!0,scope:"frame",anchor:{scope:"frame",x:24,y:24}},$.classList.add("prompt"),d("#prompt-title").textContent=`What should \u201C${t.name}\u201D be?`,d("#prompt-ideas").innerHTML=En[n].map(a=>`<button>${w(a)}</button>`).join(""),d("#composer-area").hidden=!0,d("#composer-ink").hidden=!0,k.placeholder=n==="Mobile"?"e.g. a login screen with Google sign-in and a friendly illustration":"e.g. a landing page for a habit tracker with pricing and testimonials",$.hidden=!1,k.value="",oe(k),Z(),setTimeout(()=>k.focus(),0)}d("#prompt-ideas").addEventListener("click",e=>{const t=e.target.closest("button");t&&(k.value=`${t.textContent} screen \u2014 `,oe(k),k.focus(),k.setSelectionRange(k.value.length,k.value.length))});function q(){$.classList.contains("soft")&&($.querySelector(".hint").innerHTML="<kbd>\u21B5</kbd> send \xB7 <kbd>\u21E7\u21B5</kbd> new line \xB7 <kbd>esc</kbd>"),$.classList.remove("prompt","soft"),Pt=null,y?.deep&&Te();const e=y?.frameId;y=null,$.hidden=!0,e&&Me(e)}function oe(e){e.style.height="auto",e.style.height=`${Math.min(180,e.scrollHeight)}px`}k.addEventListener("input",()=>oe(k)),k.addEventListener("keydown",e=>{e.stopPropagation(),e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),Mn()),e.key==="Escape"&&q()});async function Mn(){const e=k.value.trim();if(!e||!y)return;const t=y;q();const n=c.strokes.filter(i=>t.inkStrokes.includes(i.id)),a={frameId:t.frameId,text:e,anchor:t.anchor,author:{name:C.name,color:C.color},inkStrokes:t.inkStrokes};n.length&&t.frameId!==z&&Object.assign(a,Ln(t.frameId,n));try{await p("POST","/__loop/api/comments",a),b()||v("Comment added \u2014 the host will send it to the agent")}catch(i){v(i.message)}}function Ln(e,t){const n=c.frames.find(f=>f.id===e),a=U(e),i=new Map;for(const f of t){const m=f.points.length/2,h=Math.max(1,Math.floor(m/14));for(let g=0;g<m;g+=h){const Y=f.points[g*2]-n.x,_e=f.points[g*2+1]-n.y,L=ue(e,Y,_e);if(!L||L===a?.documentElement||L===a?.body)continue;const ce=ke(L);i.has(ce)||i.set(ce,{selector:ce,tag:L.tagName.toLowerCase(),text:(L.innerText||"").replace(/\s+/g," ").trim().slice(0,60),el:L})}}const o=[...i.values()].slice(0,10),s=Math.min(1,1280/Math.max(n.w,n.h)),r=document.createElement("canvas");r.width=Math.round(n.w*s),r.height=Math.round(n.h*s);const l=r.getContext("2d");l.scale(s,s),l.fillStyle="#fff",l.fillRect(0,0,n.w,n.h),l.strokeStyle="#c9c6bd",l.lineWidth=2,l.strokeRect(1,1,n.w-2,n.h-2),l.font="14px ui-monospace, Menlo, monospace";for(const f of o)try{const m=f.el.getBoundingClientRect();l.strokeStyle="#2F5BFF",l.setLineDash([6,4]),l.lineWidth=1.5,l.strokeRect(m.left,m.top,m.width,m.height),l.setLineDash([]),l.fillStyle="#2F5BFF",l.fillText(f.tag+(f.text?` "${f.text.slice(0,30)}"`:""),m.left+4,Math.max(14,m.top-4))}catch{}l.strokeStyle=Ve,l.lineWidth=4,l.lineCap="round",l.lineJoin="round";for(const f of t){l.beginPath();for(let m=0;m<f.points.length;m+=2){const h=f.points[m]-n.x,g=f.points[m+1]-n.y;m?l.lineTo(h,g):l.moveTo(h,g)}l.stroke()}return{ink:o.map(({el:f,...m})=>m),annotationPng:r.toDataURL("image/png")}}function ia(e,t,n){F={frameId:e,lx:t,ly:n},d("#ink-chip").hidden=!1,Z()}function Le(){F=null,d("#ink-chip").hidden=!0}let Pt=null;function Sn(e,t,n){if(!(!$.hidden&&k.value.trim())){if(e){const a=c.frames.find(f=>f.id===e),i=it(e),o=i.flatMap(f=>f.points.filter((m,h)=>h%2===0)),s=i.flatMap(f=>f.points.filter((m,h)=>h%2===1)),r=(o.length?(Math.min(...o)+Math.max(...o))/2:t[t.length-2])-a.x,l=(s.length?(Math.min(...s)+Math.max(...s))/2:t[t.length-1])-a.y;se(e,r,l)}else se(z,t[t.length-2],t[t.length-1],"canvas");y&&(y.soft=!0,$.classList.add("soft"),k.placeholder="What should change here?",$.querySelector(".hint").innerHTML="<kbd>\u21B5</kbd> send \xB7 <kbd>esc</kbd> skip",Pt={x:n.clientX,y:n.clientY,t:Date.now()})}}addEventListener("pointerdown",e=>{!y?.soft||$.hidden||$.contains(e.target)||k.value.trim()||T==="pen"&&e.target.closest("#viewport")||q()},!0),k.addEventListener("input",()=>{y?.soft&&k.value.trim()&&(y.soft=!1,$.classList.remove("soft"))}),d("#ink-chip button").addEventListener("click",()=>{if(!F)return;const e=it(F.frameId),t=c.frames.find(s=>s.id===F.frameId),n=e.flatMap(s=>s.points.filter((r,l)=>l%2===0)),a=e.flatMap(s=>s.points.filter((r,l)=>l%2===1)),i=n.length?(Math.min(...n)+Math.max(...n))/2-t.x:F.lx,o=a.length?(Math.min(...a)+Math.max(...a))/2-t.y:F.ly;se(F.frameId,i,o)});function Cn(e){q(),Le(),A=e,B.hidden=!1,It(),Ee(),Z()}function N(){A&&(A=null,B.hidden=!0,Ee())}const zt=e=>{const t=(Date.now()-e)/1e3;return t<60?"just now":t<3600?`${Math.floor(t/60)}m ago`:t<86400?`${Math.floor(t/3600)}h ago`:new Date(e).toLocaleDateString()};function Ie(e){if(!e)return"";const t=/claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(e);if(t)return`${t[1][0].toUpperCase()}${t[1].slice(1)} ${t[2]}${t[3]&&t[3].length<=2?"."+t[3]:""}`;const n=/^gpt-([\d.]+)(?:-([a-z]+))?/i.exec(e);return n?`GPT-${n[1]}${n[2]?" "+n[2][0].toUpperCase()+n[2].slice(1):""}`:e}function Dt(e){const t=[];if(e.model&&t.push(Ie(e.model)),e.startedAt&&e.finishedAt){const n=Math.round((e.finishedAt-e.startedAt)/1e3);t.push(n<60?`${n}s`:`${Math.floor(n/60)}m ${n%60}s`)}return t.length?`<div class="a-meta">${w(t.join(" \xB7 "))}</div>`:""}const Se='<svg viewBox="0 0 12 12"><path d="M2.5 6.5l2.2 2.2L9.5 3.5"/></svg>';function Tn(e){const t=b(),n=c.live[e.frameId];switch(e.status){case"waiting":return`<div class="agent-line waiting"><span class="a-ic"></span><div><div>Waiting for the host to send this to the agent.</div>${t?`<div class="a-actions"><button data-act="send" data-id="${e.id}">Send to agent</button></div>`:""}</div></div>`;case"queued":return`<div class="agent-line working"><span class="a-ic">${Se}</span><div><div class="a-text">${c.agent.mode==="inbox"?"Queued for your agent \u2014 run loop inbox --wait":"Queued \u2014 waiting for the frame to be free"}</div>${t?`<div class="a-actions"><button class="quiet" data-act="cancel" data-id="${e.id}">Cancel</button></div>`:""}</div></div>`;case"working":return`<div class="agent-line working"><span class="a-ic">${Se}</span><div><div>${e.model?`<span class="a-model">${w(Ie(e.model))}</span> `:""}<span class="a-text" data-step-for="${e.id}">${w(n?.commentId===e.id?n.step:"Working\u2026")}</span> <span class="muted" data-elapsed-from="${n?.startedAt||e.createdAt}"></span></div>${t?`<div class="a-actions"><button class="quiet" data-act="cancel" data-id="${e.id}">Stop</button></div>`:""}</div></div>`;case"done":return`<div class="agent-line"><span class="a-ic">${Se}</span><div><div>${w(e.undone?"Reverted.":e.reply||"Done.")}</div>${Dt(e)}${t&&e.snapshot&&!e.undone?`<div class="a-actions"><button class="quiet" data-act="undo" data-id="${e.id}">Undo change</button></div>`:""}${t&&e.undone&&e.redoSnap?`<div class="a-actions"><button class="quiet" data-act="redo" data-id="${e.id}">Redo change</button></div>`:""}</div></div>`;case"error":return`<div class="agent-line error"><span class="a-ic">${Se}</span><div><div>${w(e.reply||"Something went wrong.")}</div>${Dt(e)}${t?`<div class="a-actions"><button data-act="send" data-id="${e.id}">Retry</button></div>`:""}</div></div>`;case"canceled":return`<div class="agent-line waiting"><span class="a-ic"></span><div><div>Stopped.</div>${t?`<div class="a-actions"><button data-act="send" data-id="${e.id}">Send again</button></div>`:""}</div></div>`;default:return""}}function It(){const e=c.comments.find(l=>l.id===A);if(!e)return N();const t=c.frames.find(l=>l.id===e.frameId),n=Tt(e.frameId).findIndex(l=>l.id===e.id)+1,a=B.querySelector("textarea")?.value||"",i=document.activeElement===B.querySelector("textarea"),o=Ct(e.id).map(l=>l.kind==="note"?`
9
+ <div class="msg msg-agent">
10
+ <div class="msg-head"><span class="av av-agent">${Se}</span><b>${w(l.author?.name||"Agent")}</b><time>${zt(l.createdAt)}</time>${l.model?`<span class="a-model">${w(Ie(l.model))}</span>`:""}</div>
11
+ <div class="msg-text">${w(l.text)}</div>
12
+ </div>`:`
13
+ <div class="msg">
14
+ <div class="msg-head"><span class="av" style="background:${w(l.author?.color||"#888")}">${w(tt(l.author?.name))}</span><b>${w(l.author?.name||"You")}</b><time>${zt(l.createdAt)}</time></div>
15
+ <div class="msg-text">${w(l.text)}</div>
16
+ ${l.ink?.length?`<div class="muted" style="font-size:11.5px;margin-top:4px">\u270E ${l.ink.length} marked element${l.ink.length===1?"":"s"}</div>`:""}
17
+ ${Tn(l)}
18
+ </div>`).join("");B.innerHTML=`
19
+ <div class="thread-head"><span class="t-title">#${n} \xB7 ${w(t?.name||(e.frameId===z?"Canvas":""))}</span>
20
+ <span class="t-actions">
21
+ <button class="icon-btn" data-act="resolve" title="${e.resolved?"Reopen":"Resolve"}">${e.resolved?"\u21BA":"\u2713"}</button>
22
+ <button class="icon-btn" data-act="delete" title="Delete thread">\u232B</button>
23
+ <button class="icon-btn" data-act="close" title="Close">\xD7</button>
24
+ </span></div>
25
+ <div class="thread-body">${o}</div>
26
+ <div class="thread-reply"><textarea rows="1" placeholder="Reply \u2014 \u21B5 sends it to the agent"></textarea></div>`;const s=B.querySelector("textarea");s.value=a,oe(s),i&&s.focus(),s.addEventListener("input",()=>oe(s)),s.addEventListener("keydown",async l=>{if(l.stopPropagation(),l.key==="Escape")return N();if(l.key==="Enter"&&!l.shiftKey&&!l.isComposing){l.preventDefault();const f=s.value.trim();if(!f)return;s.value="";try{await p("POST","/__loop/api/comments",{frameId:e.frameId,parentId:e.id,text:f,author:{name:C.name,color:C.color}})}catch(m){v(m.message)}}});const r=B.querySelector(".thread-body");r.scrollTop=r.scrollHeight}B.addEventListener("pointerdown",e=>e.stopPropagation()),B.addEventListener("click",async e=>{const t=e.target.closest("[data-act]");if(!t)return;const n=t.dataset.act,a=t.dataset.id||A;try{if(n==="close")return N();if(n==="delete"){confirm("Delete this comment thread?")&&(await p("DELETE",`/__loop/api/comments/${A}`),N());return}if(n==="resolve"){await p("POST",`/__loop/api/comments/${A}/resolve`);return}if(n==="undo"){let i=await fetch(`/__loop/api/comments/${a}/undo`,{method:"POST",headers:{"content-type":"application/json"},body:"{}"}),o=await i.json().catch(()=>({}));if(i.status===409&&o.error==="newer"){if(!confirm(`This frame has ${o.newer} newer change${o.newer===1?"":"s"} after this one. Undoing this reverts ${o.newer===1?"it":"them"} too. Continue?`))return;i=await fetch(`/__loop/api/comments/${a}/undo`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({force:!0})}),o=await i.json().catch(()=>({}))}if(!i.ok)throw new Error(o.error||"Could not undo");v(o.reverted>1?`Reverted ${o.reverted} changes`:"Change reverted");return}await p("POST",`/__loop/api/comments/${a}/${n}`),n==="redo"&&v("Change re-applied")}catch(i){v(i.message)}}),$.addEventListener("pointerdown",e=>e.stopPropagation());function R(e){if(!(e==="frame"&&!b())){if(e!=="deep"&&H&&Te(),y?.soft&&!k.value.trim()&&q(),T=e,x.dataset.tool=e,document.querySelectorAll("#toolbar [data-tool]").forEach(t=>t.classList.toggle("on",t.dataset.tool===e)),G.hidden=!0,e!=="pen"&&Le(),c)for(const t of c.frames)D(t);re()}}document.querySelectorAll("#toolbar [data-tool]").forEach(e=>e.addEventListener("click",()=>R(e.dataset.tool)));function W(e){if(M=e,E.clear(),c)for(const t of c.frames)D(t);re(),Ke()}let j=null;x.addEventListener("pointerdown",e=>{if(!c||e.target.closest(".pin, .stop, .rename"))return;const t=e.target.closest(".frame"),n=t?.dataset.id;if(e.button===1||T==="hand"||ne)return Pn(e);if(e.button===0){if(T==="pen")return qn(e);if(T==="frame")return Dn(e);if(T==="erase"){j={type:"erase"},qt(e),x.setPointerCapture(e.pointerId);return}if(T==="deep"){if(n&&e.target.closest(".frame-body")){Un(n,e);return}Te(),q(),N();return}if(T==="comment"){const a=n&&e.target.closest(".frame-body");if(!a&&(!$.hidden||A)){q(),N();return}j={type:"cpick",fid:a?n:z,sx:e.clientX,sy:e.clientY,area:!1,rect:null},x.setPointerCapture(e.pointerId);return}if(q(),e.target.closest(".resize")&&b())return In(n,e);if(n&&e.shiftKey&&b()){Vn(n),N();return}return e.target.closest(".frame-label")?(N(),E.has(n)?b()?An(n,e):void 0:(W(n),b()?zn(n,e):void 0)):(N(),!n&&!e.shiftKey&&W(null),_n(e,n))}});function _n(e,t){const n=K(e.clientX,e.clientY);j={type:"marquee",x0:n.x,y0:n.y,clickFid:t,base:e.shiftKey?new Set([...E,...M?[M]:[]]):new Set},x.classList.add("dragging"),x.setPointerCapture(e.pointerId)}function An(e,t){const n=[...E];j={type:"gmove",ids:n,sx:t.clientX,sy:t.clientY,from:Object.fromEntries(n.map(a=>{const i=c.frames.find(o=>o.id===a);return[a,{x:i.x,y:i.y}]})),moved:!1},x.classList.add("dragging"),x.setPointerCapture(t.pointerId)}function Pn(e){j={type:"pan",sx:e.clientX,sy:e.clientY,vx:u.x,vy:u.y},x.classList.add("dragging"),x.setPointerCapture(e.pointerId)}function zn(e,t){const n=c.frames.find(a=>a.id===e);j={type:"move",fid:e,sx:t.clientX,sy:t.clientY,fx:n.x,fy:n.y,moved:!1,dup:t.altKey},x.classList.add("dragging"),x.setPointerCapture(t.pointerId)}function Dn(e){const t=K(e.clientX,e.clientY);j={type:"draw",x0:t.x,y0:t.y,rect:null},x.classList.add("dragging"),x.setPointerCapture(e.pointerId)}function In(e,t){const n=c.frames.find(i=>i.id===e),a=t.target.closest(".resize")?.dataset.dir||"se";j={type:"resize",fid:e,dir:a,sx:t.clientX,sy:t.clientY,x0:n.x,y0:n.y,w:n.w,h:n.h},x.classList.add("dragging"),x.setPointerCapture(t.pointerId)}function qn(e){Le(),y?.soft&&!k.value.trim()&&q();const t=K(e.clientX,e.clientY),n=document.createElementNS("http://www.w3.org/2000/svg","path");n.setAttribute("stroke",Ve),n.setAttribute("stroke-width",3/u.z),ye.appendChild(n),j={type:"stroke",points:[t.x,t.y],path:n,width:3/u.z},x.classList.add("dragging"),x.setPointerCapture(e.pointerId)}const On=11;function Hn(e,t,n,a,i,o){const s=i-n,r=o-a,l=s*s+r*r,f=l?Math.max(0,Math.min(1,((e-n)*s+(t-a)*r)/l)):0;return Math.hypot(e-(n+f*s),t-(a+f*r))}function qt(e){const t=K(e.clientX,e.clientY);for(const n of[...c.strokes]){if(n.commentId||String(n.id).startsWith("ink-"))continue;const a=On/u.z+(n.width||3)/2,i=n.points;let o=!1;for(let s=0;s+1<i.length&&!o;s+=2){const r=s+3<i.length?i[s+2]:i[s],l=s+3<i.length?i[s+3]:i[s+1];o=Hn(t.x,t.y,i[s],i[s+1],r,l)<=a}o&&(c.strokes=c.strokes.filter(s=>s.id!==n.id),ye.querySelector(`path[data-id="${n.id}"]`)?.remove(),p("DELETE",`/__loop/api/strokes/${n.id}`).catch(()=>{}),O("Erase mark",()=>pe({strokes:[n]}),()=>p("DELETE",`/__loop/api/strokes/${n.id}`)))}}x.addEventListener("pointermove",e=>{if(gn(e.clientX,e.clientY),!j){if(T==="comment"&&(G.hidden=!0),T==="deep"){const n=e.target.closest(".frame-body")&&e.target.closest(".frame")?.dataset.id;n&&!e.target.closest(".pin")?vn(n,e.clientX,e.clientY):G.hidden=!0}return}const t=j;if(t.type==="pan")u.x=t.vx+e.clientX-t.sx,u.y=t.vy+e.clientY-t.sy,X();else if(t.type==="move"&&t.dup){t.moved=!0;const n=c.frames.find(i=>i.id===t.fid);if(t.to={x:t.fx+(e.clientX-t.sx)/u.z,y:t.fy+(e.clientY-t.sy)/u.z},!e.metaKey&&!e.ctrlKey){const i=mt({...t.to,w:n.w,h:n.h},null);t.to.x+=i.dx,t.to.y+=i.dy,Q(i.guides)}else Q([]);const a=d("#dup-ghost");Object.assign(a.style,{left:`${t.to.x}px`,top:`${t.to.y}px`,width:`${n.w}px`,height:`${n.h}px`}),a.querySelector("span").textContent=`${n.name} copy`,a.hidden=!1}else if(t.type==="move"){t.moved=!0;const n=c.frames.find(i=>i.id===t.fid),a={x:t.fx+(e.clientX-t.sx)/u.z,y:t.fy+(e.clientY-t.sy)/u.z};if(!e.metaKey&&!e.ctrlKey){const i=mt({...a,w:n.w,h:n.h},t.fid);a.x+=i.dx,a.y+=i.dy,Q(i.guides)}else Q([]);ee.set(t.fid,a),D(n),Z()}else if(t.type==="marquee"){const n=K(e.clientX,e.clientY),a={x:Math.min(t.x0,n.x),y:Math.min(t.y0,n.y),w:Math.abs(n.x-t.x0),h:Math.abs(n.y-t.y0)};if(!t.moved&&a.w*u.z<4&&a.h*u.z<4)return;!t.moved&&t.clickFid&&!e.shiftKey&&W(null),t.moved=!0;const i=d("#marquee");i.hidden=!1,Object.assign(i.style,{left:`${a.x}px`,top:`${a.y}px`,width:`${a.w}px`,height:`${a.h}px`});const o=c.frames.filter(s=>s.x<a.x+a.w&&s.x+s.w>a.x&&s.y<a.y+a.h&&s.y+s.h>a.y).map(s=>s.id);ft([...new Set([...t.base,...o])])}else if(t.type==="gmove"){t.moved=!0;let n=(e.clientX-t.sx)/u.z,a=(e.clientY-t.sy)/u.z;const i=t.ids.map(o=>{const s=c.frames.find(r=>r.id===o);return{x:t.from[o].x+n,y:t.from[o].y+a,w:s.w,h:s.h}});if(!e.metaKey&&!e.ctrlKey){const o=ut(i),s=mt(o,new Set(t.ids));n+=s.dx,a+=s.dy,Q(s.guides)}else Q([]);for(const o of t.ids)ee.set(o,{x:t.from[o].x+n,y:t.from[o].y+a}),D(c.frames.find(s=>s.id===o));he()}else if(t.type==="cpick"){if(!t.area&&Math.hypot(e.clientX-t.sx,e.clientY-t.sy)>6&&(t.area=!0,q(),N(),x.classList.add("dragging")),!t.area)return;let n,a;if(t.fid===z)n=K(t.sx,t.sy),a=K(e.clientX,e.clientY);else{const o=c.frames.find(r=>r.id===t.fid),s=r=>({x:Math.max(0,Math.min(o.w,r.lx)),y:Math.max(0,Math.min(o.h,r.ly))});n=s(be(t.fid,t.sx,t.sy)),a=s(be(t.fid,e.clientX,e.clientY))}t.rect={x:Math.min(n.x,a.x),y:Math.min(n.y,a.y),w:Math.abs(a.x-n.x),h:Math.abs(a.y-n.y)};let i=ae(t.fid).querySelector(".area.picking");i||(i=document.createElement("div"),i.className="area picking show",ae(t.fid).prepend(i)),Object.assign(i.style,{left:`${t.rect.x}px`,top:`${t.rect.y}px`,width:`${t.rect.w}px`,height:`${t.rect.h}px`})}else if(t.type==="draw"){const n=K(e.clientX,e.clientY),a={x:Math.min(t.x0,n.x),y:Math.min(t.y0,n.y),w:Math.abs(n.x-t.x0),h:Math.abs(n.y-t.y0)};e.shiftKey&&(a.w=a.h=Math.max(a.w,a.h)),t.rect=a;const i=d("#draw-box");Object.assign(i.style,{left:`${a.x}px`,top:`${a.y}px`,width:`${a.w}px`,height:`${a.h}px`}),i.querySelector("span").textContent=`${Math.round(a.w)} \xD7 ${Math.round(a.h)}`,i.hidden=!1}else if(t.type==="resize"){const n=(e.clientX-t.sx)/u.z,a=(e.clientY-t.sy)/u.z,i=120;let o=t.x0,s=t.y0,r=t.w,l=t.h;if(t.dir.includes("e")&&(r=t.w+n),t.dir.includes("s")&&(l=t.h+a),t.dir.includes("w")&&(r=t.w-n,o=t.x0+n),t.dir.includes("n")&&(l=t.h-a,s=t.y0+a),e.shiftKey&&t.dir.length===2){const m=Math.max(r/t.w,l/t.h),h=t.w*m,g=t.h*m;t.dir.includes("w")&&(o=t.x0+t.w-h),t.dir.includes("n")&&(s=t.y0+t.h-g),r=h,l=g}r<i&&(t.dir.includes("w")&&(o=t.x0+t.w-i),r=i),l<i&&(t.dir.includes("n")&&(s=t.y0+t.h-i),l=i);const f={x:o,y:s,w:r,h:l};if(!e.metaKey&&!e.ctrlKey&&!e.shiftKey){const m=na(f,t.fid,t.dir);Object.assign(f,m.rect),Q(m.guides)}else Q([]);ee.set(t.fid,f),D(c.frames.find(m=>m.id===t.fid)),d("#fp-sw")&&(d("#fp-sw").value=Math.round(f.w),d("#fp-sh").value=Math.round(f.h))}else if(t.type==="stroke"){const n=K(e.clientX,e.clientY),a=t.points.length;if(Math.hypot(n.x-t.points[a-2],n.y-t.points[a-1])*u.z<1.5)return;t.points.push(n.x,n.y),t.path.setAttribute("d",Mt(t.points))}else t.type==="erase"&&qt(e)});const Ot=async e=>{const t=j;if(t){if(j=null,x.classList.remove("dragging"),Q([]),t.type==="marquee"){d("#marquee").hidden=!0,!t.moved&&t.clickFid&&(W(t.clickFid),_.get(t.clickFid)?.el.classList.contains("empty")&&De(t.clickFid));return}if(t.type==="gmove"){if(!t.moved)return;S=null,Fe();const n=t.from,a={};for(const i of t.ids){const o=ee.get(i);ee.delete(i);const s=c.frames.find(r=>r.id===i);o&&s&&(Object.assign(s,o),a[i]={x:o.x,y:o.y})}J(a),O(`Move ${t.ids.length} frames`,()=>J(n),()=>J(a));return}if(t.type==="cpick"){if(document.querySelectorAll(".area.picking").forEach(i=>i.remove()),t.area&&t.rect&&t.rect.w*u.z>8&&t.rect.h*u.z>8)return se(t.fid,t.rect.x,t.rect.y,null,t.rect);if(t.area)return;if(t.fid===z){const i=K(t.sx,t.sy);return se(z,i.x,i.y,"canvas")}const{lx:n,ly:a}=be(t.fid,t.sx,t.sy);return _.get(t.fid)?.el.classList.contains("empty")?De(t.fid):se(t.fid,n,a)}if(t.type==="draw"){d("#draw-box").hidden=!0;const n=t.rect;n&&n.w*u.z>12&&n.h*u.z>12&&ot("Frame",n.w,n.h,n.x,n.y);return}if(t.type==="move"&&t.dup){d("#dup-ghost").hidden=!0,t.moved&&t.to&&Xe(t.fid,t.to);return}if(t.type==="move"&&t.moved||t.type==="resize"){const n=ee.get(t.fid),a=c.frames.find(o=>o.id===t.fid),i=t.type==="move"?{x:t.fx,y:t.fy}:{x:t.x0,y:t.y0,w:t.w,h:t.h};if(n&&a&&Object.assign(a,n),ee.delete(t.fid),n){p("PATCH",`/__loop/api/frames/${t.fid}`,n).catch(r=>v(r.message));const o={...n},s=t.fid;O(t.type==="move"?"Move frame":"Resize frame",()=>te(s,i),()=>te(s,o))}t.type==="resize"&&setTimeout(()=>ie(t.fid),50)}else if(t.type==="stroke"){const n=t.points;n.length<4&&n.push(n[0]+.5,n[1]+.5);const a=new Map;for(let o=0;o<n.length;o+=2){const s=yn(n[o],n[o+1]);s&&a.set(s.id,(a.get(s.id)||0)+1)}const i=[...a.entries()].sort((o,s)=>s[1]-o[1])[0]?.[0]||null;try{const o=await p("POST","/__loop/api/strokes",{points:n,color:Ve,width:t.width,frameId:i});vt.add(o.id),O("Draw mark",()=>p("DELETE",`/__loop/api/strokes/${o.id}`),()=>pe({strokes:[o]})),t.path.remove(),c.strokes.find(s=>s.id===o.id)||c.strokes.push(o),Lt(),Sn(i,n,e)}catch(o){t.path.remove(),v(o.message)}}}};x.addEventListener("pointerup",Ot),x.addEventListener("pointercancel",Ot),x.addEventListener("pointerleave",()=>{G.hidden=!0}),addEventListener("wheel",e=>{if(e.ctrlKey||e.metaKey)return bt(e,e.clientX,e.clientY);if(!e.target.closest?.("#viewport"))return;e.preventDefault();const t=e.deltaMode===1?16:1;u.x-=e.deltaX*t,u.y-=e.deltaY*t,wt()},{passive:!1,capture:!0});let qe=1;addEventListener("gesturestart",e=>{e.preventDefault(),qe=u.z}),addEventListener("gesturechange",e=>{e.preventDefault(),V(e.clientX,e.clientY,qe*e.scale/u.z)});const Ht=()=>{const e=document.activeElement;return e&&(e.tagName==="INPUT"||e.tagName==="TEXTAREA"||e.isContentEditable)};addEventListener("keydown",e=>{if(Ht())return;if(e.code==="Space"&&!ne){ne=!0,x.classList.add("panning"),c&&c.frames.forEach(D),e.preventDefault();return}if(e.metaKey||e.ctrlKey){if(e.key.toLowerCase()==="z"){e.preventDefault(),e.shiftKey?dt():Zt();return}if(e.key.toLowerCase()==="c"&&M&&b()){e.preventDefault(),Ye=M,v(`Copied \u201C${c.frames.find(n=>n.id===M)?.name}\u201D \u2014 \u2318V to paste`,1600);return}if(e.key.toLowerCase()==="v"&&Ye&&b()){e.preventDefault(),c.frames.find(n=>n.id===Ye)?Xe(Ye):v("The copied frame no longer exists");return}if(e.key.toLowerCase()==="d"&&E.size&&b()){e.preventDefault(),[...E].forEach(n=>Xe(n));return}if(e.key.toLowerCase()==="d"&&M&&b()){e.preventDefault(),Xe(M);return}if(e.key.toLowerCase()==="a"&&b()){e.preventDefault(),ft(c.frames.map(n=>n.id));return}if(e.key.toLowerCase()==="y"&&e.ctrlKey){e.preventDefault(),dt();return}e.key==="0"&&(e.preventDefault(),u.z=1,X()),(e.key==="="||e.key==="+")&&(e.preventDefault(),V(innerWidth/2,innerHeight/2,1.25)),e.key==="-"&&(e.preventDefault(),V(innerWidth/2,innerHeight/2,.8));return}const t=e.key.toLowerCase();if(t==="v")R("select");else if(t==="h")R("hand");else if(t==="p")R("pen");else if(t==="e")R("erase");else if(t==="c")R("comment");else if(t==="d")R("deep");else if(t==="n"&&b())jt();else if(t==="f"&&b())R(T==="frame"?"select":"frame");else if(e.key==="!"||e.shiftKey&&e.code==="Digit1")we();else if(e.key==="="||e.key==="+")V(innerWidth/2,innerHeight/2,1.25);else if(e.key==="-")V(innerWidth/2,innerHeight/2,.8);else if(e.key==="Escape"&&H)Te(),q();else if(e.key==="Escape")$.hidden?A?N():!d("#agent-menu").hidden||!d("#share-menu").hidden?me():(W(null),R("select")):q();else if((e.key==="Backspace"||e.key==="Delete")&&E.size&&b())Zn([...E]);else if(e.altKey&&b()&&E.size>=2&&Qt[e.code])e.preventDefault(),en(Qt[e.code]);else if((e.key==="Backspace"||e.key==="Delete")&&M&&b()){const n=c.frames.find(a=>a.id===M);n&&Xn(n.id).then(()=>{M=null,re()})}}),addEventListener("keyup",e=>{e.code==="Space"&&(ne=!1,x.classList.remove("panning"),c&&c.frames.forEach(D))}),addEventListener("blur",()=>{ne&&(ne=!1,x.classList.remove("panning"))}),d("#zoom-in").onclick=()=>V(innerWidth/2,innerHeight/2,1.25),d("#zoom-out").onclick=()=>V(innerWidth/2,innerHeight/2,.8),d("#zoom-level").onclick=()=>we();function me(){d("#agent-menu").hidden=!0,d("#share-menu").hidden=!0}function Nt(e,t){const n=t.getBoundingClientRect();e.hidden=!1,e.style.top=`${n.bottom+8}px`,e.style.left=`${Math.max(12,Math.min(n.right-e.offsetWidth,innerWidth-e.offsetWidth-12))}px`}d("#agent-pill").addEventListener("click",e=>{if(e.stopPropagation(),!b())return;const t=d("#agent-menu"),n=!t.hidden;me(),n||Nt(t,e.currentTarget)}),document.querySelectorAll("#agent-menu .menu-item").forEach(e=>e.addEventListener("click",async()=>{try{await p("POST","/__loop/api/settings",{mode:e.dataset.mode,runner:e.dataset.runner}),v(e.dataset.mode==="inbox"?"Comments will queue for your agent session":`Loop will run ${e.dataset.runner==="codex"?"Codex":"Claude Code"} on each comment`)}catch(t){v(t.message)}me()}));function Nn(){const e=!!c.emailSharing;d("#share-menu > .menu-title").textContent=e?"Invite people":"Share this canvas",d("#invite-signin").hidden=!e||!!I?.user,d("#invite-form").hidden=!e||!I?.user;const t=d("#share-menu .lan-share");t.open=!e,t.querySelector("summary").hidden=!e,t.classList.toggle("only",!e)}function Oe(){const e=c.share||{};d("#share-lan").value=e.lan||"No network connection",d("#share-public-row").hidden=!e.public,d("#share-public").value=e.public||"",d("#share-public-btn").hidden=!!e.public,d("#share-public-btn").textContent=e.tunnelAvailable?"Create public link":"Create public link (needs cloudflared)"}d("#share-btn").addEventListener("click",async e=>{if(e.stopPropagation(),!b()){try{await navigator.clipboard.writeText(location.href),v("Link copied")}catch{v(location.href,5e3)}return}const t=d("#share-menu"),n=!t.hidden;me(),n||(Oe(),Nn(),d("#share-public-note").textContent="",Nt(t,e.currentTarget),Re())}),d("#share-public-btn").addEventListener("click",async()=>{const e=d("#share-public-note"),t=d("#share-public-btn");t.disabled=!0,e.textContent="Opening a tunnel\u2026";try{c.share=await p("POST","/__loop/api/share/public"),e.textContent="Live while this Mac is awake and Loop is running.",Oe()}catch(n){e.textContent=n.message}t.disabled=!1}),d("#share-rotate").addEventListener("click",async()=>{confirm("Reset share links? Anyone using the old links loses access.")&&(c.share=await p("POST","/__loop/api/share/rotate"),Oe(),v("Links reset"))}),document.querySelectorAll("[data-copy]").forEach(e=>e.addEventListener("click",async()=>{const t=d("#"+e.dataset.copy).value;try{await navigator.clipboard.writeText(t),v("Link copied")}catch{d("#"+e.dataset.copy).select()}})),document.querySelectorAll(".menu").forEach(e=>e.addEventListener("pointerdown",t=>t.stopPropagation())),addEventListener("pointerdown",e=>{e.target.closest(".menu, #agent-pill, #share-btn")||me()});let Rt="1440x1024";function jt(){me(),d("#new-frame").hidden=!1;const e=d("#new-frame-input");e.value="",setTimeout(()=>e.focus(),0)}function He(){d("#new-frame").hidden=!0}d("#new-frame [data-close]").addEventListener("click",He),d("#new-frame").addEventListener("pointerdown",e=>{e.target.id==="new-frame"&&He()}),document.querySelectorAll("#size-pick button").forEach(e=>e.addEventListener("click",()=>{Rt=e.dataset.size,document.querySelectorAll("#size-pick button").forEach(t=>t.classList.toggle("on",t===e))})),d("#new-frame-input").addEventListener("keydown",e=>{e.stopPropagation(),e.key==="Escape"&&He(),e.key==="Enter"&&!e.shiftKey&&!e.isComposing&&(e.preventDefault(),Ft())}),d("#new-frame-go").addEventListener("click",Ft);async function Ft(){const e=d("#new-frame-input").value.trim();if(!e)return;const[t,n]=Rt.split("x").map(Number),a=/^(https?:\/\/|\/|~\/|\.\.?\/)/.test(e)&&!/\s/.test(e);try{const i=await p("POST","/__loop/api/frames",a?{src:e,w:t,h:n}:{prompt:e,w:t,h:n});He(),setTimeout(()=>{W(null),we(!0,[i.id])},80)}catch(i){v(i.message,4e3)}}d("#name-go").addEventListener("click",Kt),d("#name-input").addEventListener("keydown",e=>{e.stopPropagation(),e.key==="Enter"&&Kt()});function Kt(){const e=d("#name-input").value.trim();e&&(C.name=e,ve.set("loop.me",C),d("#name-modal").hidden=!0,Pe())}let I=null,Ne=null;async function Wt(e){if(Ne)return Ne;const{createClient:t}=await import("https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.49.4/+esm");return Ne=t(e.url,e.key,{auth:{flowType:"pkce",detectSessionInUrl:!0,persistSession:!0,storageKey:"loop-host-auth"}}),Ne}async function Re(){if(!(P||!b())&&c.emailSharing){try{I=await p("GET","/__loop/api/cloud")}catch(e){d("#invite-note").textContent=e.message;return}Rn()}}function Bt(e,t){return t?`<img src="${w(t)}" alt="" referrerpolicy="no-referrer">`:w(tt(e))}function Rn(){const e=!!I?.user;d("#invite-signin").hidden=e,d("#invite-form").hidden=!e,e&&(d("#me-avatar").innerHTML=Bt(I.user.name,I.user.avatar),d("#me-email").textContent=I.user.email,d("#cloud-link").value=I.link||"",I.error&&!d("#invite-note").textContent&&(d("#invite-note").textContent=I.error),d("#members").innerHTML=(I.members||[]).map(t=>`
27
+ <div class="member"><span class="m-av">${Bt(t.name||t.email,t.avatar_url)}</span>
28
+ <span class="m-text"><div class="m-name">${w(t.name||t.email)}</div><div class="m-sub">${t.name?w(t.email)+" \xB7 ":""}${t.joined_at?"Joined":"Invited"}${t.role==="viewer"?" \xB7 can view":""}</div></span>
29
+ <button class="m-x" title="Remove" data-email="${w(t.email)}">\xD7</button></div>`).join(""))}d("#members").addEventListener("click",async e=>{const t=e.target.closest("[data-email]");if(t&&confirm(`Remove ${t.dataset.email} from this canvas?`))try{await p("DELETE",`/__loop/api/cloud/members/${encodeURIComponent(t.dataset.email)}`),Re()}catch(n){v(n.message)}}),d("#cloud-google").addEventListener("click",async()=>{try{const e=I||await p("GET","/__loop/api/cloud");if(!(await fetch(`${e.config.url}/auth/v1/settings`,{headers:{apikey:e.config.key}}).then(i=>i.json()).catch(()=>({external:{google:!0}}))).external?.google){d("#invite-note").textContent="",v("Google sign-in isn\u2019t switched on yet. Enable the Google provider in Supabase (Authentication \u2192 Sign In / Providers).",7e3);return}const n=await Wt(e.config),{error:a}=await n.auth.signInWithOAuth({provider:"google",options:{redirectTo:`${location.origin}/__loop/?loop_auth=1`}});if(a)throw a}catch(e){v(e.message||"Google sign-in is not available",5e3)}}),d("#cloud-signout").addEventListener("click",async()=>{await p("POST","/__loop/api/cloud/signout"),I=null,Re()});async function jn(){if(!(P||!new URLSearchParams(location.search).has("loop_auth"))){try{const e=await p("GET","/__loop/api/cloud"),t=await Wt(e.config);let{data:n}=await t.auth.getSession();for(let i=0;!n.session&&i<20;i++)await new Promise(o=>setTimeout(o,150)),{data:n}=await t.auth.getSession();if(!n.session)throw new Error("Google sign-in did not complete");t.auth.stopAutoRefresh();const{user:a}=await p("POST","/__loop/api/cloud/session",{access_token:n.session.access_token,refresh_token:n.session.refresh_token,expires_at:n.session.expires_at});try{localStorage.removeItem("loop-host-auth")}catch{}v(`Signed in as ${a.email}`),setTimeout(()=>d("#share-btn").click(),300)}catch(e){v(e.message,5e3)}history.replaceState(null,"","/__loop/")}}async function Yt(e){const t=U(e);if(!t?.documentElement)throw new Error("screen not loaded yet");const n=`<!doctype html>
30
+ `+t.documentElement.outerHTML;return p("POST","/__loop/api/cloud/snapshot",{frameId:e,html:n})}async function Fn(e){await p("POST","/__loop/api/cloud/publish");const t=c.frames.filter(a=>a.kind==="route");let n=0;for(const a of t){e.textContent=`Uploading screens\u2026 ${++n}/${t.length}`;try{await Yt(a.id)}catch(i){console.warn("snapshot",a.name,i)}}}function Kn(e){const t=c?.frames.find(n=>n.id===e);P||!t||t.kind!=="route"||!I?.canvasId||!I?.user||setTimeout(()=>Yt(e).catch(()=>{}),2500)}d("#invite-go").addEventListener("click",Xt),d("#invite-emails").addEventListener("keydown",e=>{e.stopPropagation(),e.key==="Enter"&&Xt()});async function Xt(){const e=d("#invite-emails").value.split(/[\s,;]+/).map(a=>a.trim()).filter(Boolean),t=d("#invite-note"),n=d("#invite-go");if(!e.length){t.textContent="Add one or more email addresses.";return}n.disabled=!0;try{await Fn(t),t.textContent="Sending invites\u2026";const a=await p("POST","/__loop/api/cloud/invite",{emails:e});d("#invite-emails").value="",t.textContent=a.warning?a.warning:a.failed?.length?`Invited, but email failed for ${a.failed.map(i=>i.email).join(", ")} \u2014 share the link with them.`:`Invite${a.emailed.length===1?"":"s"} sent to ${a.emailed.join(", ")}.`,await Re()}catch(a){t.textContent=a.message}n.disabled=!1}const Ut=[["Mobile",393,852],["Tablet",834,1194],["Desktop",1440,1024]],Gt=[["Phone",[["iPhone 16",393,852],["iPhone 16 Pro Max",440,956],["iPhone SE",375,667],["Android (compact)",412,917],["Android (large)",448,998]]],["Tablet",[["iPad mini",744,1133],['iPad Pro 11"',834,1194],['iPad Pro 13"',1032,1376],["Android tablet",800,1280]]],["Desktop",[["Desktop",1440,1024],["MacBook Air",1280,832],['MacBook Pro 14"',1512,982],["Desktop HD",1920,1080],["Wide",1728,1117]]]],Wn={Mobile:'<svg viewBox="0 0 24 24"><rect x="7" y="2.5" width="10" height="19" rx="2.2"/><path d="M11 18.5h2"/></svg>',Tablet:'<svg viewBox="0 0 24 24"><rect x="4.5" y="2.5" width="15" height="19" rx="2.2"/><path d="M11 18.5h2"/></svg>',Desktop:'<svg viewBox="0 0 24 24"><rect x="2.5" y="4" width="19" height="12.5" rx="1.8"/><path d="M9 20h6M12 16.5V20"/></svg>'};function Bn(e){const t=new Set(c.frames.map(n=>n.name));if(!t.has(e))return e;for(let n=2;;n++)if(!t.has(`${e} ${n}`))return`${e} ${n}`}function Yn(e,t){const n=K(innerWidth/2,innerHeight/2),a=(s,r)=>c.frames.some(l=>s<l.x+l.w+80&&s+e+80>l.x&&r<l.y+l.h+80&&r+t+80>l.y),i=Math.round(n.x-e/2),o=Math.round(n.y-t/2);for(let s=0;s<30;s++)for(const[r,l]of[[1,0],[0,1],[-1,0],[0,-1]]){const f=i+r*s*(e*.5+80),m=o+l*s*(t*.5+80);if(!a(f,m))return{x:f,y:m}}return{x:i,y:o}}function st(e){const t=c.frames.find(i=>i.id===e);if(!t)return;const n={x:u.x+t.x*u.z,y:u.y+t.y*u.z},a={x:n.x+t.w*u.z,y:n.y+t.h*u.z};(n.x<0||n.y<60||a.x>innerWidth||a.y>innerHeight-80)&&we(!0,[e])}async function ot(e,t,n,a,i){const o=a!=null?{x:Math.round(a),y:Math.round(i)}:Yn(t,n);try{const s=await p("POST","/__loop/api/frames",{new:!0,blank:!0,name:Bn(e),w:Math.round(t),h:Math.round(n),...o});xe=s.id,Ae=s.id;let r=null;O("Add frame",async()=>{r=je(s.id)||r,await p("DELETE",`/__loop/api/frames/${s.id}`)},()=>pe(r||{frames:[s]})),R("select"),c.frames.find(l=>l.id===s.id)&&(xe=Ae=null,W(s.id),st(s.id),setTimeout(()=>De(s.id),340))}catch(s){v(s.message)}}function re(){const e=d("#frame-panel");if(!c||!b()||P){e.hidden=!0;return}if(e.contains(document.activeElement)&&document.activeElement.tagName==="INPUT")return;const t=T==="select"&&M&&c.frames.find(a=>a.id===M);if(T!=="frame"&&!t){e.hidden=!0;return}e.hidden=!1;const n=d("#fp-body");if(T==="frame")d("#fp-title").textContent="New frame",n.innerHTML=`
31
+ <div class="fp-quick">${Ut.map(([a,i,o])=>`<button class="fp-q" data-w="${i}" data-h="${o}" data-name="${a}">${Wn[a]}<b>${a}</b><small>${i} \xD7 ${o}</small></button>`).join("")}</div>
32
+ <p class="fp-hint">Or drag on the canvas to draw any size. Hold \u21E7 for a square.</p>
33
+ ${Gt.map(([a,i])=>`<details class="fp-group"><summary>${a}</summary>${i.map(([o,s,r])=>`<button class="fp-row" data-w="${s}" data-h="${r}" data-name="${o}"><span>${w(o)}</span><small>${s} \xD7 ${r}</small></button>`).join("")}</details>`).join("")}
34
+ <div class="fp-custom"><label>W<input id="fp-w" inputmode="numeric" value="1200"></label><label>H<input id="fp-h" inputmode="numeric" value="800"></label><button id="fp-make" class="btn-dark">Add</button></div>
35
+ <button id="fp-describe" class="link-btn">Describe a frame and let the agent build it (N)</button>`;else{d("#fp-title").innerHTML=`<input id="fp-name" value="${w(t.name)}" title="Rename frame" spellcheck="false">`;const a=t.kind==="route"?`App route ${t.src}`:t.kind==="url"?"Live URL":"Static frame";n.innerHTML=`
36
+ <div class="fp-size"><label>W<input id="fp-sw" inputmode="numeric" value="${Math.round(t.w)}"></label><label>H<input id="fp-sh" inputmode="numeric" value="${Math.round(t.h)}"></label></div>
37
+ <div class="fp-chips">${Ut.map(([i,o,s])=>`<button class="fp-chip${Math.round(t.w)===o&&Math.round(t.h)===s?" on":""}" data-w="${o}" data-h="${s}">${i}</button>`).join("")}</div>
38
+ <details class="fp-group"><summary>All sizes</summary>${Gt.flatMap(([,i])=>i).map(([i,o,s])=>`<button class="fp-row resize" data-w="${o}" data-h="${s}"><span>${w(i)}</span><small>${o} \xD7 ${s}</small></button>`).join("")}</details>
39
+ <p class="fp-hint">${w(a)} \xB7 drag the corner handle to resize freely.</p>`}}async function rt(e,t){const n=c.frames.find(s=>s.id===M);if(!n||!(e>0&&t>0))return;const a={w:n.w,h:n.h},i={w:Math.round(e),h:Math.round(t)},o=n.id;(a.w!==i.w||a.h!==i.h)&&O("Resize frame",()=>te(o,a),()=>te(o,i)),Object.assign(n,i),D(n);try{await p("PATCH",`/__loop/api/frames/${n.id}`,{w:n.w,h:n.h})}catch(s){v(s.message)}re(),setTimeout(()=>ie(n.id),60)}d("#frame-panel").addEventListener("pointerdown",e=>e.stopPropagation()),d("#frame-panel").addEventListener("wheel",e=>e.stopPropagation()),d("#frame-panel").addEventListener("click",e=>{if(e.target.closest("#fp-close")){T==="frame"?R("select"):W(null);return}if(e.target.closest("#fp-describe")){R("select"),jt();return}if(e.target.closest("#fp-make")){const n=+d("#fp-w").value,a=+d("#fp-h").value;n>0&&a>0&&ot("Frame",n,a);return}const t=e.target.closest("[data-w]");t&&(T==="frame"?ot(t.dataset.name,+t.dataset.w,+t.dataset.h):rt(+t.dataset.w,+t.dataset.h))}),d("#frame-panel").addEventListener("keydown",e=>{if(e.stopPropagation(),e.key==="Escape"){e.target.blur();return}if(e.key==="Enter"){if(e.target.id==="fp-sw"||e.target.id==="fp-sh"){const t=+d("#fp-sw").value,n=+d("#fp-sh").value;e.target.blur(),rt(t,n)}(e.target.id==="fp-w"||e.target.id==="fp-h")&&d("#fp-make").click()}}),d("#frame-panel").addEventListener("focusout",e=>{if((e.target.id==="fp-sw"||e.target.id==="fp-sh")&&!d("#frame-panel").contains(e.relatedTarget)){const t=c.frames.find(i=>i.id===M),n=+d("#fp-sw")?.value,a=+d("#fp-sh")?.value;t&&(n!==Math.round(t.w)||a!==Math.round(t.h))&&rt(n,a)}});const Ce=[],lt=[];let ct=!1;function O(e,t,n){ct||P||!b()||(Ce.push({label:e,undo:t,redo:n}),Ce.length>200&&Ce.shift(),lt.length=0)}async function Vt(e,t,n){const a=e.pop();if(!a){v(n==="undo"?"Nothing to undo":"Nothing to redo",1200);return}ct=!0;try{await a[n](),t.push(a),v(`${n==="undo"?"Undo":"Redo"}: ${a.label}`,1400)}catch(i){v(`Couldn't ${n}: ${i.message}`)}finally{ct=!1}}const Zt=()=>Vt(Ce,lt,"undo"),dt=()=>Vt(lt,Ce,"redo");function te(e,t){const n=c.frames.find(a=>a.id===e);return n&&(Object.assign(n,t),D(n),re(),setTimeout(()=>ie(e),50)),p("PATCH",`/__loop/api/frames/${e}`,t)}const pe=e=>p("POST","/__loop/api/restore",e);function je(e){const t=c.frames.find(n=>n.id===e);return t?{frames:[{...t}],comments:c.comments.filter(n=>n.frameId===e).map(n=>({...n}))}:null}async function Xn(e){const t=je(e);t&&(await p("DELETE",`/__loop/api/frames/${e}`),O("Delete frame",()=>pe(t),()=>p("DELETE",`/__loop/api/frames/${e}`)),v(`Deleted \u201C${t.frames[0].name}\u201D \u2014 \u2318Z to undo`,2600))}let H=null;function Un(e,t){const{lx:n,ly:a}=be(e,t.clientX,t.clientY),i=ue(e,n,a),o=U(e);if(!i||i===o?.documentElement)return;if(t.shiftKey&&H?.frameId===e){const f=H.els.indexOf(i);if(f>=0?H.els.splice(f,1):H.els.push(i),!H.els.length){Te(),q();return}}else H={frameId:e,els:[i]};const s=!$.hidden&&y?.deep?k.value:"";se(e,n,a);const r=H.els;y.deep=!0,y.anchor={...y.anchor,scope:"elements",elements:r.map(Gn)},k.value=s,oe(k),k.placeholder=r.length>1?"What should change about these?":"What should change about this?";const l=d("#composer-area");l.hidden=!1,l.textContent=`${r.length} element${r.length===1?"":"s"}${r.length===1?` \xB7 ${$e(r[0])}`:""}`,Jt()}function Gn(e){const t=e.outerHTML;return{selector:ke(e),tag:e.tagName.toLowerCase(),label:$e(e),text:(e.innerText||e.getAttribute("aria-label")||e.getAttribute("alt")||"").replace(/\s+/g," ").trim().slice(0,120),html:t.length>900?t.slice(0,900)+"\u2026":t}}function Jt(){if(document.querySelectorAll(".deep-box").forEach(t=>t.remove()),!H)return;const e=ae(H.frameId);if(e)for(const t of H.els){const n=t.getBoundingClientRect(),a=document.createElement("div");a.className="deep-box",Object.assign(a.style,{left:`${n.left}px`,top:`${n.top}px`,width:`${n.width}px`,height:`${n.height}px`}),a.dataset.tag=$e(t),e.appendChild(a)}}function Te(){H=null,document.querySelectorAll(".deep-box").forEach(e=>e.remove())}function ft(e){if(e=e.filter(t=>c.frames.find(n=>n.id===t)),e.length===1)return W(e[0]);M=null,E.clear(),e.forEach(t=>E.add(t));for(const t of c.frames)D(t);re(),Ke()}function Vn(e){const t=new Set(E.size?E:M?[M]:[]);t.has(e)?t.delete(e):t.add(e),ft([...t])}const ut=e=>{const t=Math.min(...e.map(a=>a.x)),n=Math.min(...e.map(a=>a.y));return{x:t,y:n,w:Math.max(...e.map(a=>a.x+a.w))-t,h:Math.max(...e.map(a=>a.y+a.h))-n}};async function J(e){for(const[t,n]of Object.entries(e)){const a=c.frames.find(i=>i.id===t);a&&(Object.assign(a,n),D(a))}he(),Ee(),await Promise.all(Object.entries(e).map(([t,n])=>p("PATCH",`/__loop/api/frames/${t}`,n)))}const Qt={KeyA:"left",KeyD:"right",KeyW:"top",KeyS:"bottom",KeyH:"hcenter",KeyV:"vcenter"};function en(e){const t=[...E].map(s=>c.frames.find(r=>r.id===s)).filter(Boolean);if(t.length<2)return;const n=ut(t),a={},i={};for(const s of t)a[s.id]={x:s.x,y:s.y};if(e==="hdist"||e==="vdist")return Jn(e,t,n,a);S=null,Fe();for(const s of t){const r={...a[s.id]};e==="left"?r.x=n.x:e==="right"?r.x=n.x+n.w-s.w:e==="hcenter"?r.x=Math.round(n.x+n.w/2-s.w/2):e==="top"?r.y=n.y:e==="bottom"?r.y=n.y+n.h-s.h:e==="vcenter"&&(r.y=Math.round(n.y+n.h/2-s.h/2)),i[s.id]=r}J(i);const o={left:"Align left",right:"Align right",hcenter:"Align centres",top:"Align top",bottom:"Align bottom",vcenter:"Align middles",hdist:"Distribute horizontally",vdist:"Distribute vertically"}[e];O(o,()=>J(a),()=>J(i))}async function Zn(e){const t=e.map(je).filter(Boolean);await Promise.all(e.map(a=>p("DELETE",`/__loop/api/frames/${a}`)));const n={frames:t.flatMap(a=>a.frames),comments:t.flatMap(a=>a.comments)};O(`Delete ${e.length} frames`,()=>pe(n),()=>Promise.all(e.map(a=>p("DELETE",`/__loop/api/frames/${a}`)))),E.clear(),Ke(),v(`Deleted ${e.length} frames \u2014 \u2318Z to undo`,2600)}let S=null;function Jn(e,t,n,a){const i=e==="hdist"?"x":"y",o=e==="hdist"?"w":"h",s=[...t].sort((f,m)=>f[i]-m[i]);let r=((i==="x"?n.w:n.h)-s.reduce((f,m)=>f+m[o],0))/Math.max(1,s.length-1);r<40&&(r=80),S={k:i,size:o,ids:s.map(f=>f.id),start:s[0][i],before:a,after:null,gap:Math.round(r)};const l=S;le(l.gap,!0),O(e==="hdist"?"Distribute horizontally":"Distribute vertically",()=>J(l.before),()=>J(l.after)),Fe()}function Qn(e){const t=S,n={};let a=t.start;for(const i of t.ids){const o=c.frames.find(s=>s.id===i);o&&(n[i]={...t.before[i],[t.k]:Math.round(a)},a+=o[t.size]+e)}return n}function le(e,t){if(!S)return;e=Math.max(0,Math.round(e)),S.gap=e;const n=Qn(e);if(t)S.after=n,J(n);else{for(const[s,r]of Object.entries(n)){const l=c.frames.find(f=>f.id===s);l&&(Object.assign(l,r),D(l))}he(),Ee()}const a=d("#align-bar"),i=a.querySelector(".ab-range"),o=a.querySelector(".ab-val");i&&(i.max=Math.max(800,e*2),i.value=e),o&&document.activeElement!==o&&(o.value=e)}function Fe(){const e=d("#align-bar").querySelector(".ab-spacing");if(!e||(e.hidden=!S,!S))return;e.querySelector(".ab-scrub").innerHTML=S.k==="x"?'<svg viewBox="0 0 24 24"><path d="M3 12h18M7 8l-4 4 4 4M17 8l4 4-4 4"/></svg>':'<svg viewBox="0 0 24 24"><path d="M12 3v18M8 7l4-4 4 4M8 17l4 4 4-4"/></svg>';const t=e.querySelector(".ab-range"),n=e.querySelector(".ab-val");t.max=Math.max(800,S.gap*2),t.value=S.gap,document.activeElement!==n&&(n.value=S.gap),he()}const ea={left:'<path d="M4 3v18"/><rect x="7" y="6" width="11" height="4" rx="1"/><rect x="7" y="14" width="7" height="4" rx="1"/>',hcenter:'<path d="M12 3v18"/><rect x="5" y="6" width="14" height="4" rx="1"/><rect x="8" y="14" width="8" height="4" rx="1"/>',right:'<path d="M20 3v18"/><rect x="6" y="6" width="11" height="4" rx="1"/><rect x="10" y="14" width="7" height="4" rx="1"/>',top:'<path d="M3 4h18"/><rect x="6" y="7" width="4" height="11" rx="1"/><rect x="14" y="7" width="4" height="7" rx="1"/>',vcenter:'<path d="M3 12h18"/><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="8" width="4" height="8" rx="1"/>',bottom:'<path d="M3 20h18"/><rect x="6" y="6" width="4" height="11" rx="1"/><rect x="14" y="10" width="4" height="7" rx="1"/>',hdist:'<path d="M4 4v16M20 4v16"/><rect x="9" y="7" width="6" height="10" rx="1"/>',vdist:'<path d="M4 4h16M4 20h16"/><rect x="7" y="9" width="10" height="6" rx="1"/>'},ta={left:"Align left (\u2325A)",hcenter:"Align horizontal centres (\u2325H)",right:"Align right (\u2325D)",top:"Align top (\u2325W)",vcenter:"Align vertical centres (\u2325V)",bottom:"Align bottom (\u2325S)",hdist:"Distribute horizontally",vdist:"Distribute vertically"};function Ke(){const e=d("#align-bar");if(!c||E.size<2||!b()||P){e.hidden=!0;return}if(!e.dataset.ready){e.innerHTML='<span class="ab-count"></span><span class="ab-sep"></span>'+["left","hcenter","right","|","top","vcenter","bottom","|","hdist","vdist"].map(i=>i==="|"?'<span class="ab-sep"></span>':`<button data-align="${i}" title="${ta[i]}"><svg viewBox="0 0 24 24">${ea[i]}</svg></button>`).join("")+'<span class="ab-spacing" hidden><span class="ab-sep"></span><span class="ab-scrub" title="Drag left or right to change the spacing"></span><input class="ab-range" type="range" min="0" max="800" step="1" title="Drag to spread them out or pull them closer"><label class="ab-num" title="Space between frames"><input class="ab-val" inputmode="numeric"><small>px</small></label></span>';const t=e.querySelector(".ab-range"),n=e.querySelector(".ab-val"),a=e.querySelector(".ab-scrub");t.addEventListener("input",()=>le(+t.value,!1)),t.addEventListener("change",()=>le(+t.value,!0)),n.addEventListener("keydown",i=>{i.stopPropagation(),i.key==="Enter"&&(le(+n.value||0,!0),n.blur()),i.key==="Escape"&&n.blur()}),n.addEventListener("change",()=>le(+n.value||0,!0)),a.addEventListener("pointerdown",i=>{if(!S)return;i.preventDefault(),a.setPointerCapture(i.pointerId);const o=i.clientX,s=S.gap,r=f=>le(s+(f.clientX-o)*2,!1),l=()=>{a.removeEventListener("pointermove",r),a.removeEventListener("pointerup",l),le(S.gap,!0)};a.addEventListener("pointermove",r),a.addEventListener("pointerup",l)}),e.addEventListener("pointerdown",i=>i.stopPropagation()),e.addEventListener("click",i=>{const o=i.target.closest("[data-align]");o&&en(o.dataset.align)}),e.dataset.ready="1"}e.querySelector(".ab-count").textContent=`${E.size} frames`,e.querySelectorAll('[data-align$="dist"]').forEach(t=>t.disabled=E.size<2),S&&(S.ids.length!==E.size||S.ids.some(t=>!E.has(t)))&&(S=null),Fe(),e.hidden=!1,he()}function he(){const e=d("#align-bar");if(e.hidden||E.size<2||!c)return;const t=[...E].map(s=>c.frames.find(r=>r.id===s)).filter(Boolean).map(fe);if(!t.length)return;const n=ut(t),a=u.x+(n.x+n.w/2)*u.z,i=u.y+n.y*u.z-34,o=e.offsetWidth||360;e.style.left=`${Math.max(12,Math.min(a-o/2,innerWidth-o-12))}px`,e.style.top=`${Math.max(60,i-46)}px`}const tn=6,We=e=>[e.x,e.x+e.w/2,e.x+e.w],Be=e=>[e.y,e.y+e.h/2,e.y+e.h];function nn(e){const t=e instanceof Set?e:new Set([e]);return c.frames.filter(n=>!t.has(n.id)).map(n=>fe(n))}function ge(e,t,n){let a=null;for(const i of e)for(const o of t){const s=o-i;Math.abs(s)<=n&&(!a||Math.abs(s)<Math.abs(a.d))&&(a={d:s,at:o})}return a}function an(e,t,n,a){const i=[],o=[e,...t],s=.5/u.z;if(n){const r=o.filter(l=>We(l).some(f=>Math.abs(f-n.at)<s));i.push({x1:n.at,y1:Math.min(...r.map(l=>l.y)),x2:n.at,y2:Math.max(...r.map(l=>l.y+l.h))})}if(a){const r=o.filter(l=>Be(l).some(f=>Math.abs(f-a.at)<s));i.push({x1:Math.min(...r.map(l=>l.x)),y1:a.at,x2:Math.max(...r.map(l=>l.x+l.w)),y2:a.at})}return i}function mt(e,t){const n=nn(t),a=tn/u.z,i=ge(We(e),n.flatMap(We),a),o=ge(Be(e),n.flatMap(Be),a),s={...e,x:e.x+(i?.d||0),y:e.y+(o?.d||0)};return{dx:i?.d||0,dy:o?.d||0,guides:an(s,n,i,o)}}function na(e,t,n="se"){const a=nn(t),i=tn/u.z,o=a.flatMap(We),s=a.flatMap(Be),r={...e};let l=null,f=null;return n.includes("e")?(l=ge([e.x+e.w],o,i),l&&(r.w=e.w+l.d)):n.includes("w")&&(l=ge([e.x],o,i),l&&(r.x=e.x+l.d,r.w=e.w-l.d)),n.includes("s")?(f=ge([e.y+e.h],s,i),f&&(r.h=e.h+f.d)):n.includes("n")&&(f=ge([e.y],s,i),f&&(r.y=e.y+f.d,r.h=e.h-f.d)),{rect:r,guides:an(r,a,l,f)}}function Q(e){const t=d("#guides");t.innerHTML=e.map(n=>`<line x1="${n.x1}" y1="${n.y1}" x2="${n.x2}" y2="${n.y2}"/>`).join("")}let Ye=null;async function Xe(e,t){try{const n=_.get(e),a=n&&!n.parked?Je(n):n?.resume,i=await p("POST",`/__loop/api/frames/${e}/duplicate`,{...t?{x:Math.round(t.x),y:Math.round(t.y)}:{},...a?{route:a}:{}});let o=null;O("Duplicate frame",async()=>{o=je(i.id)||o,await p("DELETE",`/__loop/api/frames/${i.id}`)},()=>pe(o||{frames:[i]})),c.frames.find(s=>s.id===i.id)?(W(i.id),st(i.id)):xe=i.id}catch(n){v(n.message)}}addEventListener("keydown",e=>{e.key==="Alt"&&x.classList.add("alt")}),addEventListener("keyup",e=>{e.key==="Alt"&&x.classList.remove("alt")}),addEventListener("blur",()=>x.classList.remove("alt"));const aa=[["Tools",[["V","Select & interact"],["H / hold Space","Pan"],["F","Frame (presets, or drag to draw)"],["C","Comment (click = pin, drag = area)"],["D","Deep select: pick exact elements (\u21E7 for more)"],["P","Draw"],["E","Erase marks"],["N","Describe a frame for the agent"]]],["Edit",[["\u2318Z","Undo"],["\u21E7\u2318Z / Ctrl Y","Redo"],["\u2325 drag title","Duplicate frame"],["\u2318C / \u2318V","Copy / paste frame"],["\u2318D","Duplicate in place"],["\u21E7 click / drag box","Select several frames"],["\u2318A","Select all frames"],["\u2325A \u2325H \u2325D","Align left / centre / right"],["\u2325W \u2325V \u2325S","Align top / middle / bottom"],["Delete","Delete selected frame"],["Double-click title","Rename frame"],["Esc","Close / deselect"]]],["View",[["Pinch / \u2318 scroll","Zoom"],["Scroll","Pan"],["\u21E71","Fit everything"],["\u23180","Zoom to 100%"],["+ / \u2212","Zoom in / out"]]],["Comments",[["\u21B5","Send to the agent"],["\u21E7\u21B5","New line"],["?","This list"]]]];function sn(e){let t=d("#shortcuts");t||(t=document.createElement("div"),t.id="shortcuts",t.className="modal",t.hidden=!0,t.innerHTML=`<div class="modal-card sc-card"><div class="modal-head"><span>Keyboard shortcuts</span><button class="icon-btn" data-close>\xD7</button></div>
40
+ <div class="sc-grid">${aa.map(([n,a])=>`<div><div class="sc-group">${n}</div>${a.map(([i,o])=>`<div class="sc-row"><span>${w(o)}</span><span class="sc-keys">${i.split(" / ").map(s=>`<kbd>${w(s)}</kbd>`).join(" ")}</span></div>`).join("")}</div>`).join("")}</div></div>`,document.body.appendChild(t),t.addEventListener("pointerdown",n=>{(n.target===t||n.target.closest("[data-close]"))&&(t.hidden=!0)})),t.hidden=e===void 0?!t.hidden:!e}addEventListener("keydown",e=>{Ht()||(e.key==="?"?(e.preventDefault(),sn()):e.key==="Escape"&&d("#shortcuts")&&!d("#shortcuts").hidden&&sn(!1))});function on(e){const t=c.frames.find(a=>a.id===M),n=e.value.trim();if(t&&n&&n!==t.name){const a=t.name,i=t.id;t.name=n,D(t),p("PATCH",`/__loop/api/frames/${i}`,{name:n}).catch(o=>v(o.message)),O("Rename frame",()=>te(i,{name:a}),()=>te(i,{name:n}))}}d("#frame-panel").addEventListener("change",e=>{e.target.id==="fp-name"&&on(e.target)}),d("#frame-panel").addEventListener("keydown",e=>{e.target.id==="fp-name"&&e.key==="Enter"&&(on(e.target),e.target.blur())},!0),d("#canvas-name").addEventListener("click",()=>{const e=d("#canvas-name");if(!b()||P||e.querySelector("input"))return;const t=document.createElement("input");t.className="canvas-rename",t.value=c.name,t.spellcheck=!1,e.replaceChildren(t),t.focus(),t.select();const n=a=>{const i=t.value.trim();if(e.textContent=a&&i?i:c.name,a&&i&&i!==c.name){const o=c.name;c.name=i,p("POST","/__loop/api/settings",{name:i}).catch(s=>v(s.message)),O("Rename canvas",()=>p("POST","/__loop/api/settings",{name:o}),()=>p("POST","/__loop/api/settings",{name:i}))}};t.addEventListener("keydown",a=>{a.stopPropagation(),a.key==="Enter"&&t.blur(),a.key==="Escape"&&(t.value=c.name,t.blur())}),t.addEventListener("blur",()=>n(!0))}),addEventListener("resize",()=>Z()),jn(),R("select"),X(),ln();
@@ -0,0 +1,161 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
6
+ <title>Loop</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/__loop/app/app.css">
10
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='3' y='3' width='26' height='26' rx='7' fill='%2317171A'/%3E%3Ccircle cx='16' cy='16' r='6.5' stroke='%23F4F3EF' stroke-width='2.6' fill='none'/%3E%3C/svg%3E">
11
+ </head>
12
+ <body>
13
+ <div id="viewport" data-tool="select">
14
+ <div id="world">
15
+ <div id="frames"></div>
16
+ <svg id="ink" width="1" height="1"></svg>
17
+ <div id="cursors"></div>
18
+ <div id="canvas-pins"></div>
19
+ <div id="draw-box" hidden><span></span></div>
20
+ <div id="dup-ghost" hidden><span></span></div>
21
+ <svg id="guides" width="1" height="1"></svg>
22
+ <div id="marquee" hidden></div>
23
+ </div>
24
+ <div id="hover-box" hidden></div>
25
+ <div id="empty" hidden>
26
+ <div class="empty-card">
27
+ <div class="empty-mark"></div>
28
+ <h1>An empty canvas</h1>
29
+ <p>Ask your agent to fill it:</p>
30
+ <code>Use the Loop skill to put this project's main screens on a canvas.</code>
31
+ <p class="muted">Or press <kbd>N</kbd> to describe a frame and let the agent build it.</p>
32
+ </div>
33
+ </div>
34
+ </div>
35
+
36
+ <header id="topbar">
37
+ <div class="brand">
38
+ <span class="logo" aria-hidden="true"><svg viewBox="0 0 32 32"><rect x="3" y="3" width="26" height="26" rx="7"/><circle cx="16" cy="16" r="6.5"/></svg></span>
39
+ <span class="wordmark">Loop</span>
40
+ <span class="sep">/</span>
41
+ <span id="canvas-name" class="canvas-name" title="Rename canvas"></span>
42
+ </div>
43
+ <div class="top-right">
44
+ <div id="presence" class="presence"></div>
45
+ <button id="agent-pill" class="pill" title="Agent connection">
46
+ <span class="agent-dot"></span><span id="agent-label">Agent</span>
47
+ </button>
48
+ <button id="share-btn" class="btn-primary">Share</button>
49
+ </div>
50
+ </header>
51
+
52
+ <div id="agent-menu" class="menu" hidden>
53
+ <div class="menu-title">Comments go to</div>
54
+ <button class="menu-item" data-mode="headless" data-runner="claude">
55
+ <span class="radio"></span>
56
+ <span><b>Claude Code, run by Loop</b><small>Each comment starts Claude Code in the background on that frame. Hands-free.</small></span>
57
+ </button>
58
+ <button class="menu-item" data-mode="headless" data-runner="codex">
59
+ <span class="radio"></span>
60
+ <span><b>Codex, run by Loop</b><small>Each comment starts Codex in the background on that frame. Hands-free.</small></span>
61
+ </button>
62
+ <button class="menu-item" data-mode="inbox">
63
+ <span class="radio"></span>
64
+ <span><b>The chat that launched Loop</b><small>Comments show up in that chat and it replies here, so you can carry on in either place. If the chat isn't listening, Loop runs them in the background.</small></span>
65
+ </button>
66
+ </div>
67
+
68
+ <div id="share-menu" class="menu" hidden>
69
+ <div class="menu-title">Invite people</div>
70
+ <div id="invite-signin">
71
+ <p class="menu-copy">Invite people by email. They sign in with Google and can open the canvas anytime, even when your Mac is off. Their comments wait for you before reaching the agent.</p>
72
+ <button id="cloud-google" class="btn-google"><svg viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 01-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/><path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.83.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 009 18z"/><path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 013.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 000 9c0 1.45.35 2.83.96 4.05l3.01-2.33z"/><path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 00.96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/></svg>Continue with Google</button>
73
+ </div>
74
+ <div id="invite-form" hidden>
75
+ <div class="copy-row"><input id="invite-emails" placeholder="name@gmail.com, another@gmail.com"><button id="invite-go" class="btn-dark">Invite</button></div>
76
+ <p id="invite-note" class="menu-note"></p>
77
+ <div id="members" class="members"></div>
78
+ <label class="field-label">Canvas link (only invited people can open it)</label>
79
+ <div class="copy-row"><input id="cloud-link" readonly placeholder="Created when you invite someone"><button data-copy="cloud-link">Copy</button></div>
80
+ <div class="signed-as"><span id="me-avatar" class="av-sm"></span><span id="me-email"></span><button id="cloud-signout" class="link-btn">Sign out</button></div>
81
+ </div>
82
+ <details class="lan-share">
83
+ <summary>Or share on this network</summary>
84
+ <p class="menu-copy">Anyone with this link can view, play with the frames, and comment while your Mac is on.</p>
85
+ <label class="field-label">Same network</label>
86
+ <div class="copy-row"><input id="share-lan" readonly><button data-copy="share-lan">Copy</button></div>
87
+ <label class="field-label">Public link</label>
88
+ <div id="share-public-row" class="copy-row" hidden><input id="share-public" readonly><button data-copy="share-public">Copy</button></div>
89
+ <button id="share-public-btn" class="btn-secondary">Create public link</button>
90
+ <p id="share-public-note" class="menu-note"></p>
91
+ <button id="share-rotate" class="link-btn">Reset links (revokes old ones)</button>
92
+ </details>
93
+ </div>
94
+
95
+ <nav id="toolbar" aria-label="Tools">
96
+ <button data-tool="select" title="Select & interact (V)"><svg viewBox="0 0 24 24"><path d="M5 3.5l13 7.2-5.6 1.6-2.6 5.6z"/></svg></button>
97
+ <button data-tool="hand" title="Pan (H, or hold Space)"><svg viewBox="0 0 24 24"><path d="M8 12V5.5a1.5 1.5 0 013 0V11m0-6.5V4a1.5 1.5 0 013 0v7m0-5.5a1.5 1.5 0 013 0V13c0 4-2.5 7-6.5 7S6 18 4.8 15.5L3.6 13a1.4 1.4 0 012.3-1.5L8 13.5"/></svg></button>
98
+ <button data-tool="pen" title="Draw (P)"><svg viewBox="0 0 24 24"><path d="M4 20l1.2-4.4L16.6 4.2a1.9 1.9 0 012.7 0l.5.5a1.9 1.9 0 010 2.7L8.4 18.8z"/><path d="M14.5 6.3l3.2 3.2"/></svg></button>
99
+ <button data-tool="erase" title="Erase marks (E)"><svg viewBox="0 0 24 24"><path d="M8.5 19.5L3.8 14.8a1.8 1.8 0 010-2.5l8.5-8.5a1.8 1.8 0 012.5 0l5.4 5.4a1.8 1.8 0 010 2.5l-8 8z"/><path d="M8.5 19.5H20M7.5 9.5l7 7"/></svg></button>
100
+ <button data-tool="deep" title="Deep select — pick exact elements (D)"><svg viewBox="0 0 24 24"><rect x="9.5" y="9.5" width="11" height="11" rx="1.5" stroke-dasharray="2.2 2"/><path d="M3.5 3.5l8.2 4.3-3.4 1.1-1.5 3.3z" fill="currentColor"/></svg></button>
101
+ <button data-tool="comment" title="Point & comment (C)"><svg viewBox="0 0 24 24"><path d="M4 18.5V6.5A2.5 2.5 0 016.5 4h11A2.5 2.5 0 0120 6.5v8a2.5 2.5 0 01-2.5 2.5H8.5z"/></svg></button>
102
+ <span class="tb-sep"></span>
103
+ <button id="new-frame-btn" data-tool="frame" class="tb-new" title="Frame (F)"><svg viewBox="0 0 24 24"><path d="M8 3v18M16 3v18M3 8h18M3 16h18"/></svg><span>Frame</span></button>
104
+ </nav>
105
+
106
+ <aside id="frame-panel" class="panel" hidden>
107
+ <div class="panel-head"><span id="fp-title">Frame</span><button class="icon-btn" id="fp-close" title="Close">×</button></div>
108
+ <div id="fp-body"></div>
109
+ </aside>
110
+
111
+ <div id="align-bar" hidden></div>
112
+
113
+ <div id="zoom">
114
+ <button id="zoom-out" title="Zoom out (−)">−</button>
115
+ <button id="zoom-level" title="Fit all (Shift+1)">100%</button>
116
+ <button id="zoom-in" title="Zoom in (+)">+</button>
117
+ </div>
118
+
119
+ <div id="composer" class="pop" hidden>
120
+ <div class="prompt-head"><span class="spark">✦</span><b id="prompt-title">What should this screen be?</b></div>
121
+ <div class="prompt-ideas" id="prompt-ideas"></div>
122
+ <div class="composer-target" id="composer-target" hidden></div>
123
+ <textarea id="composer-input" rows="1" placeholder="Comment, then ↵ to send to the agent"></textarea>
124
+ <div class="composer-foot">
125
+ <span id="composer-area" class="chip chip-area" hidden></span>
126
+ <span id="composer-ink" class="chip" hidden></span>
127
+ <span class="hint"><kbd>↵</kbd> send · <kbd>⇧↵</kbd> new line · <kbd>esc</kbd></span>
128
+ </div>
129
+ </div>
130
+
131
+ <div id="thread" class="pop" hidden></div>
132
+
133
+ <div id="ink-chip" hidden><button>Comment on marks</button></div>
134
+
135
+ <div id="new-frame" class="modal" hidden>
136
+ <div class="modal-card">
137
+ <div class="modal-head"><span>New frame</span><button class="icon-btn" data-close>×</button></div>
138
+ <textarea id="new-frame-input" rows="3" placeholder="Describe it — “a pricing page with three tiers and a monthly/yearly toggle”&#10;…or paste a folder path or http://localhost URL"></textarea>
139
+ <div class="modal-foot">
140
+ <div class="size-pick" id="size-pick">
141
+ <button data-size="1440x1024" class="on" title="1440×1024">Desktop</button>
142
+ <button data-size="834x1194" title="834×1194">Tablet</button>
143
+ <button data-size="393x852" title="393×852">Mobile</button>
144
+ </div>
145
+ <button id="new-frame-go" class="btn-primary">Create <kbd>↵</kbd></button>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <div id="name-modal" class="modal" hidden>
151
+ <div class="modal-card narrow">
152
+ <div class="modal-head"><span>What should we call you?</span></div>
153
+ <input id="name-input" placeholder="Your name" maxlength="32">
154
+ <div class="modal-foot"><span class="muted">Shown next to your cursor and comments.</span><button id="name-go" class="btn-primary">Join</button></div>
155
+ </div>
156
+ </div>
157
+
158
+ <div id="toast" hidden></div>
159
+ <script src="/__loop/app/app.js" type="module"></script>
160
+ </body>
161
+ </html>
package/server.mjs ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import Ie from"node:http";import v from"node:fs";import E from"node:fs/promises";import f from"node:path";import Q from"node:os";import Re from"node:crypto";import{spawn as ue}from"node:child_process";import Fe from"node:net";import{fileURLToPath as He}from"node:url";var Ne=!1,Te=f.dirname(He(import.meta.url)),ee=f.join(Te,"public"),H=f.join(Te,"cli.mjs"),je=nt(process.argv.slice(2)),I=f.resolve(je.dir||".loop"),U=f.dirname(I),ne=f.join(I,"canvas.json"),Ee=f.join(I,"server.json");v.mkdirSync(f.join(I,"frames"),{recursive:!0});var X=process.env.LOOP_CLAUDE_BIN||q("claude"),he=process.env.LOOP_CODEX_BIN||q("codex")||["/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex","/Applications/Codex.app/Contents/Resources/codex",f.join(Q.homedir(),"Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex")].find(e=>{try{return v.accessSync(e,v.constants.X_OK),!0}catch{return!1}}),M={claude:{label:"Claude Code",bin:X},codex:{label:"Codex",bin:he}};function We(){if(process.env.LOOP_CODEX_MODEL)return process.env.LOOP_CODEX_MODEL;let e=f.join(Q.homedir(),".codex"),t=null,o=[];try{t=/^\s*model\s*=\s*"([^"]+)"/m.exec(v.readFileSync(f.join(e,"config.toml"),"utf8"))?.[1]||null}catch{}try{let i=JSON.parse(v.readFileSync(f.join(e,"models_cache.json"),"utf8"));o=(Array.isArray(i)?i:i.models||[]).filter(r=>(r.visibility||"list")==="list").map(r=>r.slug||r.id).filter(Boolean)}catch{}return t&&(!o.length||o.includes(t))?t:o[0]||t||null}var C=(e=8)=>Re.randomBytes(e).toString("base64url").slice(0,e),c=Ue()||{version:1,name:f.basename(U),token:C(16),agent:{mode:"headless"},frames:[],strokes:[],comments:[]},y=new Map,O=null,_=null;function Ue(){try{return JSON.parse(v.readFileSync(ne,"utf8"))}catch{return null}}function Oe(){let e=ne+".tmp";v.writeFileSync(e,JSON.stringify(c,null,2)),v.renameSync(e,ne)}var oe=null;function Be(){clearTimeout(oe),oe=setTimeout(Oe,120)}for(let e of c.comments)e.status==="working"&&(e.status="error",e.reply="Interrupted \u2014 the server restarted.");function ie(e){let t={};for(let[p,d]of y)t[p]={commentId:d.commentId,step:d.step,startedAt:d.startedAt};let{token:o,cloud:i,...r}=c,m=we();return{...r,live:t,role:e,emailSharing:!!T,chatListening:Z(),agentBin:m,agentLabel:M[m].label,runners:Object.fromEntries(Object.entries(M).map(([p,d])=>[p,!!d.bin])),share:e==="owner"?le():void 0}}var R="/__loop";function Ge(e,t,o){let i=new URL(o),r={...e.headers,host:i.host},m=Ie.request({hostname:i.hostname,port:i.port||80,method:e.method,path:e.url,headers:r},p=>{let d={...p.headers};typeof d.location=="string"&&d.location.startsWith(i.origin)&&(d.location=d.location.slice(i.origin.length)||"/"),delete d["x-frame-options"],d["content-security-policy"]&&(d["content-security-policy"]=String(d["content-security-policy"]).replace(/frame-ancestors[^;]*;?/i,"")),t.writeHead(p.statusCode||502,d),p.pipe(t)});m.on("error",p=>{if(t.headersSent)return t.end();t.writeHead(502,{"content-type":"text/html; charset=utf-8"}),t.end(`<body style="font:14px system-ui;color:#8b8880;display:grid;place-items:center;height:100vh;margin:0">The dev server at ${z(o)} isn't responding (${z(p.code||p.message)}). Start it, or run <code>loop scan</code>.</body>`)}),e.pipe(m)}var re=new Set;function Ae(e,t){e.write(`data: ${JSON.stringify(t)}
3
+
4
+ `)}function A(e){for(let t of re)Ae(t.res,e.type==="state"?{type:"state",state:ie(t.role)}:e)}var ke=null,T=null;function k(e){Be(),clearTimeout(ke),ke=setTimeout(()=>A({type:"state"}),16),e||T?.schedulePush()}var V=new Map;function me(e){if(e.kind!=="folder"||V.has(e.id))return;let t=null;try{let o=v.watch(e.src,{recursive:!0},(i,r)=>{r&&(r.startsWith(".")||r.includes("node_modules"))||(clearTimeout(t),t=setTimeout(()=>{y.has(e.id)||(A({type:"frame-changed",frameId:e.id}),T?.frameChanged(e))},200))});V.set(e.id,o)}catch{}}function ze(e){V.get(e)?.close(),V.delete(e)}c.frames.forEach(me);function xe(e=1280,t=800,o){let i=o&&c.frames.find(p=>p.id===o);if(i){let p=(w,$)=>c.frames.some(n=>w<n.x+n.w+60&&w+e+60>n.x&&$<n.y+n.h+60&&$+t+60>n.y),d=i.x+i.w+120,x=i.y;for(let w=0;w<50&&p(d,x);w++)d+=160;return{x:d,y:x}}if(!c.frames.length)return{x:0,y:0};let r=Math.max(...c.frames.map(p=>p.x+p.w)),m=Math.min(...c.frames.map(p=>p.y));return{x:r+120,y:m}}function Je(e){return(e||"frame").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"").slice(0,40)||"frame"}function Ye(e){let t=e.split(/[?#]/)[0];return t==="/"||!t?"Home":t.split("/").filter(Boolean).map(o=>decodeURIComponent(o).replace(/\.html?$/,"").replace(/[-_]+/g," ").replace(/^\w/,i=>i.toUpperCase())).join(" / ").slice(0,60)}function L({src:e,route:t,name:o,w:i,h:r,x:m,y:p,near:d,dup:x}){if(i=+i||W.Desktop[0],r=+r||W.Desktop[1],t!=null){t="/"+String(t).replace(/^\/+/,"");let u=!x&&c.frames.find(S=>S.kind==="route"&&S.src===t&&S.w===i);if(u)return u;let h=m!=null&&p!=null?{x:+m,y:+p}:xe(i,r,d),g={id:C(6),name:o||t,kind:"route",src:t,entry:"",w:i,h:r,...h,createdAt:Date.now()};return c.frames.push(g),k(),g}let w=/^https?:\/\//.test(e),$=w?"url":"folder",n="index.html";w||(e=f.resolve(U,e),v.existsSync(e)&&v.statSync(e).isFile()&&(n=f.basename(e),e=f.dirname(e)));let a=m!=null&&p!=null?{x:+m,y:+p}:xe(i,r,d),s={id:C(6),name:o||(w?new URL(e).pathname:f.basename(e)),kind:$,src:e,entry:n,w:i,h:r,...a,createdAt:Date.now()};return c.frames.push(s),me(s),k(),s}var z=e=>String(e).replace(/[&<>"]/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[t]),Ke=e=>`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>${z(e)}</title><style>html,body{height:100%;margin:0}body{display:grid;place-items:center;font:14px/1.4 ui-sans-serif,system-ui;color:#9a978f;background:#fbfaf7}</style></head>
6
+ <body><div>${z(e)} \u2014 waiting for the agent\u2026</div></body></html>`,Xe=e=>`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <meta name="loop-empty" content="1"><title>${z(e)}</title><style>html,body{height:100%;margin:0;background:#fff}</style></head>
8
+ <body></body></html>`;function De(e){let t=Je(e),o=f.join(I,"frames",t),i=2;for(;v.existsSync(o);)o=f.join(I,"frames",`${t}-${i++}`);return o}function Ve(e){let t=new Set(c.frames.map(o=>o.name));if(!t.has(e))return e;for(let o=2;;o++)if(!t.has(`${e} ${o}`))return`${e} ${o}`}function be(e,t=!1){let o=De(e);return v.mkdirSync(o,{recursive:!0}),v.writeFileSync(f.join(o,"index.html"),t?Xe(e):Ke(e)),o}var Pe=new Set(["node_modules",".git",".loop",".next","dist","build"]);async function J(e,t,o={bytes:3e7}){await E.mkdir(t,{recursive:!0});for(let i of await E.readdir(e,{withFileTypes:!0})){if(i.name.startsWith(".")||Pe.has(i.name))continue;let r=f.join(e,i.name),m=f.join(t,i.name);if(i.isDirectory())await J(r,m,o);else if(i.isFile()){let{size:p}=await E.stat(r);if((o.bytes-=p)<0)throw new Error("frame too large to snapshot");await E.copyFile(r,m)}}}async function se(e,t=e,o=new Set){for(let i of await E.readdir(e,{withFileTypes:!0})){if(i.name.startsWith(".")||Pe.has(i.name))continue;let r=f.join(e,i.name);i.isDirectory()?await se(r,t,o):o.add(f.relative(t,r))}return o}async function fe(e,t){if(e.kind!=="folder")return null;let o=f.join(I,"history",t);try{return await J(e.src,o),o}catch{return null}}async function Qe(e,t){if(e.kind!=="folder")return null;let o=f.join(I,"history",t);try{return await E.rm(o,{recursive:!0,force:!0}),await J(e.src,o),o}catch{return null}}async function ve(e,t){let o=await se(e.src),i=await se(t);for(let r of o)i.has(r)||await E.rm(f.join(e.src,r),{force:!0});await J(t,e.src)}function Ze(e,t={}){let o=t.file_path||t.path||t.notebook_path,i=o?f.basename(o):"";switch(e){case"Read":return i?`Reading ${i}`:"Reading";case"Edit":case"MultiEdit":return`Editing ${i}`;case"Write":return`Writing ${i}`;case"Glob":case"Grep":return"Searching the code";case"Bash":return"Running a command";case"TodoWrite":return"Planning";default:return e}}var W={Mobile:[393,852],Tablet:[834,1194],Desktop:[1440,1024]},ae=e=>e.w<=480?"Mobile":e.w>=1100?"Desktop":e.h>=e.w*1.15?"Tablet":"Custom-size";function te(e){let t=c.frames.filter(o=>o.id!==e?.id);return["","How Loop canvases work (follow these conventions):",`- A frame is one screen at one device size. Standard sizes: Mobile ${W.Mobile.join("\xD7")}, Tablet ${W.Tablet.join("\xD7")}, Desktop ${W.Desktop.join("\xD7")}. When someone asks for a "mobile", "phone", "tablet", "iPad", "desktop" or "web" version, use the matching size.`,"- Change only the project (or this frame's folder). Never edit Loop itself: its canvas, toolbar, menus or .loop/ files are not yours to change.","- A frame IS the device screen: design edge to edge at that size. Never draw a phone or laptop mockup, notch, status bar, home indicator or bezel inside a frame, and never letterbox a small layout in the middle of a big frame.",'- "A mobile version of this" means the same screen, content and copy, re-laid-out for that width: single column, stacked sections, a mobile navigation pattern (top bar, tab bar or menu), tap targets of at least 44px, readable type. Keep the same colours, fonts and components.','- Name frames "<Screen> \u2014 <Device>", e.g. "Settings \u2014 Mobile". New frames go next to the frame they came from.',"- Keep frames consistent with each other: reuse the visual style already on the canvas.",...t.length?["- Other frames on this canvas: "+t.map(o=>`"${o.name}" (${ae(o)} ${o.w}\xD7${o.h}${o.kind==="route"?`, app route ${o.src}`:""})`).join("; ")+"."]:[]]}function pe(e,t){let o=[];if(t.kind==="create")return o.push("You are the design agent behind Loop, a canvas of live HTML frames.",`Create a new frame named "${e.name}". Its folder is the current working directory; write a complete, polished index.html there (self-contained HTML/CSS/JS \u2014 CDN imports are fine, no build step). The frame renders at ${e.w}\xD7${e.h}px.`,"","Brief:",'"""',t.text,'"""',"","Make it real and interactive where it makes sense. Only write files inside this folder.",...te(e),"","When done, reply with ONE short sentence describing what you made."),o.join(`
9
+ `);if(e.kind==="canvas"){o.push("You are the design agent behind Loop, a canvas of live frames for the project in the current working directory. The user left a general comment on the canvas itself (not on a single frame).","","Frames on the canvas:",...c.frames.map(x=>`- "${x.name}": ${x.kind==="folder"?`static files in ${x.src}`:x.kind==="route"?`the app's ${x.src} route (dev server ${c.proxy})`:x.src}`));let d=t.anchor?.near;return t.anchor?.area&&d?.length?o.push("",`They drew a box on the canvas over: ${d.map(x=>`"${x}"`).join(", ")}. Those frames are the focus; the box is approximate.`):d?.length&&o.push("",`The comment was placed near: ${d.map(x=>`"${x}"`).join(", ")}.`),o.push(...te(null)),o.push("","Comment:",'"""',t.text,'"""',"",`To add a frame: \`node ${H} new "<Screen> \u2014 <Device>" --w <w> --h <h> --dir ${I}\` prints a folder; write a self-contained index.html there.`,"Decide which frames or source files it concerns and make the change. If it is a question or a note that needs no change, just answer it. Reply with ONE short sentence on what you did."),o.join(`
10
+ `)}let i=t.parentId?c.comments.filter(d=>(d.id===t.parentId||d.parentId===t.parentId)&&d.id!==t.id&&d.createdAt<t.createdAt):[];if(o.push(`You are the design agent behind Loop, a canvas of live frames. The user is reviewing the frame "${e.name}" (${ae(e)}, ${e.w}\xD7${e.h}px) and left a comment on it.`),e.kind==="folder"?o.push(`The frame's files are in the current working directory (entry: ${e.entry}). Keep it a self-contained static page, and only modify files in this folder.`):e.kind==="route"?o.push(`The frame is the app's "${e.src}" route, rendered live by the project's dev server (${c.proxy}) from the current working directory. Find the source code for that route and the element the user points at, and change it there. The dev server hot-reloads, so don't start or restart it.`):o.push(`The frame is a live page at ${e.src}, served from the project in the current working directory. Find the source code responsible for what the user points at and change it there.`),e.kind==="folder")try{v.readFileSync(f.join(e.src,e.entry),"utf8").includes('name="loop-empty"')&&o.push(`This frame is EMPTY: a blank ${e.w}\xD7${e.h} ${ae(e)} screen. Design it from scratch in index.html based on the comment (a complete, polished screen at exactly that size), and remove the loop-empty meta tag.`)}catch{}if(i.length){o.push("","Earlier in this comment thread:");for(let d of i)o.push(d.kind==="note"?`- You (in the chat): ${d.text}`:`- User: ${d.text}${d.reply?`
11
+ You: ${d.reply}`:""}`)}o.push("","Comment:",'"""',t.text,'"""');let r=t.anchor;if(r?.scope==="area")o.push("",`They highlighted an AREA of the frame: x=${Math.round(r.x)}, y=${Math.round(r.y)}, ${Math.round(r.w)}\xD7${Math.round(r.h)}px (in the frame's ${e.w}\xD7${e.h} viewport). That area is the focus of the comment.`),r.elements?.length&&o.push("Inside it: "+r.elements.map(d=>`<${d.tag}> \`${d.selector}\`${d.text?` ("${d.text}")`:""}`).join("; ")+"."),r.container&&o.push(`The smallest element wrapping the whole area: <${r.container.tag}> \`${r.container.selector}\`.`),o.push("The area is approximate \u2014 a pointer to where to look, not a strict boundary. Focus your change there, but adjust things just outside it if the comment needs that, and keep the rest of the frame consistent. Use the whole frame for context.");else if(r?.scope==="elements"&&r.elements?.length){o.push("",`They deep-selected ${r.elements.length===1?"this exact element":`these ${r.elements.length} exact elements`} \u2014 the comment is about ${r.elements.length===1?"it":"them"}:`);for(let d of r.elements.slice(0,10))o.push(`- <${d.tag}> \`${d.selector}\`${d.text?` ("${d.text}")`:""}`),d.html&&o.push(" ```html"," "+d.html.replace(/\n/g,`
12
+ `)," ```")}else r?.scope==="frame"?o.push("","The comment is about the whole frame, not one element."):r&&r.selector?(o.push("",`They clicked on this element (their pin sits on it): <${r.tag}> \u2014 CSS selector \`${r.selector}\`. The comment may be about this element or the area around it.`),r.text&&o.push(`Its visible text: "${r.text}"`),r.html&&o.push("Its HTML (may be truncated):","```html",r.html,"```")):r&&o.push("",`They pointed at x=${Math.round(r.x)}, y=${Math.round(r.y)} in the frame's viewport.`);t.ink?.length&&o.push("",`They also drew marks on the frame over: ${t.ink.map(d=>`\`${d.selector}\`${d.text?` ("${d.text}")`:""}`).join(", ")}.`),t.annotation&&o.push(`An image of their drawing (red marks over the frame's outline and the elements they cover) is at ${t.annotation} \u2014 look at it.`),o.push("","If they ask for a NEW frame (another version, a mobile/desktop version, a variant, an alternative), do not change this frame. Create a new one next to it instead:");let[m,p]=W.Mobile;return e.kind==="route"?o.push(`- The same app screen at another size: \`node ${H} add-route ${e.src} --name "<Screen> \u2014 Mobile" --w ${m} --h ${p} --near ${e.id} --dir ${I}\` (use the preset size they asked for). This is the real app, so if it doesn't look right at that width, fix the app's responsive styles in the source rather than making a copy.`,`- A brand-new static design: \`node ${H} new "<Screen> \u2014 <Device>" --w <w> --h <h> --near ${e.id} --dir ${I}\` prints a folder; write a complete self-contained index.html there.`):o.push(`- Run \`node ${H} new "<Screen> \u2014 <Device>" --w <w> --h <h> --near ${e.id} --dir ${I}\` with the preset size (e.g. --w ${m} --h ${p} for mobile). It prints a folder path; write a complete, self-contained index.html there, based on this frame's files and adapted to that size.`),o.push(...te(e)),o.push("","Otherwise, make the change directly in the files. Preserve everything unrelated. When done, reply with ONE short sentence describing what you changed."),o.join(`
13
+ `)}var ce="__canvas";function B(e){return e!==ce?c.frames.find(t=>t.id===e):(c.canvasFrame||={id:ce,name:"Canvas",kind:"canvas",src:U,entry:"",w:0,h:0},c.canvasFrame)}var de=0,Z=()=>Date.now()-de<45e3;function N(e){if(y.has(e)||c.agent.mode==="inbox"&&Z())return;let t=c.comments.find(o=>o.frameId===e&&o.status==="queued");t&&Ce(t)}function ge(){for(let e of c.frames)N(e.id);N(ce)}function F(e,t){let o=y.get(e);o&&(o.step=t,A({type:"activity",frameId:e,commentId:o.commentId,step:t}),T?.liveChanged(y))}function D(e,t,o){let i=y.get(e.frameId);y.delete(e.frameId),e.status=i?.canceled?"canceled":t,e.reply=i?.canceled?"Stopped.":o,e.finishedAt=Date.now(),k(),A({type:"frame-changed",frameId:e.frameId,done:!0,commentId:e.id,status:e.status});let r=c.frames.find(m=>m.id===e.frameId);r&&T?.frameChanged(r),T?.liveChanged(y),setTimeout(()=>N(e.frameId),50)}function we(){let e=c.agent.runner;return e&&M[e]?.bin?e:X?"claude":he?"codex":"claude"}async function Ce(e,t=!1){let o=B(e.frameId);if(!o)return;if(we()==="codex")return Le(e,o,t);if(!X)return y.set(o.id,{commentId:e.id,step:"",startedAt:Date.now()}),D(e,"error",'No agent found to run this. Install Claude Code or Codex, or switch to "Your agent session" in the top-right menu.');y.set(o.id,{commentId:e.id,step:"Starting agent\u2026",startedAt:Date.now()}),e.startedAt=Date.now(),e.status="working",k(),!t&&!e.snapshot&&(e.snapshot=await fe(o,e.id));let i=pe(o,e),r=["-p","--output-format","stream-json","--verbose","--permission-mode","acceptEdits","--allowedTools",`Read,Edit,MultiEdit,Write,Glob,Grep,TodoWrite,Bash(node ${H} new:*),Bash(node ${H} add-route:*)`,"--add-dir",I],m=!!(o.sessionId&&!t&&e.kind!=="create");m&&r.push("--resume",o.sessionId),process.env.LOOP_MODEL&&r.push("--model",process.env.LOOP_MODEL);let p=o.kind==="folder"?o.src:U,d;try{d=ue(X,r,{cwd:p,env:process.env,stdio:["pipe","pipe","pipe"]})}catch(s){return D(e,"error",`Couldn't start the agent: ${s.message}`)}y.get(o.id).child=d,d.stdin.on("error",()=>{}),d.stdin.end(i);let x="",w=null,$=!1,n="",a="";d.stdout.on("data",s=>{x+=s;let u;for(;(u=x.indexOf(`
14
+ `))>=0;){let h=x.slice(0,u);if(x=x.slice(u+1),!h.trim())continue;let g;try{g=JSON.parse(h)}catch{continue}g.session_id&&(o.sessionId=g.session_id);let S=g.type==="system"&&g.model||g.type==="assistant"&&g.message?.model;if(S&&e.model!==S&&(e.model=S,c.agent.lastModel=S,k()),g.type==="assistant")for(let b of g.message?.content||[])b.type==="tool_use"?F(o.id,Ze(b.name,b.input)):b.type==="text"&&b.text.trim()&&(n=b.text.trim(),F(o.id,"Thinking\u2026"));else g.type==="result"&&(w=g.result??n,$=!!g.is_error,(g.permission_denials||[]).some(b=>/^(Edit|MultiEdit|Write)$/.test(b.tool_name))&&($=!0))}}),d.stderr.on("data",s=>{a=(a+s).slice(-2e3)}),d.on("error",s=>{a+=s.message}),d.on("close",s=>{let u=y.get(o.id),h=w==null||$&&(!String(w).trim()||/no conversation found/i.test(String(w)+a));if(!u?.canceled&&m&&h)return o.sessionId=null,y.delete(o.id),Ce(e,!0);u?.canceled&&w==null&&(o.sessionId=null),w!=null&&!$?D(e,"done",String(w).trim().split(`
15
+ `).filter(Boolean).slice(-2).join(" ").slice(0,400)||"Done."):D(e,"error",String(w||a.trim()||`Agent exited with code ${s}`).slice(-400))})}async function Le(e,t,o=!1){y.set(t.id,{commentId:e.id,step:"Starting Codex\u2026",startedAt:Date.now()}),e.startedAt=Date.now(),e.status="working",k(),!o&&!e.snapshot&&(e.snapshot=await fe(t,e.id));let i=pe(t,e),r=t.kind==="folder"?t.src:U,m=We(),p=["--json","-c","sandbox_workspace_write.network_access=true",...m?["-m",m]:[]],d=!!(t.codexSession&&!o&&e.kind!=="create"),x=d?["exec","resume",...p,t.codexSession,"-"]:["exec",...p,"-s","workspace-write","--skip-git-repo-check","-C",r,"--add-dir",I,"-"];m&&(e.model=m,c.agent.lastModel=m,k());let w;try{w=ue(he,x,{cwd:r,env:process.env,stdio:["pipe","pipe","pipe"]})}catch(h){return D(e,"error",`Couldn't start Codex: ${h.message}`)}y.get(t.id).child=w,w.stdin.on("error",()=>{}),w.stdin.end(i);let $="",n="",a=null,s=!1,u="";w.stdout.on("data",h=>{$+=h;let g;for(;(g=$.indexOf(`
16
+ `))>=0;){let S=$.slice(0,g);$=$.slice(g+1);let b;try{b=JSON.parse(S)}catch{continue}let P=b.item||{};b.type==="thread.started"&&b.thread_id&&e.kind!=="create"?t.codexSession=b.thread_id:P.type==="command_execution"&&b.type==="item.started"?F(t.id,/\bcat |sed -n|rg |ls\b|grep /.test(P.command||"")?"Reading the code":"Running a command"):P.type==="file_change"?F(t.id,`Editing ${f.basename(P.changes?.[0]?.path||"")}`.trim()):P.type==="agent_message"&&P.text?.trim()?(n=P.text.trim(),F(t.id,"Thinking\u2026")):P.type==="reasoning"?F(t.id,"Thinking\u2026"):b.type==="turn.completed"?s=!0:(b.type==="turn.failed"||b.type==="error")&&(a=b.error?.message||b.message||"Codex failed")}}),w.stderr.on("data",h=>{u=(u+h).slice(-2e3)}),w.on("error",h=>{u+=h.message}),w.on("close",h=>{if(!y.get(t.id)?.canceled&&!s&&d)return t.codexSession=null,y.delete(t.id),Le(e,t,!0);s&&!a?D(e,"done",n.split(`
17
+ `).filter(Boolean).slice(-2).join(" ").slice(0,400)||"Done."):D(e,"error",String(a||u.trim()||`Codex exited with code ${h}`).slice(-400))})}function qe(){for(let e of Object.values(Q.networkInterfaces()))for(let t of e||[])if(t.family==="IPv4"&&!t.internal)return t.address;return null}var G=0;function le(){let e=qe();return{lan:e?`http://${e}:${G}${R}/?t=${c.token}`:null,public:O?`${O}${R}/?t=${c.token}`:null,tunnelAvailable:!!q("cloudflared")}}function et(){return new Promise((e,t)=>{if(O)return e(O);let o=q("cloudflared");if(!o)return t(new Error("Public links need cloudflared. Install it with: brew install cloudflared"));_=ue(o,["tunnel","--no-autoupdate","--url",`http://localhost:${G}`],{stdio:["ignore","pipe","pipe"]});let i=r=>{let m=String(r).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);m&&!O&&(O=m[0],k(),e(O))};_.stdout.on("data",i),_.stderr.on("data",i),_.on("close",()=>{O=null,_=null,k()}),setTimeout(()=>t(new Error("The tunnel took too long to start")),2e4)})}var tt={".html":"text/html; charset=utf-8",".js":"text/javascript; charset=utf-8",".mjs":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".gif":"image/gif",".webp":"image/webp",".woff2":"font/woff2",".woff":"font/woff",".ttf":"font/ttf",".ico":"image/x-icon",".mp4":"video/mp4",".txt":"text/plain; charset=utf-8"};function _e(e,t){let o=e.socket.remoteAddress||"",i=o==="127.0.0.1"||o==="::1"||o==="::ffff:127.0.0.1",r=e.headers["x-forwarded-for"]||e.headers["cf-connecting-ip"];if(i&&!r)return"owner";let m=/(?:^|;\s*)loop_t=([^;]+)/.exec(e.headers.cookie||"")?.[1];return t.searchParams.get("t")===c.token||m===c.token?"guest":null}async function j(e){let t=[],o=0;for await(let r of e){if(o+=r.length,o>15e6)throw new Error("too large");t.push(r)}let i=Buffer.concat(t).toString("utf8");return i?JSON.parse(i):{}}function l(e,t,o){e.writeHead(t,{"content-type":"application/json","cache-control":"no-store"}),e.end(JSON.stringify(o))}async function Y(e,t,o={}){try{if((await E.stat(t)).isDirectory())return Y(e,f.join(t,"index.html"),o);e.writeHead(200,{"content-type":tt[f.extname(t).toLowerCase()]||"application/octet-stream","cache-control":"no-store",...o}),v.createReadStream(t).pipe(e)}catch{e.writeHead(404),e.end("Not found")}}var Se=(e,t)=>t===e||t.startsWith(e+f.sep),K=Ie.createServer(async(e,t)=>{let o=new URL(e.url,"http://x"),i;try{i=decodeURIComponent(o.pathname)}catch{return t.writeHead(400),t.end()}let r=_e(e,o);if(!r)return t.writeHead(403,{"content-type":"text/plain"}),t.end("This Loop canvas is private. Ask the host for a share link.");let m=r==="owner";if(i!==R&&!i.startsWith(R+"/"))return c.proxy?Ge(e,t,c.proxy):i==="/"?(t.writeHead(302,{location:`${R}/${o.search}`}),t.end()):(t.writeHead(404),t.end("Not found"));i=i.slice(R.length)||"/";try{if(i==="/"||i==="/index.html"){let n=o.searchParams.get("t")?{"set-cookie":`loop_t=${c.token}; Path=/; HttpOnly; SameSite=Lax`}:{};return Y(t,f.join(ee,"index.html"),n)}if(i.startsWith("/app/")){let n=f.join(ee,f.normalize(i.slice(5)));return Se(ee,n)?Y(t,n):(t.writeHead(400),t.end())}let p=/^\/f\/([^/]+)\/(.*)$/.exec(i);if(p){let n=c.frames.find(s=>s.id===p[1]);if(!n||n.kind!=="folder")return t.writeHead(404),t.end();let a=f.join(n.src,f.normalize(p[2]||n.entry));return Se(n.src,a)?Y(t,a):(t.writeHead(400),t.end())}if(i==="/api/ping")return l(t,200,{ok:!0,name:c.name,pid:process.pid});if(i==="/api/state")return l(t,200,ie(r));if(i==="/api/events"){t.writeHead(200,{"content-type":"text/event-stream","cache-control":"no-store",connection:"keep-alive"});let n={res:t,role:r};re.add(n),Ae(t,{type:"state",state:ie(r)});let a=setInterval(()=>t.write(`: ka
18
+
19
+ `),2e4);e.on("close",()=>{clearInterval(a),re.delete(n)});return}if(e.method==="POST"&&i==="/api/presence"){let n=await j(e);return A({type:"presence",id:String(n.id).slice(0,32),name:String(n.name||"Guest").slice(0,32),color:String(n.color||"#888").slice(0,16),x:+n.x,y:+n.y,leave:!!n.leave}),l(t,200,{ok:!0})}if(e.method==="POST"&&i==="/api/comments"){let n=await j(e),a=B(n.frameId);if(!a||!String(n.text||"").trim())return l(t,400,{error:"frame and text required"});let s=C(8),u=null;typeof n.annotationPng=="string"&&n.annotationPng.startsWith("data:image/png;base64,")&&(await E.mkdir(f.join(I,"annotations"),{recursive:!0}),u=f.join(I,"annotations",`${s}.png`),await E.writeFile(u,Buffer.from(n.annotationPng.split(",")[1],"base64")));let h=n.parentId?c.comments.find(S=>S.id===n.parentId):null,g={id:s,frameId:a.id,parentId:h?h.parentId||h.id:null,text:String(n.text).slice(0,4e3),anchor:n.anchor||h?.anchor||null,ink:Array.isArray(n.ink)?n.ink.slice(0,20):[],inkStrokes:Array.isArray(n.inkStrokes)?n.inkStrokes.map(String):[],annotation:u,author:{name:String(n.author?.name||(m?"You":"Guest")).slice(0,32),color:String(n.author?.color||"#2F5BFF").slice(0,16),guest:!m},status:m?"queued":"waiting",reply:null,createdAt:Date.now()};c.comments.push(g);for(let S of c.strokes)g.inkStrokes.includes(S.id)&&(S.commentId=g.id);return k(),N(a.id),l(t,200,g)}let d=/^\/api\/comments\/([^/]+)(?:\/(\w+))?$/.exec(i);if(d){let n=c.comments.find(s=>s.id===d[1]);if(!n)return l(t,404,{error:"no such comment"});let a=d[2];if(e.method==="DELETE"&&!a){if(!m&&!n.author?.guest)return l(t,403,{});let s=new Set([n.id,...c.comments.filter(h=>h.parentId===n.id).map(h=>h.id)]),u=y.get(n.frameId);return u&&s.has(u.commentId)&&(u.canceled=!0,u.child?u.child.kill("SIGTERM"):y.delete(n.frameId)),c.comments=c.comments.filter(h=>!s.has(h.id)),c.strokes=c.strokes.filter(h=>!s.has(h.commentId)),k(),l(t,200,{ok:!0})}if(!m)return l(t,403,{error:"host only"});if(a==="send")return["waiting","error","canceled"].includes(n.status)&&(n.status="queued",n.snapshot=null,n.undone=!1,n.redoSnap=null,k(),N(n.frameId)),l(t,200,n);if(a==="cancel"){let s=y.get(n.frameId);return s?.commentId===n.id?(s.canceled=!0,s.child?s.child.kill("SIGTERM"):D(n,"canceled","Stopped.")):n.status==="queued"&&(n.status="canceled",k()),l(t,200,{ok:!0})}if(a==="undo"){let s=B(n.frameId);if(!n.snapshot||!s||s.kind!=="folder")return l(t,400,{error:"nothing to undo"});if(y.has(s.id))return l(t,409,{error:"The agent is still working on this frame \u2014 wait for it to finish (or stop it) first."});let u=c.comments.filter(g=>g.id!==n.id&&g.frameId===n.frameId&&g.snapshot&&g.status==="done"&&!g.undone&&(g.startedAt||g.createdAt)>(n.startedAt||n.createdAt)),h=await j(e);if(u.length&&!h.force)return l(t,409,{error:"newer",newer:u.length});n.redoSnap=await Qe(s,`${n.id}-after`),await ve(s,n.snapshot),n.undone=!0;for(let g of u)g.undone=!0;return k(),A({type:"frame-changed",frameId:s.id}),l(t,200,{ok:!0,reverted:1+u.length})}if(a==="redo"){let s=B(n.frameId);return!n.redoSnap||!n.undone||!s?l(t,400,{error:"nothing to redo"}):y.has(s.id)?l(t,409,{error:"The agent is still working on this frame \u2014 wait for it to finish first."}):(await ve(s,n.redoSnap),n.undone=!1,k(),A({type:"frame-changed",frameId:s.id}),l(t,200,{ok:!0}))}if(a==="resolve")return n.resolved=!n.resolved,k(),l(t,200,n);if(a==="note"){let s=await j(e),u=String(s.text||"").trim().slice(0,2e3);if(!u)return l(t,400,{error:"text required"});let h={id:C(8),kind:"note",frameId:n.frameId,parentId:n.parentId||n.id,text:u,anchor:n.anchor,author:{name:M[we()].label,agent:!0,color:"#17171A"},model:s.model?String(s.model).slice(0,60):void 0,status:"done",createdAt:Date.now()};return c.comments.push(h),k(),A({type:"frame-changed",frameId:n.frameId}),l(t,200,h)}if(a==="step"){let s=await j(e);return F(n.frameId,String(s.text||"").slice(0,120)),l(t,200,{ok:!0})}if(a==="reply"){let s=await j(e);return y.has(n.frameId)||y.set(n.frameId,{commentId:n.id,step:"",startedAt:Date.now()}),s.model&&(n.model=String(s.model).slice(0,60),c.agent.lastModel=n.model),n.startedAt||(n.startedAt=y.get(n.frameId)?.startedAt),D(n,s.error?"error":"done",String(s.text||"Done.").slice(0,400)),l(t,200,n)}}if(!m)return l(t,403,{error:"host only"});if(i==="/api/inbox"){let n=Date.now()+(o.searchParams.get("wait")?25e3:0),a=Z(),s=!1;for(e.on("close",()=>{s=!0}),de=Date.now(),a||k(!0);;){if(s)return;de=Date.now();let u=c.comments.find(h=>h.status==="queued"&&!y.has(h.frameId));if(u){let h=B(u.frameId);return y.set(h.id,{commentId:u.id,step:"Your agent picked this up",startedAt:Date.now(),inbox:!0}),u.status="working",k(),u.snapshot=u.snapshot||await fe(h,u.id),l(t,200,{comment:{id:u.id,kind:u.kind||"comment",text:u.text,anchor:u.anchor,ink:u.ink,annotation:u.annotation},frame:{id:h.id,name:h.name,kind:h.kind,src:h.src,entry:h.entry,w:h.w,h:h.h},prompt:pe(h,u)})}if(Date.now()>n)return l(t,200,{comment:null});await new Promise(h=>setTimeout(h,400))}}if(e.method==="POST"&&i==="/api/frames"){let n=await j(e);if(n.prompt){let a=n.name||n.prompt.split(/[.\n]/)[0].slice(0,40),s=be(a),u=L({src:s,name:a,w:n.w,h:n.h,x:n.x,y:n.y});return c.comments.push({id:C(8),kind:"create",frameId:u.id,text:n.prompt,author:{name:"You",color:"#2F5BFF"},status:"queued",createdAt:Date.now()}),k(),N(u.id),l(t,200,{...u,dir:s})}if(n.new){let a=be(n.name||"Untitled",!!n.blank);return l(t,200,{...L({...n,src:a}),dir:a})}return n.route!=null?l(t,200,L(n)):n.src?!/^https?:\/\//.test(n.src)&&!v.existsSync(f.resolve(U,n.src))?l(t,400,{error:`No such file or folder: ${n.src}`}):l(t,200,L(n)):l(t,400,{error:"src, route, new or prompt required"})}let x=/^\/api\/frames\/([^/]+)\/duplicate$/.exec(i);if(x&&e.method==="POST"){let n=c.frames.find(b=>b.id===x[1]);if(!n)return l(t,404,{error:"no such frame"});let a=await j(e),s=n.kind==="route"&&typeof a.route=="string"&&a.route.startsWith("/")&&!a.route.startsWith("/__loop/")?a.route.slice(0,2e3):null,u=s&&s!==n.src,h=Ve(u?Ye(s):`${n.name.replace(/ copy( \d+)?$/,"")} copy`),g=a.x!=null&&a.y!=null?{x:a.x,y:a.y}:{near:n.id},S;if(n.kind==="folder"){let b=De(h);await J(n.src,b),S=L({src:f.join(b,n.entry),name:h,w:n.w,h:n.h,...g})}else n.kind==="route"?S=L({route:s||n.src,name:h,w:n.w,h:n.h,dup:!0,...g}):S=L({src:n.src,name:h,w:n.w,h:n.h,...g});return l(t,200,S)}let w=/^\/api\/frames\/([^/]+)$/.exec(i);if(w){let n=c.frames.find(a=>a.id===w[1]);if(!n)return l(t,404,{});if(e.method==="PATCH"){let a=await j(e);for(let s of["x","y","w","h"])a[s]!=null&&isFinite(a[s])&&(n[s]=Math.round(+a[s]));return a.name&&(n.name=String(a.name).slice(0,80)),k(),l(t,200,n)}if(e.method==="DELETE"){let a=y.get(n.id);return a&&(a.canceled=!0,a.child?.kill("SIGTERM"),y.delete(n.id)),c.frames=c.frames.filter(s=>s.id!==n.id),c.comments=c.comments.filter(s=>s.frameId!==n.id),ze(n.id),k(),l(t,200,{ok:!0})}}if(e.method==="POST"&&i==="/api/restore"){let n=await j(e);for(let a of n.frames||[])a?.id&&!c.frames.find(s=>s.id===a.id)&&(c.frames.push(a),me(a));for(let a of n.strokes||[])a?.id&&!c.strokes.find(s=>s.id===a.id)&&c.strokes.push(a);for(let a of n.comments||[])a?.id&&!c.comments.find(s=>s.id===a.id)&&c.comments.push({...a,status:["working","queued"].includes(a.status)?"canceled":a.status});return k(),l(t,200,{ok:!0})}if(e.method==="POST"&&i==="/api/strokes"){let n=await j(e),a={id:C(8),points:(n.points||[]).slice(0,6e3).map(s=>Math.round(s*10)/10),color:String(n.color||"#E5484D").slice(0,16),width:+n.width||3,frameId:n.frameId||null,createdAt:Date.now()};return c.strokes.push(a),k(),l(t,200,a)}let $=/^\/api\/strokes\/([^/]+)$/.exec(i);if($&&e.method==="DELETE")return c.strokes=c.strokes.filter(n=>n.id!==$[1]),k(),l(t,200,{ok:!0});if(e.method==="POST"&&i==="/api/settings"){let n=await j(e);if((n.mode==="headless"||n.mode==="inbox")&&(c.agent.mode=n.mode),n.runner&&M[n.runner]){if(!M[n.runner].bin)return l(t,400,{error:`${M[n.runner].label} isn't installed on this computer`});c.agent.runner=n.runner}if(n.proxy!==void 0){if(n.proxy&&!/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(n.proxy))return l(t,400,{error:"proxy must be a local dev server origin, e.g. http://localhost:3000"});c.proxy=n.proxy||null}return n.name&&(c.name=String(n.name).slice(0,80)),k(),ge(),l(t,200,{ok:!0})}if(e.method==="POST"&&i==="/api/share/public")try{return await et(),l(t,200,le())}catch(n){return l(t,400,{error:n.message})}if(e.method==="POST"&&i==="/api/share/stop")return _?.kill(),O=null,k(),l(t,200,{ok:!0});if(e.method==="POST"&&i==="/api/share/rotate")return c.token=C(16),k(),l(t,200,le());if(i.startsWith("/api/cloud")&&!T)return l(t,404,{error:"Email sharing is not available in this version of Loop."});if(i==="/api/cloud"&&e.method==="GET")return l(t,200,await T.status());if(e.method==="POST"&&i==="/api/cloud/session"){let n=await j(e);return l(t,200,{user:await T.setSession(n)})}if(e.method==="POST"&&i==="/api/cloud/signout")return T.signOut(),l(t,200,{ok:!0});if(e.method==="POST"&&i==="/api/cloud/publish")return l(t,200,{link:await T.publish()});if(e.method==="POST"&&i==="/api/cloud/snapshot"){let n=await j(e),a=c.frames.find(u=>u.id===n.frameId);if(!a||a.kind!=="route"||typeof n.html!="string")return l(t,400,{error:"route frame and html required"});let s=await T.publishSnapshot(a,n.html);return T.schedulePush(200),l(t,200,s)}if(e.method==="POST"&&i==="/api/cloud/invite"){let n=await j(e);return l(t,200,await T.invite(n.emails||[],n.role))}if(e.method==="DELETE"&&i.startsWith("/api/cloud/members/"))return await T.removeMember(decodeURIComponent(i.slice(19))),l(t,200,{ok:!0});if(e.method==="POST"&&i==="/api/shutdown")return l(t,200,{ok:!0}),setTimeout(ye,50);l(t,404,{error:"not found"})}catch(p){l(t,500,{error:p.message})}});K.on("upgrade",(e,t,o)=>{let i=new URL(e.url,"http://x");if(!c.proxy||i.pathname.startsWith(R)||!_e(e,i))return t.destroy();let r=new URL(c.proxy),m=Fe.connect(+(r.port||80),r.hostname,()=>{let p=`${e.method} ${e.url} HTTP/1.1\r
20
+ `;for(let d=0;d<e.rawHeaders.length;d+=2){let x=e.rawHeaders[d],w=e.rawHeaders[d+1];x.toLowerCase()==="host"&&(w=r.host),p+=`${x}: ${w}\r
21
+ `}m.write(p+`\r
22
+ `),o?.length&&m.write(o),m.pipe(t),t.pipe(m)});m.on("error",()=>t.destroy()),t.on("error",()=>m.destroy())});Ne&&(T={createCloud:()=>null}.createCloud({state:c,changed:k,pump:N,broadcast:A,log:e=>console.log(e)}));var $e=!1;setInterval(()=>{let e=Z();if(e!==$e&&($e=e,k(!0)),c.agent.mode==="inbox"&&!e){for(let[t,o]of y)if(o.inbox){y.delete(t);let i=c.comments.find(r=>r.id===o.commentId);i&&i.status==="working"&&(i.status="queued")}ge()}},5e3);function Me(e,t=20){K.once("error",o=>{o.code==="EADDRINUSE"&&t>0?Me(e+1,t-1):(console.error(o),process.exit(1))}),K.listen(e,"0.0.0.0",()=>{G=K.address().port,v.writeFileSync(Ee,JSON.stringify({port:G,pid:process.pid})),console.log(`Loop \u25B8 http://localhost:${G} (${I})`),ge()})}function ye(){try{v.rmSync(Ee,{force:!0})}catch{}_?.kill();for(let e of y.values())e.child?.kill("SIGTERM");clearTimeout(oe),Oe(),process.exit(0)}process.on("SIGINT",ye);process.on("SIGTERM",ye);Me(+je.port||4848);function nt(e){let t={};for(let o=0;o<e.length;o++)if(e[o].startsWith("--")){let i=e[o].slice(2);t[i]=e[o+1]&&!e[o+1].startsWith("--")?e[++o]:!0}return t}function q(e){let t=[...(process.env.PATH||"").split(":"),f.join(Q.homedir(),".local/bin"),"/opt/homebrew/bin","/usr/local/bin"];for(let o of t){if(!o)continue;let i=f.join(o,e);try{return v.accessSync(i,v.constants.X_OK),i}catch{}}return null}export{W as PRESETS};
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: loop-canvas
3
+ description: Put design work on a Loop canvas, a local, shareable canvas of live frames (real HTML/CSS/JS or running pages) that people can play with, draw on, point at and comment on. Comments flow back to the agent and the frame shows it working. Use when the user mentions a "Loop canvas" or the loop CLI, says "put this on a canvas", "show me the screens side by side", "explore a few directions/variations", "make a canvas of …", or wants feedback on UI they can click through and share.
4
+ ---
5
+
6
+ # Loop
7
+
8
+ Loop is a local canvas of **live frames**. Each frame is either a folder of static files (`index.html` + assets) or a live URL (for example a route on the project's running dev server). The user can interact with frames, draw on them, and point at an element and comment. Each comment is delivered to an agent together with the exact element (selector, text, HTML) and any marks drawn on it. While the agent works, the frame's title shows a loading state. When the work is done, the frame reloads in place.
9
+
10
+ ## Rules (always)
11
+
12
+ - **Loop is the `loop` CLI and the canvas it serves at `http://localhost:<port>/__loop/`.** Only use it through `loop` commands. Never build your own canvas, viewer or gallery page, and never publish one as a hosted site. If `loop` can't be installed or won't run, stop and tell the user what failed.
13
+ - **Never change Loop itself.** Its toolbar, menus, tools, theme and `.loop/` files are not yours to edit or extend. Your output is the content of frames: the project's code, or a frame's own folder.
14
+ - **Never copy the app's code into frame folders.** For an existing app, frames are live routes of the running app (`loop scan`, `loop add-route`). Static frames (`loop new`) are for new designs, each one small and self-contained.
15
+
16
+ ## Setup (first use only)
17
+
18
+ The CLI is `loop` (Node 18+). Check with `command -v loop`. If it's missing, install it:
19
+
20
+ ```bash
21
+ npm install -g loop-canvas
22
+ ```
23
+
24
+ If a global install isn't allowed, run every command below as `npx -y loop-canvas <command>` instead. Comments are handled by the agent that launched Loop: Claude Code (`claude`) or Codex (`codex`), installed and signed in.
25
+
26
+ ## Make a canvas from a prompt
27
+
28
+ 1. **Decide what the frames are.**
29
+ - "Put this project (or its main screens) on a canvas": run `loop scan` from the project root. It detects the framework (Next.js, SvelteKit, Nuxt, Astro, Remix, React Router/Vite SPAs, Angular, or plain HTML), starts the project's dev server if it isn't running, and places **one live frame of the app** (its home screen, served through Loop so the user can point at exact elements). The user clicks around inside it, and duplicates it (⌘D or ⌥-drag the title) to keep another screen side by side; the copy opens on the screen they navigated to. Options: `--mobile` (phone, 393×852), `--landscape` (phone landscape, 852×393, good for games), `--w W --h H` (any size), `--both` (desktop + phone), `--url http://localhost:PORT` (use a specific running server).
30
+ Add a specific screen only when the user asks for it: `loop add-route /settings --name "Settings"`. Don't place every screen: each live frame runs the whole app. `loop scan --all` exists, but only use it when the user explicitly asks for every screen at once.
31
+ If the app can't run (missing env vars, backend down), say so and ask which few screens matter; then build those as small static frames (step 2), never a copy of the whole app per frame.
32
+ - "Explore / give me N directions for X": make N frames, one per distinct direction. Make them genuinely different (layout, hierarchy, tone), not one design with small tweaks.
33
+ 2. **Create a static frame:** `loop new "Pricing — editorial"` prints a folder path. Write a complete, self-contained `index.html` there: inline CSS/JS, CDN imports are fine, no build step, realistic content, working interactions. The canvas reloads the frame live as you write.
34
+ - Sizes: `--w 1280 --h 800` desktop (default) · `--w 390 --h 844` mobile · `--w 834 --h 1112` tablet.
35
+ - An existing folder or HTML file can be placed directly: `loop add ./prototypes/onboarding --name "Onboarding"`.
36
+ 3. **Open it:** `loop open` (`loop scan` already does this). It starts Loop's server for this project in the background (state lives in `./.loop/`), prints a URL like `http://localhost:4848/__loop/`, and returns. Open **that exact URL**:
37
+ - In Codex, open it in Codex's built-in browser view. If you can't open a browser, give the user the URL to open.
38
+ - In Claude Code, it opens in the user's browser automatically.
39
+ The canvas is only ever that localhost page. If you find yourself writing HTML for a canvas, toolbar or comment UI, or publishing a site, stop: that's not Loop, and comments left there reach no one.
40
+ 4. **Share it, if asked:** `loop share` prints a link anyone on the same Wi-Fi can open (also in the canvas under **Share**). `loop share --public` makes a public link through a tunnel (needs `brew install cloudflared`). Guests can click through the frames and comment; their comments wait for the host's approval before reaching the agent.
41
+
42
+ ## Keep the conversation in this chat (Claude Code)
43
+
44
+ If you can watch a long-running command and receive its output as messages (Claude Code: the Monitor tool, or a background command), do this right after opening the canvas:
45
+
46
+ 1. Start `loop watch` as a background watcher. Each comment the user leaves on the canvas arrives as a line: `COMMENT <id> on "<frame>": "<text>" | full instructions: <file> | when done: loop reply …`.
47
+ 2. For each COMMENT line: read the instructions file, make the change, then run `loop reply <id> "<one sentence>" --model <your model id>`. The frame reloads and your reply appears in the canvas thread.
48
+ 3. If the user continues the conversation here in the chat about that frame, make the change and post a short update to the same canvas thread with `loop note <id> "<what you changed>"`, so the canvas stays in sync. Use `loop thread <id>` to read what was said on the canvas.
49
+
50
+ Tell the user: "Comments you leave on the canvas will show up here, and I'll reply in both places." If the chat closes, Loop notices within about 45 seconds and runs comments in the background instead.
51
+
52
+ ## How comments reach an agent
53
+
54
+ Check the mode with `loop list` or the pill in the top right; switch it with `loop agent headless|inbox`.
55
+
56
+ Loop detects which agent launched it (Claude Code or Codex) and uses that same agent for the canvas's comments. If detection ever picks the wrong one, pass `--agent codex` or `--agent claude` to `loop open` / `loop scan`.
57
+
58
+ - **headless** (default): Loop runs `claude -p` itself in the frame's folder (or in the project root for URL frames) for every comment, streams progress to the frame title, and keeps a snapshot so the user can undo. Nothing else is needed.
59
+ - **inbox**: use this when the user wants *this* session to handle feedback. Steps:
60
+ 1. `loop inbox --wait` blocks up to 25 seconds and returns JSON `{comment, frame, prompt}`, or `{"comment": null}`. Repeat until you get a comment.
61
+ 2. Read `prompt`. It describes the request, the element the user pointed at, and the path to a PNG of any marks they drew. Make the change in `frame.src` (static frames), or find the source for that route (URL frames).
62
+ 3. While working, optionally show progress on the frame title: `loop step <commentId> "Editing the header"`.
63
+ 4. Finish with `loop reply <commentId> "One sentence on what changed" --model <your model id, e.g. claude-opus-5-5>` (add `--error` if you couldn't do it). The frame reloads, and the model shows under your reply.
64
+
65
+ ## Other commands
66
+ `loop list` (frames + status) · `loop create "<brief>"` (Loop's own agent builds a new frame) · `loop rm <id>` · `loop stop`
67
+
68
+ ## Notes
69
+ - Keep static frames self-contained in their folder. The comment agent is only allowed to edit inside that folder.
70
+ - Name frames by what they are ("Checkout — one page", "Checkout — stepped"), not "Frame 1".
71
+ - `.loop/canvas.json` holds the layout and comments; its folder includes a `.gitignore` for runtime files.