What shall we make?
+Describe a piece. Play it as it takes shape.
+ +Loading…
+Pieces publish to your public @handle as you work. Conversations and revisions are shared with authorized AC staff. Privacy
+ +diff --git a/easel/web/.gitignore b/easel/web/.gitignore
new file mode 100644
index 0000000000..dd78d933f3
--- /dev/null
+++ b/easel/web/.gitignore
@@ -0,0 +1,2 @@
+/test-results/
+/node_modules/
diff --git a/easel/web/README.md b/easel/web/README.md
new file mode 100644
index 0000000000..a734614bda
--- /dev/null
+++ b/easel/web/README.md
@@ -0,0 +1,61 @@
+# Aesel in a browser
+
+The `/try/` client uses `phone/session.mjs` and the shared AC engine. The first
+release makes JavaScript pieces using hosted AC braincells, publishes edits to
+the signed-in handle and embeds the public piece. It does not run a local CLI.
+
+```sh
+npm ci --prefix easel/web
+node easel/web/build.mjs
+node easel/web/serve.mjs
+# http://localhost:8771/try/
+```
+
+The build writes only to `system/public/aesel/try/` (or a supplied output
+directory). It bundles explicit browser shims and guides; it never serves the
+repository or exposes a development token endpoint. Its only build dependency
+is pinned esbuild. Build outputs are ignored by git.
+
+Auth0 uses the existing AC SPA client, `LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt`,
+at `hi.aesthetic.computer`, with the existing vendored Auth0 SPA SDK and PKCE.
+Append these settings without replacing other AC URLs:
+
+| Setting | Production | Local |
+| --- | --- | --- |
+| Allowed Callback URLs | `https://aesel.app/try/` | `http://localhost:8771/try/` |
+| Allowed Logout URLs | `https://aesel.app/try/` | `http://localhost:8771/try/` |
+| Allowed Web Origins | `https://aesel.app` | `http://localhost:8771` |
+| Allowed Origins (CORS) | `https://aesel.app` | `http://localhost:8771` |
+
+Auth0 owns token storage and refresh. Engine credentials stay in memory. Drafts,
+conversation context and the last-opened piece are stored separately per Auth0
+subject in this browser's localStorage. They survive sign-out but are not synced
+between computers. Download source before clearing browser data. Model output
+is rendered as text; preview code runs on the separate AC origin in a sandboxed
+iframe. The gate discloses public publishing and existing staff access.
+
+After committing and pushing to main, run `bash easel/web/deploy.sh`. It builds
+from that pushed commit on lith, then atomically switches the `/try/` symlink
+to the new release. It does not deploy other working-tree changes or restart
+the monolith. The existing aesel.app Caddy site serves the result. Prior releases
+remain in `/opt/ac/.aesel-web-releases/`; `try/build.json` identifies the revision.
+A regular monolith deployment does not rebuild this client; run this script
+again when its shared engine dependencies change.
+
+Validation:
+
+```sh
+node --test easel/web/storage.test.mjs easel/test/phone-session.test.mjs easel/test/phone-credits.test.mjs
+node easel/web/browser.test.mjs
+```
+
+The browser test needs the local server and installed Chrome. It uses the real
+bundle and agent loop with mocked authentication, inference and uploads. It
+checks saved conversations, two tool rounds, publication and preview wiring,
+account isolation, escaping, logout and mobile layout. It does not prove live
+Auth0, live inference or rendering on older browsers. Browser build targets:
+Chrome 90, Firefox 91 and Safari 15.6; actual oldest-device support still needs
+device testing, including the AC runtime inside the preview.
+
+Only pieces are supported initially. Native preview capture, local files, other
+media and vendor CLI subscriptions remain desktop capabilities.
diff --git a/easel/web/app.css b/easel/web/app.css
new file mode 100644
index 0000000000..452ea32db0
--- /dev/null
+++ b/easel/web/app.css
@@ -0,0 +1,2 @@
+@font-face{font-family:Unifont;src:url('/pixel.woff') format('woff');font-display:swap}
+:root{color-scheme:light;--paper:#f4eddb;--ink:#39274c;--purple:#63417d;--line:#d8c9bc;--pink:#f4c0d7}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:18px/1.5 Unifont,ui-monospace,monospace}button,textarea,select{font:inherit;color:inherit}button,a,select{-webkit-tap-highlight-color:transparent}button,select{border:1px solid var(--line);border-radius:7px;background:#fff9ee;padding:8px 13px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}a{color:var(--purple)}button:hover:not(:disabled){background:var(--pink)}button.primary{background:var(--purple);color:white;border-color:var(--purple)}button.primary:hover:not(:disabled){background:#4d2e65}:focus-visible{outline:3px solid #b35b97;outline-offset:3px}[hidden]{display:none!important}header{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:18px 26px;border-bottom:1px solid var(--line)}.brand{font-size:30px;text-decoration:none;line-height:1.2}.brand span{display:block;font-size:14px}.account{display:flex;align-items:center;gap:14px;font-size:15px}#gate{max-width:650px;margin:7vh auto;padding:24px;text-align:center}#gate img{object-fit:contain;image-rendering:pixelated}h1{font-size:42px;line-height:1.15;margin:20px 0}#gate-status{min-height:27px}#gate-status.error,.bad{color:#942748}.disclosure{font-size:15px;max-width:460px;margin:30px auto}.pieces{display:flex;align-items:center;gap:12px;padding:14px 26px}.pieces select{max-width:320px;min-width:120px}#status{margin-left:auto;font-size:15px}.workbench{display:grid;grid-template-columns:minmax(280px,1fr) minmax(300px,1fr);height:calc(100vh - 165px);min-height:420px;gap:24px;padding:0 26px 20px}.notebook{display:flex;flex-direction:column;min-height:0;border:1px solid var(--line);border-radius:10px;background:#fffaf0;overflow:hidden}#log{list-style:none;margin:0;padding:22px;overflow:auto;flex:1}#log li{margin:0 0 22px;white-space:pre-wrap;overflow-wrap:anywhere}#log li.you{border-left:3px solid #b35b97;padding-left:14px}#log li.note{font-size:15px;color:#705d77}#composer{border-top:1px solid var(--line);padding:14px}textarea{display:block;width:100%;resize:vertical;min-height:76px;max-height:180px;border:0;background:transparent;outline-offset:2px}.compose-actions{display:flex;justify-content:space-between;align-items:center;margin-top:10px}.compose-actions span{font-size:14px;color:#705d77}.preview-panel{display:flex;flex-direction:column;min-height:0}.preview-title{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:42px;margin-bottom:12px}.preview-title button{margin-left:auto;font-size:15px}.stage{position:relative;flex:1;min-height:260px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#e8ddcd;display:flex;align-items:center;justify-content:center}.stage iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:white}#publish-note{font-size:14px;margin:10px 0 0}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){header{padding:14px 16px}.account{gap:8px;flex-wrap:wrap;justify-content:flex-end}#credits{display:none}.pieces{padding:12px 16px;flex-wrap:wrap}.pieces label{display:none}.pieces select{flex:1;min-width:0;max-width:none}#status{width:100%;text-align:right}.workbench{display:flex;flex-direction:column;height:auto;min-height:0;padding:0 16px 16px;gap:16px}.notebook{height:52vh;min-height:310px}.preview-panel{min-height:330px}.stage{height:320px;flex:auto}h1{font-size:34px}#gate{margin:3vh auto}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
diff --git a/easel/web/app.mjs b/easel/web/app.mjs
new file mode 100644
index 0000000000..bdc303b30c
--- /dev/null
+++ b/easel/web/app.mjs
@@ -0,0 +1,148 @@
+import '../phone/shim/globals.js';
+import { createSession } from '../phone/session.mjs';
+import { accountStorage } from './storage.mjs';
+
+// A few shared engine helpers postdate the oldest browser build we target.
+if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', {value(index) { return this[index < 0 ? this.length + index : index]; }});
+if (!Array.prototype.findLast) Object.defineProperty(Array.prototype, 'findLast', {value(fn) { for(let i=this.length-1;i>=0;i--) if(fn(this[i],i,this)) return this[i]; }});
+if (!AbortSignal.timeout) AbortSignal.timeout = ms => { const c=new AbortController(); setTimeout(()=>c.abort(),ms); return c.signal; };
+if (!AbortSignal.prototype.throwIfAborted) AbortSignal.prototype.throwIfAborted = function() { if(this.aborted) throw new DOMException('Stopped','AbortError'); };
+
+const $ = id => document.getElementById(id);
+const callback = `${location.origin}/try/`;
+let auth, session, streaming, subject = '', source = '', submitting = false;
+
+function gate(message, error = false) {
+ $('gate').hidden = false;
+ $('workspace').hidden = true;
+ $('gate-status').textContent = message;
+ $('gate-status').className = error ? 'error' : '';
+}
+function line(kind, text) {
+ const node = document.createElement('li');
+ node.className = kind;
+ node.textContent = text;
+ $('log').append(node);
+ return node;
+}
+function follow(fn) {
+ const log = $('log'), near = log.scrollHeight - log.scrollTop - log.clientHeight < 100;
+ fn();
+ if (near) log.scrollTop = log.scrollHeight;
+}
+function busy(value) {
+ $('send').disabled = value;
+ $('stop').hidden = !value;
+ $('new').disabled = value;
+ $('history').disabled = value;
+ $('logout').disabled = value;
+}
+function event(e) {
+ if(e.type === 'you' || e.type === 'note' || e.type === 'bad') follow(()=>line(e.type,e.text));
+ if(e.type === 'busy') busy(e.busy);
+ if(e.type === 'status') $('status').textContent = e.text;
+ if(e.type === 'signedIn') { $('handle').textContent = e.handle ? `@${e.handle}` : ''; $('logout').hidden = false; }
+ if(e.type === 'credits') $('credits').textContent = Number.isFinite(e.total) ? `${e.total.toLocaleString()} braincells` : '';
+ if(e.type === 'piece' || e.type === 'source') { source = e.source || ''; $('download').disabled = !source; }
+ if(e.type === 'thread') {
+ $('log').replaceChildren(); streaming = null;
+ $('preview').hidden = true; $('preview').removeAttribute('src'); $('empty').hidden = false; $('piece-link').hidden = true;
+ for(const item of e.events || []) event(item);
+ }
+ if(e.type === 'preview') {
+ const url = new URL(e.url);
+ if(url.origin !== 'https://aesthetic.computer') return;
+ $('preview').src = url.href; $('preview').hidden = false; $('empty').hidden = true;
+ $('piece-link').href = url.href; $('piece-link').hidden = false;
+ }
+ if(e.type === 'history') {
+ const selected = session?.state.id;
+ $('history').replaceChildren(...e.items.map(item => { const o=document.createElement('option');o.value=item.id;o.textContent=item.title;o.selected=item.id===selected;return o; }));
+ }
+ if(e.type === 'bridge') {
+ const p=e.params || {};
+ if(e.method === 'turn/started') streaming = null;
+ if(e.method === 'turn/progress') $('status').textContent = p.phase || 'Working…';
+ if(e.method === 'item/agentMessage/delta') follow(()=>{ if(!streaming) streaming=line('answer',''); streaming.append(document.createTextNode(p.delta || '')); });
+ if(e.method === 'item/completed' && p.item?.type === 'agentMessage') {
+ if(!streaming) follow(()=>line('answer',p.item.text || ''));
+ streaming=null;
+ }
+ if(e.method === 'turn/completed') {
+ streaming=null;
+ if(p.turn?.error?.message) line('bad',p.turn.error.message);
+ $('status').textContent=p.turn?.error ? 'Could not finish' : p.turn?.status === 'interrupted' ? 'Stopped' : 'Ready';
+ }
+ }
+}
+
+async function enter() {
+ const user=await auth.getUser();
+ if(!user?.sub) throw new Error('Sign in again to open your pieces.');
+ subject=user.sub;
+ const token=await auth.getTokenSilently();
+ session=createSession({storage:accountStorage(localStorage,user.sub),emit:event});
+ await session.adoptToken(token);
+ if(!session.state.handle) {
+ gate('Claim an @handle at aesthetic.computer/handle, then return here.');
+ const link=document.createElement('a');link.href='https://aesthetic.computer/handle';link.textContent='Choose your handle';
+ $('gate-status').append(document.createElement('br'),link);
+ return;
+ }
+ await session.begin();
+ await session.open();
+ $('gate').hidden=true;$('workspace').hidden=false;
+ $('input').focus({preventScroll:true});
+}
+
+async function boot() {
+ if(!window.auth0 || !crypto.subtle) throw new Error('This browser could not load secure sign-in. Try a current Firefox or Chrome.');
+ auth=new window.auth0.Auth0Client({
+ domain:'hi.aesthetic.computer',clientId:'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt',
+ cacheLocation:'localstorage',useRefreshTokens:true,useRefreshTokensFallback:true,
+ httpTimeoutInSeconds:30,authorizationParams:{redirect_uri:callback},
+ });
+ const query=new URLSearchParams(location.search);
+ if(query.has('state') && (query.has('code') || query.has('error'))) {
+ try { await auth.handleRedirectCallback(); }
+ finally { history.replaceState({},'',callback); }
+ } else await auth.checkSession();
+ $('login').disabled=false;
+ if(await auth.isAuthenticated()) await enter();
+ else gate('');
+}
+$('login').onclick=()=>{
+ $('login').disabled=true;
+ auth.loginWithRedirect({authorizationParams:{redirect_uri:callback}}).catch(error=>{gate(error.message,true);$('login').disabled=false;});
+};
+$('logout').onclick=async()=>{
+ if(session?.state.busy || session?.state.publishing) { line('note','Wait for this turn and upload to finish before signing out.');return; }
+ session?.signOut();
+ await auth.logout({logoutParams:{returnTo:callback}});
+};
+$('composer').onsubmit=async e=>{
+ e.preventDefault();
+ const text=$('input').value.trim();
+ if(!text || submitting || session?.state.busy) return;
+ submitting=true;busy(true);
+ try {
+ // Renew via the SDK before each turn instead of persisting engine tokens.
+ const token=await auth.getTokenSilently();
+ if((await auth.getUser())?.sub !== subject) throw new Error('Your signed-in account changed. Reload to open its pieces.');
+ session.state.token=token;
+ $('input').value='';
+ await session.ask(text);
+ if(session.state.publishing) await session.state.publishing;
+ } catch(error) {line('bad',error.message);}
+ finally {submitting=false;busy(false);}
+};
+$('input').onkeydown=e=>{if(e.key==='Enter' && !e.shiftKey && !e.isComposing && matchMedia('(hover: hover)').matches){e.preventDefault();$('send').click();}};
+$('stop').onclick=()=>session?.stop();
+$('new').onclick=async()=>{try{await session.newPiece();$('input').focus();}catch(error){line('bad',error.message);}};
+$('history').onchange=async()=>{try{await session.resumeSession($('history').value);}catch(error){line('bad',error.message);}};
+$('download').onclick=()=>{
+ const url=URL.createObjectURL(new Blob([source],{type:'text/javascript'}));
+ const a=document.createElement('a');a.href=url;a.download=`${session.state.slug}.mjs`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
+};
+window.addEventListener('pagehide',()=>session?.saveCurrent());
+void boot().catch(error=>{gate(error.message,true);$('login').disabled=!auth;});
diff --git a/easel/web/browser.test.mjs b/easel/web/browser.test.mjs
new file mode 100644
index 0000000000..f7817019ac
--- /dev/null
+++ b/easel/web/browser.test.mjs
@@ -0,0 +1,84 @@
+// Focused integration: real browser bundle/agent loop, mocked account and network.
+// Nothing is sent to inference, storage or publishing services.
+import { chromium } from 'playwright';
+import assert from 'node:assert/strict';
+import { mkdir } from 'node:fs/promises';
+const browser=await chromium.launch({channel:'chrome',headless:true});
+const context=await browser.newContext();
+const page=await context.newPage();
+const errors=[];page.on('pageerror',error=>errors.push(error.message));
+let rounds=0,uploaded='',requests=[];
+const source='export function paint({ wipe, ink, circle }) { wipe("purple"); ink("pink").circle(100, 100, 40).draw(); }\n';
+const json=(route,data)=>route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
+await context.route('**/*',async route=>{
+ const url=new URL(route.request().url());
+ if(url.pathname==='/try/auth0.js') return route.fulfill({contentType:'text/javascript',body:`window.auth0={Auth0Client:class {
+ async checkSession(){} async isAuthenticated(){return !localStorage.getItem('signedOut');}
+ async getUser(){return {sub:localStorage.getItem('testAccount') || 'auth0|alice'};}
+ async getTokenSilently(){return 'test-only-token';}
+ async logout(){localStorage.setItem('signedOut','yes');location.reload();}
+ async loginWithRedirect(){localStorage.removeItem('signedOut');location.reload();}
+ }};`});
+ if(url.hostname==='localhost') return route.continue();
+ requests.push({url:url.href,method:route.request().method()});
+ if(url.pathname==='/userinfo')return json(route,{sub:'auth0|alice'});
+ if(url.pathname==='/handle')return json(route,{handle:'alice'});
+ if(url.pathname==='/api/easel-credits')return json(route,{remaining:500,purchased:0});
+ if(url.pathname==='/api/easel-inference'){
+ rounds++;
+ const body=route.request().postDataJSON();
+ assert(body.system && body.tools.some(t=>t.name==='write_piece'));
+ const events=rounds===1 ? [
+ {type:'content_block_start',index:0,content_block:{type:'tool_use',id:'write-1',name:'write_piece'}},
+ {type:'content_block_delta',index:0,delta:{type:'input_json_delta',partial_json:JSON.stringify({source})}},
+ {type:'content_block_stop',index:0},
+ {type:'message_delta',delta:{stop_reason:'tool_use'}},
+ ] : [
+ {type:'content_block_delta',index:0,delta:{type:'text_delta',text:'A pink circle on purple. '}},
+ {type:'message_delta',delta:{stop_reason:'end_turn'}},
+ ];
+ return route.fulfill({contentType:'text/event-stream',body:events.map(e=>'data: '+JSON.stringify(e)+'\n\n').join('')});
+ }
+ if(url.pathname.includes('/presigned-upload-url/'))return json(route,{uploadURL:'https://upload.test/piece'});
+ if(url.hostname==='upload.test'){uploaded=route.request().postData();return route.fulfill({body:''});}
+ if(url.pathname.endsWith('.mjs'))return route.fulfill({body:uploaded});
+ if(url.pathname.startsWith('/@alice/'))return route.fulfill({contentType:'text/html',body:'
Preview fixture
'}); + return json(route,{}); +}); +try { + await page.goto('http://localhost:8771/try/'); + await page.locator('#workspace').waitFor({state:'visible'}); + await page.locator('#input').fill('Make a pink circle.'); + await page.locator('#send').click(); + await page.locator('#log .answer').waitFor(); + await page.locator('#preview').waitFor({state:'visible'}); + await page.waitForFunction(()=>!document.getElementById('send').disabled); + assert.equal(uploaded,source);assert.equal(rounds,2); + assert.equal(await page.locator('#log img').count(),0); + const firstId=await page.locator('#history').inputValue(); + await page.reload(); + await page.locator('#workspace').waitFor({state:'visible'}); + assert.equal(await page.locator('#history').inputValue(),firstId); + assert.match(await page.locator('#log').innerText(),/Make a pink circle/); + await page.locator('#new').click(); + await page.waitForFunction(id=>document.getElementById('history').value!==id,firstId); + assert.equal(await page.locator('#log li').count(),0); + await page.locator('#history').selectOption(firstId); + await page.locator('#log .answer').waitFor(); + await mkdir(new URL('./test-results/',import.meta.url),{recursive:true}); + await page.screenshot({path:new URL('./test-results/desktop.png',import.meta.url).pathname}); + await page.setViewportSize({width:390,height:844}); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + await page.screenshot({path:new URL('./test-results/mobile.png',import.meta.url).pathname,fullPage:true}); + const storage=await page.evaluate(()=>JSON.stringify(localStorage)); + assert(!storage.includes('test-only-token')); + await page.locator('#logout').click(); + await page.locator('#gate').waitFor({state:'visible'}); + await page.evaluate(()=>{localStorage.setItem('testAccount','auth0|bob');localStorage.removeItem('signedOut');}); + await page.reload(); + await page.locator('#workspace').waitFor({state:'visible'}); + assert(!((await page.locator('#log').innerText()).includes('pink circle'))); + assert.equal(await page.locator('#history option').count(),1); + assert.deepEqual(errors,[]); + console.log('PASS: generation, upload, preview, reload, thread switching, text safety, account isolation, sign-out and mobile layout. Network mocked.'); +}finally {await browser.close();} diff --git a/easel/web/build.mjs b/easel/web/build.mjs new file mode 100644 index 0000000000..9d0ef56b6f --- /dev/null +++ b/easel/web/build.mjs @@ -0,0 +1,29 @@ +import { build } from 'esbuild'; +import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..'); +const out=resolve(root,process.argv[2] || 'system/public/aesel/try'); +await mkdir(out,{recursive:true}); +const shims={'node:events':'events','node:fs':'fs','node:path':'path','node:url':'url','node:crypto':'crypto','node:os':'absent','node:http':'absent','node:child_process':'absent','node:readline':'absent'}; +const guides={}; +for(const name of ['pieces.md','screen.md','hand.md','kidlisp.md','api.json']) guides[`/easel/context/${name}`]=await readFile(resolve(root,'easel/context',name),'utf8'); +const result=await build({ + entryPoints:[resolve(root,'easel/web/app.mjs')],outfile:resolve(out,'app.js'),bundle:true,format:'iife', + platform:'browser',target:['chrome90','firefox91','safari15.6'],minify:true,metafile:true, + define:{'import.meta.url':JSON.stringify('https://aesel.app/easel/src/browser.mjs')}, + banner:{js:`globalThis.__aeselGuides=${JSON.stringify(guides)};`}, + plugins:[{name:'phone-shims',setup(b){ + b.onResolve({filter:/^node:/},args=>{ + const name=shims[args.path];if(!name) throw new Error(`Unmapped browser dependency: ${args.path}`); + return {path:resolve(root,`easel/phone/shim/${name}.mjs`)}; + }); + b.onResolve({filter:/^\/easel\//},args=>({path:resolve(root,args.path.slice(1))})); + b.onResolve({filter:/^\.\/(revisions|preview-frame|jev-advisor)\.mjs$/},args=>({path:resolve(root,'easel/phone/shim',args.path)})); + }}], +}); +for(const name of ['index.html','app.css']) await copyFile(resolve(root,'easel/web',name),resolve(out,name)); +await copyFile(resolve(root,'system/public/aesthetic.computer/dep/auth0-spa-js.production.js'),resolve(out,'auth0.js')); +await writeFile(resolve(out,'build.json'),JSON.stringify({revision:process.env.AESEL_WEB_REVISION || null,inputs:Object.keys(result.metafile.inputs)},null,2)+'\n'); +console.log(`Aesel web → ${out}`); diff --git a/easel/web/deploy.sh b/easel/web/deploy.sh new file mode 100644 index 0000000000..ec0563250d --- /dev/null +++ b/easel/web/deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Deploy only the browser client, built on lith from a pushed main commit. +set -euo pipefail +cd "$(dirname "$0")/../.." +revision=$(git rev-parse "${1:-HEAD}^{commit}") +host=${LITH_HOST:-lith.aesthetic.computer} +key=${LITH_SSH_KEY:-aesthetic-computer-vault/home/.ssh/id_rsa} +git fetch origin main --quiet +git merge-base --is-ancestor "$revision" origin/main || { + echo 'Push this commit to main before deploying.' >&2; exit 1; +} +ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=10 "root@$host" bash -s -- "$revision" <<'REMOTE' +set -euo pipefail +revision=$1 +cd /opt/ac +git fetch origin main --quiet +git merge-base --is-ancestor "$revision" origin/main +release_root=/opt/ac/.aesel-web-releases +release="$release_root/$revision" +link=/opt/ac/system/public/aesel/try +if [ -e "$link" ] && [ ! -L "$link" ]; then + echo "$link already exists as a real directory; refusing to replace it." >&2 + exit 1 +fi +mkdir -p "$release_root" +work=$(mktemp -d "$release_root/.build.XXXXXX") +trap 'rm -rf "$work"' EXIT +if [ ! -f "$release/build.json" ]; then + mkdir "$work/source" + git archive "$revision" easel/web easel/phone easel/src easel/context \ + system/public/aesthetic.computer/dep/auth0-spa-js.production.js \ + | tar -x -C "$work/source" + cd "$work/source" + npm ci --prefix easel/web --include=dev --no-audit --no-fund + AESEL_WEB_REVISION="$revision" node easel/web/build.mjs "$work/output" + mv "$work/output" "$release" +fi +# GNU mv replaces the previous symlink atomically; retain earlier releases. +ln -s "$release" "$work/try" +mv -Tf "$work/try" "$link" +echo "Aesel web deployed: $revision → https://aesel.app/try/" +REMOTE diff --git a/easel/web/index.html b/easel/web/index.html new file mode 100644 index 0000000000..5b8bc99b31 --- /dev/null +++ b/easel/web/index.html @@ -0,0 +1,35 @@ + + + + + + +Describe a piece. Play it as it takes shape.
+ +Loading…
+Pieces publish to your public @handle as you work. Conversations and revisions are shared with authorized AC staff. Privacy
+ +Your piece appears here.
Edits publish to your public @handle. Drafts stay in this browser.