retake-demos 0.2.0

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.
@@ -0,0 +1,385 @@
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" />
6
+ <title>Retake</title>
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
8
+ <link rel="manifest" href="/manifest.webmanifest" />
9
+ <meta name="theme-color" content="#edefe8" />
10
+ <style>
11
+ /* The review surface. Retake is driven from an agent (or the workshop);
12
+ this page is where a person watches, judges, and collects the output.
13
+ It accepts no prose on purpose. */
14
+ :root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#767e74; --rule:#dbdfd3; --rule2:#c9cec0; --accent:#1a1c1e; --amber:#e8b04b; --ok:#2f5d3f; --bad:#a23b2c; }
15
+ * { box-sizing:border-box; }
16
+ html,body { margin:0; height:100%; background:var(--paper); color:var(--ink); font:15px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
17
+ button, input { font:inherit; color:inherit; }
18
+ .app { min-height:100%; display:flex; flex-direction:column; }
19
+ .top { display:flex; align-items:center; gap:10px; padding:14px 22px; }
20
+ .mark { width:22px; height:22px; border-radius:7px; background:var(--accent); position:relative; flex:none; }
21
+ .mark i { position:absolute; left:5.5px; height:2.5px; border-radius:2px; background:#eceee9; }
22
+ .mark i:nth-child(1){top:4px;width:4px;opacity:.55} .mark i:nth-child(2){top:8px;width:7px;opacity:.8} .mark i:nth-child(3){top:12px;width:11px;background:var(--amber)} .mark i:nth-child(4){top:16px;width:7px;opacity:.8}
23
+ .top h1 { font-size:15px; font-weight:600; margin:0; }
24
+ .controls { position:relative; display:flex; align-items:center; gap:8px; margin-left:8px; }
25
+ .chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:6px 13px; font-size:13px; color:var(--ink); cursor:pointer; }
26
+ .chip:hover { border-color:var(--ink); }
27
+ .pop { position:absolute; left:0; top:38px; z-index:40; background:#fff; border:1px solid var(--rule2); border-radius:14px; box-shadow:0 12px 34px rgba(24,28,24,.13); padding:8px; width:290px; display:none; }
28
+ .pop.show { display:block; }
29
+ .pop .ttl { font:11px ui-monospace, Menlo, monospace; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); padding:6px 10px 8px; margin:0; }
30
+ .row2 { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:10px; cursor:pointer; font-size:14px; min-height:38px; }
31
+ .row2:hover { background:#f1f3ee; }
32
+ .row2 .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
33
+ .row2 small { color:var(--muted); font-size:12px; }
34
+ .dot { width:7px; height:7px; border-radius:50%; background:var(--rule2); flex:none; }
35
+ .dot.ok { background:var(--ok);} .dot.bad { background:var(--bad);} .dot.live { background:var(--amber); animation:pulse 1.2s infinite; }
36
+ @keyframes pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
37
+ .quiet { padding:8px 10px; color:var(--muted); font-size:13px; }
38
+ .sp { flex:1; }
39
+ .wrap { width:100%; margin:0; padding:6px 22px 40px; display:grid; grid-template-columns:minmax(0,1fr); gap:26px; align-items:start; }
40
+ .col-main { max-width:720px; width:100%; margin:0 auto; min-width:0; }
41
+ /* wide screens: the library floats as its own card at the left, detached
42
+ from the window edge; the video centers in the space beside it. */
43
+ @media (min-width: 1000px) {
44
+ .wrap.two { grid-template-columns:280px minmax(0,1fr); gap:30px; }
45
+ .wrap.two .lib { position:sticky; top:14px; order:-1; }
46
+ }
47
+
48
+ /* live activity — what an agent is doing to this library right now */
49
+ .live { border:1px solid var(--rule); background:var(--card); border-radius:14px; padding:14px 18px; margin-bottom:18px; display:none; }
50
+ .live.show { display:block; }
51
+ .live .head { display:flex; align-items:center; gap:9px; font-size:13px; color:var(--muted); margin-bottom:6px; }
52
+ .live .head b { color:var(--ink); font-weight:600; }
53
+ .live .lines { font-size:14px; color:var(--muted); }
54
+ .live .lines div { padding:1.5px 0; }
55
+ .live .lines div:last-child { color:var(--ink); }
56
+ /* the video */
57
+ .pin { display:none; }
58
+ .pin.show { display:block; }
59
+ .pin video { width:100%; height:auto; max-height:62vh; border-radius:12px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 10px 30px rgba(24,28,24,.10); }
60
+ .under { display:flex; align-items:center; gap:8px; margin-top:10px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
61
+ .under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:5px 12px; cursor:pointer; font-size:13px; }
62
+ .under a:hover, .under button:hover { border-color:var(--ink); }
63
+ /* A native select with its arrow removed leaves lopsided padding; make it
64
+ sit exactly like the pills beside it, text centred, same height. */
65
+ .under select { color:var(--ink); border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 12px; height:29px; line-height:27px; font-size:13px; font-family:inherit; text-align:center; text-align-last:center; cursor:pointer; -webkit-appearance:none; appearance:none; min-width:58px; }
66
+ .under a, .under button { height:29px; line-height:17px; box-sizing:border-box; }
67
+ .under select:hover { border-color:var(--ink); }
68
+ .stills { display:flex; gap:8px; margin-top:10px; overflow-x:auto; padding-bottom:4px; }
69
+ .stills img { height:64px; border-radius:7px; border:1px solid var(--rule2); box-shadow:0 2px 6px rgba(24,28,24,.06); cursor:pointer; flex:none; }
70
+ .stills img:hover { border-color:var(--ink); }
71
+ /* the library: one quiet card at the video's side */
72
+ .lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 10px; position:sticky; top:14px; line-height:1.4; }
73
+ .lib h2 { font:11px ui-monospace, Menlo, monospace; letter-spacing:.07em; text-transform:uppercase; color:var(--muted); font-weight:500; margin:2px 0 10px 12px; }
74
+ details.group { margin-bottom:0; }
75
+ details.group summary { list-style:none; cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 12px 12px 8px; border-radius:12px; font-size:15.5px; color:var(--muted); min-height:48px; }
76
+ details.group summary::-webkit-details-marker { display:none; }
77
+ details.group summary:hover { background:#eef1ea; color:var(--ink); }
78
+ details.group summary .tri { transition:transform .15s; font-size:12px; width:28px; height:28px; display:flex; align-items:center; justify-content:center; border-radius:8px; flex:none; }
79
+ details.group summary:hover .tri { background:#e6e9e1; }
80
+ details.group summary .fold { width:18px; height:14px; flex:none; position:relative; }
81
+ details.group summary .fold::before { content:""; position:absolute; inset:0; border-radius:3px; background:var(--amber); opacity:.9; }
82
+ details.group summary .fold::after { content:""; position:absolute; left:0; top:-3px; width:8px; height:5px; border-radius:2px 2px 0 0; background:var(--amber); opacity:.9; }
83
+ details.group[open] summary .tri { transform:rotate(90deg); }
84
+ details.group summary b { color:var(--ink); font-weight:600; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
85
+ details.group summary small { margin-left:auto; font-size:12.5px; }
86
+ details.group .demo { margin-left:14px; }
87
+ .demo { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:10px; cursor:pointer; min-height:40px; }
88
+ .demo:hover { background:#eef1ea; }
89
+ .demo.on { background:#fff; border:1px solid var(--rule); }
90
+ .demo .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:14.5px; }
91
+ .demo small { color:var(--muted); font-size:12px; flex:none; }
92
+ .demo .zap { flex:none; border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:14px; width:26px; height:26px; border-radius:7px; visibility:hidden; }
93
+ .demo .zap:hover { background:#f3e9e6; }
94
+ .demo:hover .zap { visibility:visible; }
95
+ .demo .zap:hover { color:var(--bad); }
96
+ .arch { margin-top:10px; border-top:1px solid var(--rule); padding-top:10px; }
97
+ .arch .row2 small { cursor:pointer; }
98
+ .arch .row2 small:hover { color:var(--ink); text-decoration:underline; }
99
+ .arch .clear { display:block; width:100%; text-align:left; border:0; background:transparent; color:var(--muted); font-size:13px; padding:10px 12px; cursor:pointer; border-radius:10px; }
100
+ .arch .clear:hover { background:#f3e9e6; color:var(--bad); }
101
+ .veil { position:fixed; inset:0; background:rgba(24,28,24,.32); display:flex; align-items:center; justify-content:center; z-index:60; }
102
+ .modal { background:#fff; border:1px solid var(--rule2); border-radius:16px; box-shadow:0 18px 50px rgba(24,28,24,.18); padding:22px 24px; width:min(92vw, 380px); }
103
+ .modal h3 { margin:0 0 8px; font-size:16px; }
104
+ .modal p { margin:0 0 18px; color:var(--muted); font-size:14px; line-height:1.55; }
105
+ .modal .row { display:flex; gap:8px; justify-content:flex-end; }
106
+ .modal .btn { border:1px solid var(--rule2); background:#fff; border-radius:10px; padding:8px 15px; cursor:pointer; font-size:14px; }
107
+ .modal .btn:hover { border-color:var(--ink); }
108
+ .modal .btn.danger { background:var(--bad); border-color:var(--bad); color:#fff; }
109
+ .modal .btn.danger:hover { filter:brightness(.92); }
110
+ .empty { text-align:center; color:var(--muted); padding:60px 20px; }
111
+ .empty .mark { margin:0 auto 14px; width:34px; height:34px; border-radius:10px; }
112
+ .empty .mark i { left:8px; height:3.5px; }
113
+ .empty .mark i:nth-child(1){top:7px;width:7px} .empty .mark i:nth-child(2){top:13px;width:11px} .empty .mark i:nth-child(3){top:19px;width:17px} .empty .mark i:nth-child(4){top:25px;width:11px}
114
+ .empty h2 { color:var(--ink); font-size:17px; margin:0 0 6px; }
115
+ .empty p { margin:0; font-size:14px; }
116
+ .empty code { background:var(--card); border:1px solid var(--rule); border-radius:6px; padding:2px 7px; font:13px ui-monospace, Menlo, monospace; }
117
+ </style>
118
+ </head>
119
+ <body>
120
+ <div class="app">
121
+ <div class="top">
122
+ <div class="mark"><i></i><i></i><i></i><i></i></div>
123
+ <h1>Retake</h1>
124
+ <span class="sp"></span>
125
+ <a class="chip" href="/guide" style="text-decoration:none">How it works</a>
126
+ <button class="chip" id="gremlinBtn" title="Something's off? Tell me.">Bug?</button>
127
+ </div>
128
+ <div class="wrap two">
129
+ <div class="col-main">
130
+ <div class="live" id="live">
131
+ <div class="head"><span class="dot live" id="liveDot"></span><b id="liveWho"></b><span id="liveWhat"></span></div>
132
+ <div class="lines" id="liveLines"></div>
133
+ </div>
134
+ <div class="pin" id="pin">
135
+ <video id="vid" controls playsinline></video>
136
+ <div class="under">
137
+ <span id="vidMeta"></span>
138
+ <span class="sp"></span>
139
+ <select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
140
+ <option value="0.75">0.75×</option><option value="0.85">0.85×</option><option value="1" selected>1×</option><option value="1.15">1.15×</option><option value="1.3">1.3×</option>
141
+ </select>
142
+ <a id="vidDl" download>Download</a>
143
+ <button id="vidFolder">Show in Finder</button>
144
+ <a id="vidEdit" href="/classic">Edit</a>
145
+ </div>
146
+ <div class="stills" id="stills"></div>
147
+ </div>
148
+ <div class="empty" id="empty" style="display:none">
149
+ <div class="mark"><i></i><i></i><i></i><i></i></div>
150
+ <h2>Nothing recorded yet</h2>
151
+ <p>Ask your agent for a demo — <code>record a demo of my app showing the sign-up flow</code> —<br>or build one by hand in the <a href="/classic">workshop</a>. It will appear here.<br><a href="/guide">How Retake works</a> — one page, two minutes.</p>
152
+ </div>
153
+ </div>
154
+ <aside class="lib" id="lib" style="display:none">
155
+ <h2 id="libTtl">Demos</h2>
156
+ <div id="demos"></div>
157
+ </aside>
158
+ </div>
159
+ </div>
160
+ <script>
161
+ const $ = (id) => document.getElementById(id);
162
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
163
+ let current = null, demos = [], settings = null;
164
+ async function api(u, o) { const r = await fetch(u, o); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || r.statusText); return j; }
165
+
166
+ const REPO = 'https://github.com/glebbogachev00/retake';
167
+ document.getElementById('gremlinBtn').onclick = async () => {
168
+ const veil = document.createElement('div');
169
+ veil.className = 'veil';
170
+ const hasDemo = !!current;
171
+ veil.innerHTML = `<div class="modal" role="dialog" aria-modal="true" style="width:min(92vw,440px)">
172
+ <h3>Something off?</h3>
173
+ <p>Say it however it broke. Retake fills in the boring half — version, demo, and what the last take actually did — you just write the sentence.</p>
174
+ <textarea id="gremText" rows="4" placeholder="It was supposed to… but instead…" style="width:100%;border:1px solid var(--rule2);border-radius:10px;padding:10px 12px;font:inherit;resize:vertical;margin-bottom:10px"></textarea>
175
+ <input id="gremContact" type="text" placeholder="Where can I find you? GitHub or X handle — optional" style="width:100%;border:1px solid var(--rule);border-radius:10px;padding:9px 12px;font-size:13.5px;margin-bottom:10px" />
176
+ ${hasDemo ? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;color:var(--muted);margin-bottom:14px"><input type="checkbox" id="gremAttach" checked> Attach the receipts for “${esc(current)}”</label>` : ''}
177
+ <div class="row">
178
+ <button class="btn" data-no>Never mind</button>
179
+ <button class="btn" data-copy>Copy report</button>
180
+ <button class="btn danger" style="background:var(--accent);border-color:var(--accent)" data-go>File it on GitHub</button>
181
+ </div>
182
+ <p style="margin:12px 0 0;font-size:12px">Goes to the public issue tracker — no email involved, mine or yours.</p>
183
+ </div>`;
184
+ const close = () => veil.remove();
185
+ veil.addEventListener('click', (e) => { if (e.target === veil) close(); });
186
+ veil.querySelector('[data-no]').onclick = close;
187
+ const build = async () => {
188
+ const text = veil.querySelector('#gremText').value.trim() || '(they typed nothing — apparently it was self-explanatory)';
189
+ const contact = veil.querySelector('#gremContact').value.trim();
190
+ let boring = `Retake ${settings?.version || 'dev'} · ${navigator.platform}`;
191
+ const attach = veil.querySelector('#gremAttach');
192
+ if (attach && attach.checked && current) {
193
+ try {
194
+ const d = await api('/api/take/' + current);
195
+ const t = d.take;
196
+ boring += `\ndemo: ${current} · ${t.ok ? 'all steps passed' : 'a step FAILED'} · ${(t.duration - t.trimBefore).toFixed(0)}s`;
197
+ const tail = (d.proof || '').split('\n').filter((l) => /\| \d+ \|/.test(l)).slice(-8).join('\n');
198
+ if (tail) boring += '\n\nlast steps of the take:\n' + tail;
199
+ } catch { /* no take, no receipts */ }
200
+ }
201
+ return `**What happened**\n\n${text}\n\n${contact ? `**Where to find them:** ${contact}\n\n` : ''}---\n_The boring half, filled in by Retake:_\n\n\u0060\u0060\u0060\n${boring}\n\u0060\u0060\u0060\n`;
202
+ };
203
+ veil.querySelector('[data-copy]').onclick = async () => {
204
+ await navigator.clipboard.writeText(await build()).catch(() => {});
205
+ veil.querySelector('[data-copy]').textContent = 'Copied';
206
+ };
207
+ veil.querySelector('[data-go]').onclick = async () => {
208
+ const body = await build();
209
+ const title = 'bug: ' + (veil.querySelector('#gremText').value.trim().slice(0, 60) || 'something is off');
210
+ window.open(REPO + '/issues/new?labels=bug&template=bug.md&title=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(body).slice(0, 6500), '_blank');
211
+ close();
212
+ };
213
+ document.body.appendChild(veil);
214
+ setTimeout(() => veil.querySelector('#gremText').focus(), 40);
215
+ };
216
+
217
+ function ask(title, body, confirmLabel) {
218
+ return new Promise((done) => {
219
+ const veil = document.createElement('div');
220
+ veil.className = 'veil';
221
+ veil.innerHTML = `<div class="modal" role="dialog" aria-modal="true"><h3>${esc(title)}</h3><p>${esc(body)}</p><div class="row"><button class="btn" data-no>Cancel</button><button class="btn danger" data-yes>${esc(confirmLabel)}</button></div></div>`;
222
+ const end = (v) => { veil.remove(); document.removeEventListener('keydown', onKey); done(v); };
223
+ const onKey = (e) => { if (e.key === 'Escape') end(false); };
224
+ veil.addEventListener('click', (e) => { if (e.target === veil) end(false); });
225
+ veil.querySelector('[data-no]').onclick = () => end(false);
226
+ veil.querySelector('[data-yes]').onclick = () => end(true);
227
+ document.addEventListener('keydown', onKey);
228
+ document.body.appendChild(veil);
229
+ setTimeout(() => veil.querySelector('[data-no]').focus(), 40);
230
+ });
231
+ }
232
+
233
+ async function pin(name) {
234
+ let d; try { d = await api('/api/take/' + name); } catch { return false; }
235
+ if (!d.files.includes('demo.mp4')) return false;
236
+ current = name;
237
+ const q = '?v=' + d.stamp;
238
+ const playing = !$('vid').paused && !$('vid').ended;
239
+ if (!$('vid').src.includes('/out/' + name + '/') || !playing) {
240
+ $('vid').src = `/out/${name}/demo.mp4${q}`;
241
+ if (d.files.includes('thumbnail.png')) $('vid').poster = `/out/${name}/thumbnail.png${q}`;
242
+ }
243
+ const t = d.take;
244
+ const failed = (t.timeline || []).find((e) => e.ok === false);
245
+ $('vidMeta').textContent = `${name} · ${(t.duration - t.trimBefore).toFixed(0)}s · ${t.ok ? 'all steps passed' : failed ? `stopped at step ${failed.index}: ${failed.summary}` : 'some steps failed'}${d.facts ? ` · ${d.facts.width}×${d.facts.height}` : ''}`;
246
+ $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
247
+ $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
248
+ $('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
249
+ $('vidEdit').href = '/classic#' + encodeURIComponent(name);
250
+ const tempo = (demos.find((x) => x.name === name)?.settings?.tempo) || 1;
251
+ $('vidSpeed').value = String(tempo);
252
+ $('vid').playbackRate = 1; // the file already carries its tempo
253
+ const st = (d.files || []).filter((f) => f.startsWith('stills/')).sort();
254
+ $('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="${esc(f.slice(7, -4))}" loading="lazy">`).join('');
255
+ for (const img of $('stills').querySelectorAll('img')) img.onclick = () => window.open(img.src, '_blank');
256
+ $('pin').classList.add('show');
257
+ drawDemos();
258
+ return true;
259
+ }
260
+
261
+ async function drawDemos() {
262
+ try { demos = await api('/api/demos'); } catch { return; }
263
+ demos.sort((a, b) => (b.lastTake ? new Date(b.lastTake.finishedAt).getTime() : 0) - (a.lastTake ? new Date(a.lastTake.finishedAt).getTime() : 0));
264
+ const has = demos.length > 0;
265
+ $('empty').style.display = has ? 'none' : '';
266
+ $('lib').style.display = has ? '' : 'none';
267
+ document.body.classList.toggle('rail', has);
268
+ $('libTtl').textContent = `Demos · ${demos.length}`;
269
+ $('demos').innerHTML = '';
270
+ const groups = new Map();
271
+ for (const p of demos) { const g = p.group || 'unsorted'; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(p); }
272
+ const open = new Set(JSON.parse(localStorage.getItem('retake.openGroups') || 'null') || [demos.find((d) => d.name === current)?.group || (demos[0] && demos[0].group)]);
273
+ for (const [g, items] of groups) {
274
+ const det = document.createElement('details');
275
+ det.className = 'group';
276
+ const holdsCurrent = items.some((x) => x.name === current);
277
+ if (open.has(g) || groups.size === 1 || holdsCurrent) det.open = true;
278
+ det.innerHTML = `<summary><span class="tri">▶</span><span class="fold" aria-hidden="true"></span><b>${esc(g)}</b><small>${items.length}</small></summary>`;
279
+ det.addEventListener('toggle', () => {
280
+ const now = [...document.querySelectorAll('details.group[open] summary b')].map((b) => b.textContent);
281
+ localStorage.setItem('retake.openGroups', JSON.stringify(now));
282
+ });
283
+ for (const p of items) {
284
+ const el = document.createElement('div');
285
+ el.className = 'demo' + (p.name === current ? ' on' : '');
286
+ const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
287
+ const when = p.lastTake ? new Date(p.lastTake.finishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'no take yet';
288
+ el.innerHTML = `<span class="dot ${dot}"></span><span class="nm">${esc(p.name)}</span><small>${esc(when)}</small><button class="zap" title="Archive this demo">✕</button>`;
289
+ el.onclick = () => { if (p.lastTake) pin(p.name); else location.href = '/classic#' + encodeURIComponent(p.name); };
290
+ el.querySelector('.zap').onclick = async (ev) => {
291
+ ev.stopPropagation();
292
+ // Archive, not destroy — but moving someone's work still deserves a beat.
293
+ if (!(await ask('Archive ' + p.name + '?', 'Its videos are removed. The demo itself moves to the archive below and can be restored any time.', 'Archive'))) return;
294
+ await api('/api/demos/' + p.name, { method: 'DELETE' }).catch((e) => alert(e.message));
295
+ if (current === p.name) { current = null; $('pin').classList.remove('show'); }
296
+ await drawDemos();
297
+ };
298
+ det.appendChild(el);
299
+ }
300
+ $('demos').appendChild(det);
301
+ }
302
+ await drawArchive();
303
+ }
304
+
305
+ async function drawArchive() {
306
+ let items = [];
307
+ try { items = await api('/api/trash'); } catch { return; }
308
+ const old = document.querySelector('.arch'); if (old) old.remove();
309
+ if (!items.length) return;
310
+ const det = document.createElement('details');
311
+ det.className = 'group arch';
312
+ det.innerHTML = `<summary><span class="tri">▶</span><b>Archive</b><small>${items.length}</small></summary>`;
313
+ for (const t of items) {
314
+ const el = document.createElement('div');
315
+ el.className = 'row2';
316
+ el.innerHTML = `<span class="dot"></span><span class="nm" style="color:var(--muted)">${esc(t.name)}</span><small>restore</small>`;
317
+ el.querySelector('small').onclick = async (ev) => { ev.stopPropagation(); await api('/api/demos/' + t.name + '/restore', { method: 'POST' }).catch((e) => alert(e.message)); await drawDemos(); };
318
+ det.appendChild(el);
319
+ }
320
+ const clear = document.createElement('button');
321
+ clear.className = 'clear';
322
+ clear.textContent = 'Clear archive — gone for good';
323
+ clear.onclick = async () => {
324
+ if (!(await ask('Clear the archive?', `${items.length} archived demo${items.length === 1 ? '' : 's'} will be deleted permanently. There is no way back from this one.`, 'Delete forever'))) return;
325
+ await api('/api/trash/clear', { method: 'POST' });
326
+ await drawDemos();
327
+ };
328
+ det.appendChild(clear);
329
+ $('demos').appendChild(det);
330
+ }
331
+
332
+ async function newest() {
333
+ const withTake = demos.filter((x) => x.lastTake).sort((a, b) => new Date(b.lastTake.finishedAt) - new Date(a.lastTake.finishedAt));
334
+ for (const d of withTake) if (await pin(d.name)) return;
335
+ }
336
+
337
+ // ---- speed: hear it now, keep it on download ----------------------------------
338
+ $('vidSpeed').addEventListener('change', async () => {
339
+ if (!current) return;
340
+ const v = Number($('vidSpeed').value);
341
+ const was = (demos.find((x) => x.name === current)?.settings?.tempo) || 1;
342
+ // Preview instantly: the current file plays at (wanted / baked) rate.
343
+ $('vid').playbackRate = v / was;
344
+ $('vidMeta').textContent = `${current} · re-rendering at ${v}× — Download will have it in a few seconds…`;
345
+ try {
346
+ await api('/api/demos/' + current + '/settings', { method: 'PATCH', body: JSON.stringify({ tempo: v === 1 ? null : v }) });
347
+ const before = (await api('/api/take/' + current)).stamp;
348
+ await api('/api/run/' + current + '?mode=render', { method: 'POST' });
349
+ for (let i = 0; i < 60; i++) {
350
+ await new Promise((r) => setTimeout(r, 1500));
351
+ const d = await api('/api/take/' + current).catch(() => null);
352
+ if (d && d.stamp !== before) break;
353
+ }
354
+ await drawDemos();
355
+ await pin(current);
356
+ } catch (e) { $('vidMeta').textContent = `${current} · could not re-render: ${e.message}`; }
357
+ });
358
+
359
+ // ---- the live feed: an agent working shows up here as it works -------------
360
+ function watchActivity() {
361
+ const es = new EventSource('/api/activity/stream');
362
+ const apply = (a) => {
363
+ if (!a || (!a.active && !a.lines?.length)) return;
364
+ $('live').classList.add('show');
365
+ $('liveDot').className = 'dot ' + (a.active ? 'live' : 'ok');
366
+ $('liveWho').textContent = a.active ? (a.who || 'your agent') : 'Done';
367
+ $('liveWhat').textContent = a.demo ? `· ${a.demo}` : '';
368
+ $('liveLines').innerHTML = (a.lines || []).slice(-4).map((l) => `<div>${esc(l)}</div>`).join('');
369
+ };
370
+ es.addEventListener('state', (ev) => {
371
+ const d = JSON.parse(ev.data);
372
+ // The server restarted under us → this page's HTML/CSS is stale. Reload.
373
+ if (window.__boot && d.boot && d.boot !== window.__boot) location.reload();
374
+ window.__boot = d.boot || window.__boot;
375
+ apply(d);
376
+ });
377
+ es.addEventListener('line', (ev) => apply(JSON.parse(ev.data)));
378
+ es.addEventListener('finished', async (ev) => { apply(JSON.parse(ev.data)); await drawDemos(); await newest(); setTimeout(() => $('live').classList.remove('show'), 8000); });
379
+ es.onerror = () => { es.close(); setTimeout(watchActivity, 3000); };
380
+ }
381
+
382
+ (async () => { settings = await api('/api/settings').catch(() => null); await drawDemos(); await newest(); watchActivity(); setInterval(drawDemos, 30000); })();
383
+ </script>
384
+ </body>
385
+ </html>
@@ -0,0 +1,85 @@
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" />
6
+ <title>How Retake works</title>
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
8
+ <style>
9
+ :root { --paper:#edefe8; --card:#f8f9f5; --ink:#191d19; --muted:#767e74; --rule:#dbdfd3; --amber:#e8b04b; --accent:#1a1c1e; }
10
+ * { box-sizing:border-box; }
11
+ body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.7 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
12
+ .top { display:flex; align-items:center; gap:10px; padding:14px 22px; --rule2:#c9cec0; }
13
+ .mark { width:22px; height:22px; border-radius:7px; background:var(--accent); position:relative; }
14
+ .mark i { position:absolute; left:5.5px; height:2.5px; border-radius:2px; background:#eceee9; }
15
+ .mark i:nth-child(1){top:4px;width:4px;opacity:.55} .mark i:nth-child(2){top:8px;width:7px;opacity:.8} .mark i:nth-child(3){top:12px;width:11px;background:var(--amber)} .mark i:nth-child(4){top:16px;width:7px;opacity:.8}
16
+ .top a { display:flex; align-items:center; gap:10px; text-decoration:none; color:inherit; }
17
+ .top h1 { font-size:15px; font-weight:600; margin:0; }
18
+ main { max-width:640px; margin:0 auto; padding:28px 22px 80px; }
19
+ h2 { font-size:22px; letter-spacing:-.01em; margin:0 0 6px; }
20
+ .lead { color:var(--muted); margin:0 0 34px; }
21
+ section { margin-bottom:34px; }
22
+ h3 { font-size:16px; margin:0 0 8px; }
23
+ p { margin:0 0 10px; }
24
+ .say { background:var(--card); border:1px solid var(--rule); border-radius:12px; padding:12px 16px; margin:10px 0; font-style:italic; }
25
+ .say::before { content:"“"; } .say::after { content:"”"; }
26
+ ul { margin:8px 0; padding-left:20px; }
27
+ li { margin-bottom:4px; }
28
+ .quiet { color:var(--muted); font-size:14px; }
29
+ code { background:var(--card); border:1px solid var(--rule); border-radius:6px; padding:1px 7px; font:14px ui-monospace, Menlo, monospace; }
30
+ hr { border:0; border-top:1px solid var(--rule); margin:34px 0; }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <div class="top">
35
+ <a href="/"><div class="mark"><i></i><i></i><i></i><i></i></div><h1>Retake</h1></a>
36
+ <span style="flex:1"></span>
37
+ <a href="/" style="border:1px solid var(--rule2, #c9cec0); background:#fff; border-radius:999px; padding:6px 13px; font-size:13px; color:var(--ink); text-decoration:none">← Back</a>
38
+ </div>
39
+ <main>
40
+ <h2>How Retake works</h2>
41
+ <p class="lead">Describe a demo of your app in a sentence. Retake drives a real browser through it and hands you the video. When the app changes, you re-run — nobody re-records anything by hand.</p>
42
+
43
+ <section>
44
+ <h3>1 · Ask for a demo</h3>
45
+ <p>Talk to the agent you already use — Claude Code or Codex. It has Retake's tools.</p>
46
+ <div class="say">Record a demo of my app at localhost:3000 showing the sign-up flow.</div>
47
+ <div class="say">What demos are worth recording for this project? Record the best three.</div>
48
+ <p class="quiet">The agent reads your app, writes the steps, checks every one of them against the live page, and only then records. You can watch it work on this window's front page.</p>
49
+ </section>
50
+
51
+ <section>
52
+ <h3>2 · Judge the take</h3>
53
+ <p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Edit</b> to open its steps and knobs in the workshop.</p>
54
+ </section>
55
+
56
+ <section>
57
+ <h3>3 · Direct it like a videographer</h3>
58
+ <p>Say what you want changed, in plain words:</p>
59
+ <ul>
60
+ <li>“No zooms — keep the camera still.”</li>
61
+ <li>“Hide the cursor.” · “Shorter captions.” · “No captions.”</li>
62
+ <li>“Hold the ending longer.” · “Cut the first two seconds.”</li>
63
+ <li>“Make it square for a post.” · “Vertical for a reel.”</li>
64
+ </ul>
65
+ <p class="quiet">Say it once — Retake remembers it for the project. Most changes re-render in seconds without re-recording.</p>
66
+ </section>
67
+
68
+ <hr>
69
+
70
+ <section>
71
+ <h3>Where things live</h3>
72
+ <p>Each demo is a small YAML file in <code>demos/</code> — that file <i>is</i> the demo. Videos land in <code>outputs/&lt;name&gt;/</code>: <code>demo.mp4</code>, <code>stills/</code>, and <code>proof-log.md</code> saying exactly what happened, step by step.</p>
73
+ </section>
74
+
75
+ <section>
76
+ <h3>Good to know</h3>
77
+ <ul>
78
+ <li>Retake never starts your app without you saying so.</li>
79
+ <li>Passwords stay in <code>.env</code> — never in a demo file, never on camera.</li>
80
+ <li>A failed step names what was on screen at the time — read the proof log before guessing.</li>
81
+ </ul>
82
+ </section>
83
+ </main>
84
+ </body>
85
+ </html>