retake-demos 0.3.0 → 0.4.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.
package/dist/ui/chat.html CHANGED
@@ -16,13 +16,13 @@
16
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
17
  button, input { font:inherit; color:inherit; }
18
18
  .app { min-height:100%; display:flex; flex-direction:column; }
19
- .top { display:flex; align-items:center; gap:10px; padding:14px 22px; }
19
+ .top { display:flex; align-items:center; gap:10px; padding:0 24px; height:56px; border-bottom:1px solid var(--rule); background:var(--paper); }
20
20
  .mark { width:22px; height:22px; border-radius:7px; background:var(--accent); position:relative; flex:none; }
21
21
  .mark i { position:absolute; left:5.5px; height:2.5px; border-radius:2px; background:#eceee9; }
22
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
23
  .top h1 { font-size:15px; font-weight:600; margin:0; }
24
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; }
25
+ .chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; height:32px; padding:0 14px; display:inline-flex; align-items:center; font-size:13px; font-weight:500; color:var(--ink); cursor:pointer; transition:border-color .12s; }
26
26
  .chip:hover { border-color:var(--ink); }
27
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
28
  .pop.show { display:block; }
@@ -36,13 +36,13 @@
36
36
  @keyframes pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
37
37
  .quiet { padding:8px 10px; color:var(--muted); font-size:13px; }
38
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; }
39
+ .wrap { width:100%; margin:0; padding:24px 24px 56px; display:grid; grid-template-columns:minmax(0,1fr); gap:24px; align-items:start; }
40
+ .col-main { max-width:760px; width:100%; margin:0 auto; min-width:0; }
41
41
  /* wide screens: the library floats as its own card at the left, detached
42
42
  from the window edge; the video centers in the space beside it. */
43
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; }
44
+ .wrap.two { grid-template-columns:272px minmax(0,1fr); gap:32px; }
45
+ .wrap.two .lib { position:sticky; top:24px; order:-1; }
46
46
  }
47
47
 
48
48
  /* live activity — what an agent is doing to this library right now */
@@ -58,46 +58,58 @@
58
58
  .pin.show { display:block; }
59
59
  /* The element takes the video's own shape: width follows the height cap, so
60
60
  a 1.6:1 take gets no black bars at the sides. */
61
- .pin video { width:auto; max-width:100%; height:auto; max-height:62vh; margin:0 auto; border-radius:12px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 10px 30px rgba(24,28,24,.10); }
62
- .under { display:flex; align-items:center; gap:8px; margin-top:10px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
63
- .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; }
61
+ .pin video { width:auto; max-width:100%; height:auto; max-height:60vh; margin:0 auto; border-radius:14px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 16px 40px rgba(24,28,24,.10); }
62
+ .under { display:flex; align-items:baseline; gap:8px; margin-top:16px; flex-wrap:wrap; }
63
+ .vtitle { font-size:15px; font-weight:600; color:var(--ink); }
64
+ .vmeta { font-size:13px; color:var(--muted); }
65
+ .under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 14px; cursor:pointer; font-size:13px; font-weight:500; display:inline-flex; align-items:center; transition:border-color .12s; }
64
66
  .under a:hover, .under button:hover { border-color:var(--ink); }
65
67
  /* A native select with its arrow removed leaves lopsided padding; make it
66
68
  sit exactly like the pills beside it, text centred, same height. */
67
- .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; }
68
- .under a, .under button { height:29px; line-height:17px; box-sizing:border-box; }
69
+ .under select { color:var(--ink); border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 12px; height:32px; font-size:13px; font-weight:500; font-family:inherit; text-align:center; text-align-last:center; cursor:pointer; -webkit-appearance:none; appearance:none; min-width:58px; transition:border-color .12s; }
70
+ .under a, .under button { height:32px; box-sizing:border-box; align-self:center; }
69
71
  .under select:hover { border-color:var(--ink); }
70
- .stills { display:flex; gap:8px; margin-top:10px; overflow-x:auto; padding-bottom:4px; }
71
- .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; }
72
+ .frames { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
73
+ .frames .fhead { display:flex; align-items:center; margin-bottom:10px; }
74
+ .frames .fhead .lab { font:11px ui-monospace, Menlo, monospace; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); }
75
+ .frames .fhead .hint { margin-left:10px; font-size:12.5px; color:var(--muted); }
76
+ .stills { display:flex; gap:8px; overflow-x:auto; padding-bottom:2px; align-items:center; }
77
+ .stills label.up { font-size:12.5px; font-weight:500; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; height:58px; display:inline-flex; align-items:center; padding:0 14px; cursor:pointer; flex:none; transition:border-color .12s, color .12s; }
78
+ .stills label.up:hover { border-color:var(--ink); color:var(--ink); }
79
+ .stills img { height:58px; border-radius:8px; border:1px solid var(--rule2); cursor:pointer; flex:none; transition:border-color .12s; }
72
80
  .stills img:hover { border-color:var(--ink); }
73
- .stills img.poster, .coverrow img.poster { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
74
- .coverrow { display:flex; gap:8px; align-items:center; margin-top:10px; flex-wrap:wrap; }
75
- .coverrow img { height:64px; border-radius:7px; border:1px solid var(--rule2); cursor:pointer; }
81
+ .stills img.poster, .coverrow img.poster { border-color:var(--ink); box-shadow:0 0 0 2px var(--ink); }
82
+ .coverrow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px; }
83
+ .coverrow:empty { display:none; margin:0; }
84
+ .coverrow img { height:58px; border-radius:8px; border:1px solid var(--rule2); cursor:pointer; transition:border-color .12s; }
76
85
  .coverrow img:hover { border-color:var(--ink); }
77
- .coverrow .lab { font:11px ui-monospace, Menlo, monospace; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); }
78
- .coverrow label.up { font-size:12.5px; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; padding:8px 12px; cursor:pointer; }
86
+ .coverrow .lab { display:none; }
87
+ .coverrow label.up { font-size:12.5px; font-weight:500; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; height:58px; display:inline-flex; align-items:center; padding:0 14px; cursor:pointer; transition:border-color .12s, color .12s; }
79
88
  .coverrow label.up:hover { border-color:var(--ink); color:var(--ink); }
80
89
  /* the library: one quiet card at the video's side */
81
- .lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 10px; position:sticky; top:14px; line-height:1.4; }
82
- .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; }
90
+ .lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:12px 8px; position:sticky; top:24px; line-height:1.4; }
91
+ .lib h2 { font:11px ui-monospace, Menlo, monospace; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:500; margin:4px 4px 10px 14px; }
83
92
  details.group { margin-bottom:0; }
84
- 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; }
93
+ details.group summary { list-style:none; cursor:pointer; display:flex; align-items:center; gap:8px; padding:0 12px 0 6px; border-radius:10px; font-size:14px; color:var(--muted); height:38px; }
85
94
  details.group summary::-webkit-details-marker { display:none; }
86
95
  details.group summary:hover { background:#eef1ea; color:var(--ink); }
87
- 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; }
88
- details.group summary:hover .tri { background:#e6e9e1; }
89
- details.group summary .fold { width:18px; height:14px; flex:none; position:relative; }
90
- details.group summary .fold::before { content:""; position:absolute; inset:0; border-radius:3px; background:var(--amber); opacity:.9; }
91
- 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; }
96
+ details.group summary .tri { transition:transform .15s; font-size:9px; color:var(--rule2); width:18px; height:18px; display:flex; align-items:center; justify-content:center; flex:none; }
97
+ details.group summary:hover .tri { color:var(--muted); }
98
+ /* The folder mark is quiet: amber belongs to the brand and the live dot,
99
+ not to every row of a list. */
100
+ details.group summary .fold { width:15px; height:12px; flex:none; position:relative; opacity:.8; }
101
+ details.group summary .fold::before { content:""; position:absolute; inset:0; border-radius:2.5px; background:var(--rule2); }
102
+ details.group summary .fold::after { content:""; position:absolute; left:0; top:-2.5px; width:7px; height:4px; border-radius:2px 2px 0 0; background:var(--rule2); }
103
+ details.group[open] summary .fold::before, details.group[open] summary .fold::after { background:var(--amber); opacity:.85; }
92
104
  details.group[open] summary .tri { transform:rotate(90deg); }
93
- details.group summary b { color:var(--ink); font-weight:600; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
94
- details.group summary small { margin-left:auto; font-size:12.5px; }
95
- details.group .demo { margin-left:14px; }
96
- .demo { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:10px; cursor:pointer; min-height:40px; }
105
+ details.group summary b { color:var(--ink); font-weight:500; font-size:13.5px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
106
+ details.group summary small { margin-left:auto; font-size:11.5px; color:var(--muted); font-variant-numeric:tabular-nums; }
107
+ details.group .demo { margin-left:24px; }
108
+ .demo { display:flex; align-items:center; gap:9px; padding:0 10px; border-radius:9px; cursor:pointer; height:34px; }
97
109
  .demo:hover { background:#eef1ea; }
98
- .demo.on { background:#fff; border:1px solid var(--rule); }
99
- .demo .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:14.5px; }
100
- .demo small { color:var(--muted); font-size:12px; flex:none; }
110
+ .demo.on { background:#fff; border:1px solid var(--rule); box-shadow:0 1px 3px rgba(24,28,24,.05); }
111
+ .demo .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13.5px; }
112
+ .demo small { color:var(--muted); font-size:11.5px; flex:none; font-variant-numeric:tabular-nums; }
101
113
  .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; }
102
114
  .demo .zap:hover { background:#f3e9e6; }
103
115
  .demo:hover .zap { visibility:visible; }
@@ -123,6 +135,42 @@
123
135
  .empty h2 { color:var(--ink); font-size:17px; margin:0 0 6px; }
124
136
  .empty p { margin:0; font-size:14px; }
125
137
  .empty code { background:var(--card); border:1px solid var(--rule); border-radius:6px; padding:2px 7px; font:13px ui-monospace, Menlo, monospace; }
138
+ .modal.wide { width:min(94vw, 560px); max-height:82vh; display:flex; flex-direction:column; }
139
+ .modal.wide h3 { display:flex; align-items:baseline; gap:9px; }
140
+ .modal.wide .scroll { overflow-y:auto; margin:0 -4px; padding:0 4px; }
141
+ .rel { border-top:1px solid var(--rule); padding:15px 0; }
142
+ .rel:first-child { border-top:0; padding-top:2px; }
143
+ .relhead { display:flex; flex-wrap:wrap; align-items:baseline; gap:9px; margin-bottom:7px; }
144
+ .relhead b { font-size:14.5px; color:var(--ink); letter-spacing:-.01em; }
145
+ .relmeta { font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums; }
146
+ /* --accent is near-black in this window; the badge needs the amber token or
147
+ it renders dark ink on a dark pill and reads as a redaction bar. */
148
+ .rel.latest .relmeta::after { content:"newest"; margin-left:7px; padding:1.5px 7px; border-radius:99px; background:var(--amber); color:#241d0c; font-size:10.5px; font-weight:600; letter-spacing:.01em; }
149
+ .rel ul { margin:0; padding-left:17px; }
150
+ .rel li { margin:4px 0; font-size:13.5px; line-height:1.55; color:var(--muted); }
151
+ .rel:not(.latest) li { opacity:.8; }
152
+ .chip .ndot { display:inline-block; width:6px; height:6px; border-radius:99px; background:var(--amber); margin-left:6px; vertical-align:middle; }
153
+ .lib h2 { display:flex; align-items:center; gap:8px; }
154
+ .lib h2 .selbtn { margin-left:auto; border:1px solid var(--rule2); background:#fff; color:var(--muted); border-radius:8px; font-size:11.5px; padding:3px 9px; cursor:pointer; line-height:1.4; }
155
+ .lib h2 .selbtn:hover { border-color:var(--ink); color:var(--ink); }
156
+ .lib h2 .selbtn.on { background:var(--ink); border-color:var(--ink); color:#fff; }
157
+ .demo .pick { display:none; width:15px; height:15px; margin-right:2px; accent-color:var(--ink); flex:none; cursor:pointer; }
158
+ body.selecting .demo .pick { display:inline-block; }
159
+ body.selecting .demo .zap { display:none; }
160
+ body.selecting .demo { cursor:pointer; }
161
+ .selbar { position:sticky; bottom:0; margin-top:10px; padding:10px 12px; background:var(--paper); border-top:1px solid var(--rule2); display:flex; align-items:center; gap:9px; }
162
+ .selbar small { color:var(--muted); font-size:12px; }
163
+ .selbar .sp { flex:1; }
164
+ .selbar button { border:1px solid var(--rule2); background:#fff; border-radius:8px; font-size:12px; padding:5px 11px; cursor:pointer; }
165
+ .selbar button:hover { border-color:var(--ink); }
166
+ .selbar button.go { background:var(--bad); border-color:var(--bad); color:#fff; }
167
+ .selbar button.go:disabled { opacity:.4; cursor:default; }
168
+ .selbar button.go:not(:disabled):hover { filter:brightness(.92); }
169
+ .group summary .gpick { display:none; width:15px; height:15px; margin-right:2px; accent-color:var(--ink); flex:none; cursor:pointer; vertical-align:middle; }
170
+ body.selecting .group summary .gpick { display:inline-block; }
171
+ .arch summary .emptybtn { margin-left:auto; border:1px solid var(--rule2); background:#fff; color:var(--muted); border-radius:8px; font-size:11px; padding:2px 8px; cursor:pointer; }
172
+ .arch summary .emptybtn:hover { background:var(--bad); border-color:var(--bad); color:#fff; }
173
+ .arch summary { display:flex; align-items:center; gap:6px; }
126
174
  </style>
127
175
  </head>
128
176
  <body>
@@ -132,6 +180,7 @@
132
180
  <h1>Retake</h1>
133
181
  <span class="sp"></span>
134
182
  <a class="chip" href="/guide" style="text-decoration:none">How it works</a>
183
+ <button class="chip" id="newsBtn" title="What changed in this version">What's new</button>
135
184
  <button class="chip" id="gremlinBtn" title="Something's off? Tell me.">Bug?</button>
136
185
  </div>
137
186
  <div class="wrap two">
@@ -143,18 +192,25 @@
143
192
  <div class="pin" id="pin">
144
193
  <video id="vid" controls playsinline></video>
145
194
  <div class="under">
146
- <span id="vidMeta"></span>
195
+ <span class="vtitle" id="vidName"></span>
196
+ <span class="vmeta" id="vidMeta"></span>
147
197
  <span class="sp"></span>
148
198
  <select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
149
199
  <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>
150
200
  </select>
151
201
  <a id="vidDl" download>Download</a>
152
202
  <button id="vidFolder">Show in folder</button>
153
- <button id="vidCover" title="Make the frame showing right now the poster">Use this frame</button>
154
- <!-- The workshop (/classic) still exists but is not advertised until it has been tested as carefully as this page. -->
155
203
  </div>
156
- <div class="coverrow" id="coverrow"></div>
157
- <div class="stills" id="stills"></div>
204
+ <div class="frames">
205
+ <div class="fhead">
206
+ <span class="lab">Poster & frames</span>
207
+ <span class="hint">click any frame to make it the poster</span>
208
+ <span class="sp"></span>
209
+ <button class="chip" id="vidCover" title="Make the frame showing right now the poster">Use current frame</button>
210
+ </div>
211
+ <div class="coverrow" id="coverrow"></div>
212
+ <div class="stills" id="stills"></div>
213
+ </div>
158
214
  </div>
159
215
  <div class="empty" id="empty" style="display:none">
160
216
  <div class="mark"><i></i><i></i><i></i><i></i></div>
@@ -163,7 +219,7 @@
163
219
  </div>
164
220
  </div>
165
221
  <aside class="lib" id="lib" style="display:none">
166
- <h2 id="libTtl">Demos</h2>
222
+ <h2><span id="libTtl">Library</span><button class="selbtn" id="selBtn" title="Pick several to archive at once">Select</button></h2>
167
223
  <div id="demos"></div>
168
224
  </aside>
169
225
  </div>
@@ -188,6 +244,45 @@ async function setCover(body, marked) {
188
244
  } catch (e) { $('vidMeta').textContent = 'could not set the poster: ' + e.message; }
189
245
  }
190
246
 
247
+
248
+ // What's new — reads the same list the landing page does. The dot appears when
249
+ // the newest entry is one this browser has not seen, and only until it is read;
250
+ // nothing interrupts a person who is in the middle of something.
251
+ const SEEN_KEY = 'retake:seen-release';
252
+ let releases = null;
253
+ async function loadReleases() {
254
+ if (releases) return releases;
255
+ try { releases = (await api('/api/releases')).releases || []; } catch { releases = []; }
256
+ return releases;
257
+ }
258
+ (async () => {
259
+ const rs = await loadReleases();
260
+ const btn = $('newsBtn');
261
+ if (!rs.length) { btn.style.display = 'none'; return; }
262
+ if (localStorage.getItem(SEEN_KEY) !== rs[0].version) btn.insertAdjacentHTML('beforeend', '<span class="ndot"></span>');
263
+ })();
264
+ $('newsBtn').onclick = async () => {
265
+ const rs = await loadReleases();
266
+ if (!rs.length) return;
267
+ const fmt = (d) => { const x = new Date(d + 'T00:00:00'); return isNaN(x) ? '' : x.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }); };
268
+ const veil = document.createElement('div');
269
+ veil.className = 'veil';
270
+ veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3>' +
271
+ '<div class="scroll">' + rs.map((r, i) =>
272
+ '<div class="rel' + (i ? '' : ' latest') + '">' +
273
+ '<div class="relhead"><b>' + esc(r.headline) + '</b><span class="relmeta">' + esc(r.version) + (r.date ? ' · ' + fmt(r.date) : '') + '</span></div>' +
274
+ '<ul>' + (r.notes || []).map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul>' +
275
+ '</div>').join('') + '</div>' +
276
+ '<div class="row" style="margin-top:18px"><button class="btn" id="newsClose">Close</button></div></div>';
277
+ document.body.appendChild(veil);
278
+ localStorage.setItem(SEEN_KEY, rs[0].version);
279
+ $('newsBtn').querySelector('.ndot')?.remove();
280
+ const shut = () => veil.remove();
281
+ veil.querySelector('#newsClose').onclick = shut;
282
+ veil.onclick = (e) => { if (e.target === veil) shut(); };
283
+ document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { shut(); document.removeEventListener('keydown', esc2); } });
284
+ };
285
+
191
286
  document.getElementById('gremlinBtn').onclick = async () => {
192
287
  const veil = document.createElement('div');
193
288
  veil.className = 'veil';
@@ -266,7 +361,8 @@ async function pin(name) {
266
361
  }
267
362
  const t = d.take;
268
363
  const failed = (t.timeline || []).find((e) => e.ok === false);
269
- $('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}` : ''}`;
364
+ $('vidName').textContent = name;
365
+ $('vidMeta').textContent = `${(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}` : ''}`;
270
366
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
271
367
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
272
368
  $('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
@@ -278,15 +374,17 @@ $('vidCover').onclick = () => setCover({ at: $('vid').currentTime });
278
374
  // here, not guessed: any scene still, any frame of the take, a generated
279
375
  // cover, or the person's own image. All of it just rewrites thumbnail.png,
280
376
  // so it costs no re-render.
377
+ // One tray: generated covers first, then every scene frame, then upload —
378
+ // a single row a person scans once, left to right.
281
379
  const st = (d.files || []).filter((f) => f.startsWith('stills/')).sort();
282
- $('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="Make this the poster — ${esc(f.slice(7, -4))}" loading="lazy" data-still="${esc(f.slice(7))}">`).join('');
283
- for (const img of $('stills').querySelectorAll('img')) img.onclick = () => setCover({ still: img.dataset.still }, img);
284
-
285
380
  const covers = (d.files || []).filter((f) => f === 'cover.png' || f === 'cover-titled.png');
286
- $('coverrow').innerHTML =
287
- (covers.length ? `<span class="lab">poster</span>` + covers.map((f) => `<img src="/out/${name}/${f}${q}" title="${f === 'cover.png' ? 'The title card' : 'This frame, with the title on it'}" data-file="${f}">`).join('') : `<span class="lab">poster</span>`) +
381
+ $('coverrow').innerHTML = '';
382
+ $('stills').innerHTML =
383
+ covers.map((f) => `<img src="/out/${name}/${f}${q}" title="${f === 'cover.png' ? 'The title card' : 'This frame, with the title on it'}" data-file="${f}">`).join('') +
384
+ st.map((f) => `<img src="/out/${name}/${f}${q}" title="Make this the poster — ${esc(f.slice(7, -4))}" loading="lazy" data-still="${esc(f.slice(7))}">`).join('') +
288
385
  `<label class="up">Upload an image<input type="file" accept="image/png,image/jpeg,image/webp" hidden id="coverUp"></label>`;
289
- for (const img of $('coverrow').querySelectorAll('img[data-file]')) img.onclick = () => setCover({ file: img.dataset.file }, img);
386
+ for (const img of $('stills').querySelectorAll('img[data-still]')) img.onclick = () => setCover({ still: img.dataset.still }, img);
387
+ for (const img of $('stills').querySelectorAll('img[data-file]')) img.onclick = () => setCover({ file: img.dataset.file }, img);
290
388
  $('coverUp').onchange = (e) => {
291
389
  const f = e.target.files?.[0];
292
390
  if (!f) return;
@@ -306,7 +404,7 @@ async function drawDemos() {
306
404
  $('empty').style.display = has ? 'none' : '';
307
405
  $('lib').style.display = has ? '' : 'none';
308
406
  document.body.classList.toggle('rail', has);
309
- $('libTtl').textContent = `Demos · ${demos.length}`;
407
+ $('libTtl').textContent = `Library · ${demos.length}`;
310
408
  $('demos').innerHTML = '';
311
409
  const groups = new Map();
312
410
  for (const p of demos) { const g = p.group || 'unsorted'; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(p); }
@@ -316,7 +414,26 @@ async function drawDemos() {
316
414
  det.className = 'group';
317
415
  const holdsCurrent = items.some((x) => x.name === current);
318
416
  if (open.has(g) || groups.size === 1 || holdsCurrent) det.open = true;
319
- det.innerHTML = `<summary><span class="tri">▶</span><span class="fold" aria-hidden="true"></span><b>${esc(g)}</b><small>${items.length}</small></summary>`;
417
+ det.innerHTML = `<summary><input type="checkbox" class="gpick"><span class="tri">▶</span><span class="fold" aria-hidden="true"></span><b>${esc(g)}</b><small>${items.length}</small></summary>`;
418
+ // A whole folder in one click — with 35 demos in a handful of projects,
419
+ // ticking them one at a time is the thing this is meant to replace.
420
+ const gbox = det.querySelector('.gpick');
421
+ const names = items.map((x) => x.name);
422
+ gbox.checked = names.length > 0 && names.every((n) => picked.has(n));
423
+ // Clicking a checkbox inside <summary> also toggles the folder open/shut,
424
+ // and preventDefault would stop BOTH — including the tick, which is what
425
+ // makes "select the folder" look like it did nothing. So the fold is
426
+ // allowed to happen and then put straight back, and the tick is left
427
+ // entirely alone.
428
+ gbox.onclick = (ev) => {
429
+ ev.stopPropagation();
430
+ const wasOpen = det.open;
431
+ const all = names.every((n) => picked.has(n));
432
+ for (const n of names) all ? picked.delete(n) : picked.add(n);
433
+ for (const b of det.querySelectorAll('.demo .pick')) b.checked = !all;
434
+ drawSelBar();
435
+ setTimeout(() => { det.open = wasOpen; }, 0);
436
+ };
320
437
  det.addEventListener('toggle', () => {
321
438
  const now = [...document.querySelectorAll('details.group[open] summary b')].map((b) => b.textContent);
322
439
  localStorage.setItem('retake.openGroups', JSON.stringify(now));
@@ -326,8 +443,16 @@ async function drawDemos() {
326
443
  el.className = 'demo' + (p.name === current ? ' on' : '');
327
444
  const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
328
445
  const when = p.lastTake ? new Date(p.lastTake.finishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'no take yet';
329
- 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>`;
330
- el.onclick = () => { if (p.lastTake) pin(p.name); };
446
+ el.innerHTML = `<input type="checkbox" class="pick"${picked.has(p.name) ? ' checked' : ''}><span class="dot ${dot}"></span><span class="nm">${esc(p.name)}</span><small>${esc(when)}</small><button class="zap" title="Archive this demo">✕</button>`;
447
+ const box = el.querySelector('.pick');
448
+ const syncGroup = () => { const gb = det.querySelector('.gpick'); if (gb) gb.checked = items.every((x) => picked.has(x.name)); };
449
+ box.onclick = (ev) => { ev.stopPropagation(); box.checked ? picked.add(p.name) : picked.delete(p.name); syncGroup(); drawSelBar(); };
450
+ el.onclick = () => {
451
+ // In select mode the whole row is the checkbox — hunting for a 15px
452
+ // target is exactly the fiddliness this is meant to remove.
453
+ if (selecting) { box.checked = !box.checked; box.checked ? picked.add(p.name) : picked.delete(p.name); syncGroup(); drawSelBar(); return; }
454
+ if (p.lastTake) pin(p.name);
455
+ };
331
456
  el.querySelector('.zap').onclick = async (ev) => {
332
457
  ev.stopPropagation();
333
458
  // Archive, not destroy — but moving someone's work still deserves a beat.
@@ -341,8 +466,59 @@ async function drawDemos() {
341
466
  $('demos').appendChild(det);
342
467
  }
343
468
  await drawArchive();
469
+ drawSelBar();
344
470
  }
345
471
 
472
+
473
+ // Bulk archive. Off by default: a list you can accidentally multi-select from
474
+ // is worse than one that needs a deliberate "Select" first.
475
+ let selecting = false;
476
+ const picked = new Set();
477
+
478
+ function drawSelBar() {
479
+ const old = document.querySelector('.selbar'); if (old) old.remove();
480
+ if (!selecting) return;
481
+ const bar = document.createElement('div');
482
+ bar.className = 'selbar';
483
+ const n = picked.size;
484
+ bar.innerHTML = `<small>${n ? n + ' selected' : 'none selected'}</small><span class="sp"></span>` +
485
+ `<button id="selAll">${n === demos.length ? 'None' : 'All'}</button>` +
486
+ `<button class="go" id="selGo"${n ? '' : ' disabled'}>Archive${n ? ' ' + n : ''}</button>`;
487
+ $('lib').appendChild(bar);
488
+ bar.querySelector('#selAll').onclick = () => {
489
+ if (picked.size === demos.length) picked.clear(); else for (const d of demos) picked.add(d.name);
490
+ for (const b of document.querySelectorAll('.demo .pick')) b.checked = picked.has(b.parentElement.querySelector('.nm').textContent);
491
+ drawSelBar();
492
+ };
493
+ bar.querySelector('#selGo').onclick = async () => {
494
+ const names = [...picked];
495
+ if (!names.length) return;
496
+ if (!(await ask(`Archive ${names.length} demo${names.length === 1 ? '' : 's'}?`,
497
+ names.slice(0, 8).join(', ') + (names.length > 8 ? ` and ${names.length - 8} more` : '') +
498
+ '. Their videos are removed. The demos move to the archive below and can be restored any time.', 'Archive'))) return;
499
+ const failed = [];
500
+ for (const nm of names) {
501
+ try { await api('/api/demos/' + nm, { method: 'DELETE' }); } catch (e) { failed.push(nm + ' (' + e.message + ')'); }
502
+ }
503
+ if (current && picked.has(current)) { current = null; $('pin').classList.remove('show'); }
504
+ picked.clear();
505
+ selecting = false;
506
+ document.body.classList.remove('selecting');
507
+ $('selBtn').classList.remove('on');
508
+ await drawDemos();
509
+ if (failed.length) alert('Could not archive:\n' + failed.join('\n'));
510
+ };
511
+ }
512
+
513
+ $('selBtn').onclick = () => {
514
+ selecting = !selecting;
515
+ picked.clear();
516
+ document.body.classList.toggle('selecting', selecting);
517
+ $('selBtn').classList.toggle('on', selecting);
518
+ $('selBtn').textContent = selecting ? 'Done' : 'Select';
519
+ drawDemos();
520
+ };
521
+
346
522
  async function drawArchive() {
347
523
  let items = [];
348
524
  try { items = await api('/api/trash'); } catch { return; }
@@ -350,7 +526,7 @@ async function drawArchive() {
350
526
  if (!items.length) return;
351
527
  const det = document.createElement('details');
352
528
  det.className = 'group arch';
353
- det.innerHTML = `<summary><span class="tri">▶</span><b>Archive</b><small>${items.length}</small></summary>`;
529
+ det.innerHTML = `<summary><span class="tri">▶</span><b>Archive</b><small>${items.length}</small><button class="emptybtn" id="emptyArch" title="Delete every archived demo, permanently">Empty</button></summary>`;
354
530
  for (const t of items) {
355
531
  const el = document.createElement('div');
356
532
  el.className = 'row2';
@@ -360,12 +536,15 @@ async function drawArchive() {
360
536
  }
361
537
  const clear = document.createElement('button');
362
538
  clear.className = 'clear';
363
- clear.textContent = 'Clear archive — gone for good';
364
- clear.onclick = async () => {
365
- 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;
539
+ clear.textContent = `Empty the archive — delete ${items.length} demo${items.length === 1 ? '' : 's'} for good`;
540
+ const empty = async (ev) => {
541
+ if (ev) { ev.stopPropagation(); ev.preventDefault(); } // the summary must not fold
542
+ if (!(await ask('Empty the archive?', `${items.length} archived demo${items.length === 1 ? '' : 's'} will be deleted permanently — the manifests and everything recorded from them. There is no way back from this one.`, 'Delete forever'))) return;
366
543
  await api('/api/trash/clear', { method: 'POST' });
367
544
  await drawDemos();
368
545
  };
546
+ clear.onclick = empty;
547
+ det.querySelector('#emptyArch').onclick = empty;
369
548
  det.appendChild(clear);
370
549
  $('demos').appendChild(det);
371
550
  }
package/dist/ui/server.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * POST /api/describe { name, url, describe } → scout + model → draft manifest (saved)
11
11
  * GET /api/provider { active, available }
12
12
  * GET /api/presets [{name, ...}]
13
+ * GET /api/releases what's new, from site/releases.json
13
14
  * GET/PUT/DELETE /api/drafts/:id an unfinished "new demo" (auto-saved as you go)
14
15
  * DELETE /api/demos/:name remove a demo (manifest + outputs)
15
16
  * PATCH /api/demos/:name/settings { preset?, layout?, camera?, cursor? } → edits keys, keeps comments
@@ -34,7 +35,7 @@ import { applyEdits, receiptsFor } from "../edits.js";
34
35
  import { dryRun } from "../dryrun.js";
35
36
  import { startOperator, getSession, addPending, answerPending, markDone, stopSession } from "../operator/run.js";
36
37
  import { ffmpegBin, gifskiBin, makeGif } from "../render.js";
37
- import { captureHash } from "../record.js";
38
+ import { captureHash, restorePrevious } from "../record.js";
38
39
  import { digest } from "../digest.js";
39
40
  import { startOffer, startApp, listeningPorts } from "../appserver.js";
40
41
  import { PKG_ROOT, PROJECT_ROOT, VERSION, entry } from "../paths.js";
@@ -163,6 +164,34 @@ function unassignDemoProject(name) {
163
164
  delete map[name];
164
165
  fs.writeFileSync(DEMO_PROJECTS, JSON.stringify(map, null, 2) + "\n");
165
166
  }
167
+ /** Parsed manifests, keyed by path and invalidated by mtime+size.
168
+ Listing 32 demos meant 64 YAML parses and zod validations per request —
169
+ two seconds, on a call the window makes on load, on every agent event and
170
+ every 30s. Nothing about a file that has not changed can have changed. */
171
+ const manifestCache = new Map();
172
+ function cachedManifest(file) {
173
+ let key;
174
+ try {
175
+ const st = fs.statSync(file);
176
+ key = `${st.mtimeMs}:${st.size}`;
177
+ }
178
+ catch {
179
+ manifestCache.delete(file);
180
+ return null;
181
+ }
182
+ const hit = manifestCache.get(file);
183
+ if (hit && hit.key === key)
184
+ return hit.manifest;
185
+ let manifest = null;
186
+ try {
187
+ manifest = loadManifest(file).manifest;
188
+ }
189
+ catch {
190
+ manifest = null;
191
+ }
192
+ manifestCache.set(file, { key, manifest });
193
+ return manifest;
194
+ }
166
195
  function listDemos(project) {
167
196
  if (!fs.existsSync(DEMOS))
168
197
  return [];
@@ -170,13 +199,12 @@ function listDemos(project) {
170
199
  const assignments = demoProjects();
171
200
  const byUrl = new Map();
172
201
  for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
173
- try {
174
- const mm = loadManifest(path.join(DEMOS, f)).manifest;
202
+ const mm = cachedManifest(path.join(DEMOS, f));
203
+ if (mm) {
175
204
  const asg = assignments[mm.name];
176
205
  if (asg && mm.url && !byUrl.has(mm.url))
177
206
  byUrl.set(mm.url, asg);
178
207
  }
179
- catch { /* invalid manifest: no vote */ }
180
208
  }
181
209
  return fs
182
210
  .readdirSync(DEMOS)
@@ -185,7 +213,9 @@ function listDemos(project) {
185
213
  const file = path.join(DEMOS, f);
186
214
  let title = "", name = f.replace(/\.ya?ml$/, ""), valid = true, url = "", settings = {};
187
215
  try {
188
- const m = loadManifest(file).manifest;
216
+ const m = cachedManifest(file);
217
+ if (!m)
218
+ throw new Error("invalid");
189
219
  title = m.title ?? "";
190
220
  name = m.name;
191
221
  url = m.url;
@@ -206,6 +236,11 @@ function listDemos(project) {
206
236
  // Capture lands in a second "localhost:3100" fold next to "capture".
207
237
  const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
208
238
  const group = assigned ? path.basename(assigned) : shortGroup(url);
239
+ // Heal a folder whose run was killed mid-recording: the previous take is
240
+ // in its stash and nothing has put it back yet. The window must never
241
+ // show someone an empty demo when their recording is safe on disk.
242
+ if (!fs.existsSync(path.join(outRoot(), name, "take.json")))
243
+ restorePrevious(path.join(outRoot(), name));
209
244
  const take = path.join(outRoot(), name, "take.json");
210
245
  let lastTake = null;
211
246
  // Does the browser need to run again, or is a re-render enough? The
@@ -415,7 +450,6 @@ function noteActivity(b) {
415
450
  }
416
451
  export function serve(port) {
417
452
  loadDotenv(ROOT);
418
- const page = fs.readFileSync(path.join(HERE, "index.html"), "utf8");
419
453
  const chatPage = fs.readFileSync(path.join(HERE, "chat.html"), "utf8");
420
454
  const guidePage = fs.readFileSync(path.join(HERE, "guide.html"), "utf8");
421
455
  const server = http.createServer(async (req, res) => {
@@ -435,6 +469,16 @@ export function serve(port) {
435
469
  }
436
470
  // Both prefixes: the page is served at /landing here and from a site
437
471
  // root when deployed, so its asset paths are relative either way.
472
+ // releases.json sits beside index.html when deployed, so the preview
473
+ // has to serve it from the same two prefixes the page might be under.
474
+ const mrel = /^(?:\/landing)?\/releases\.json$/.exec(p);
475
+ if (mrel && (req.method === "GET" || req.method === "HEAD")) {
476
+ const f = path.join(PKG_ROOT, "site", "releases.json");
477
+ if (!fs.existsSync(f))
478
+ return json(res, 404, { error: "not found" });
479
+ res.writeHead(200, { "content-type": "application/json" });
480
+ return res.end(req.method === "HEAD" ? undefined : fs.readFileSync(f));
481
+ }
438
482
  const msite = /^(?:\/landing)?\/(media|logos)\/([A-Za-z0-9._-]+)$/.exec(p);
439
483
  if (msite && (req.method === "GET" || req.method === "HEAD")) {
440
484
  const f = path.join(PKG_ROOT, "site", msite[1], msite[2]);
@@ -449,10 +493,6 @@ export function serve(port) {
449
493
  res.writeHead(200, { "content-type": "text/html" });
450
494
  return res.end(guidePage);
451
495
  }
452
- if (p === "/classic" && req.method === "GET") {
453
- res.writeHead(200, { "content-type": "text/html" });
454
- return res.end(page);
455
- }
456
496
  if (p === "/manifest.webmanifest") {
457
497
  res.writeHead(200, { "content-type": "application/manifest+json" });
458
498
  return res.end(JSON.stringify({ name: "Retake", short_name: "Retake", description: "Rerun the demo, don't re-record it.", start_url: "/", scope: "/", display: "standalone", background_color: "#edefe8", theme_color: "#edefe8", icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }, { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }] }));
@@ -494,6 +534,19 @@ export function serve(port) {
494
534
  }
495
535
  if (p === "/api/provider" && req.method === "GET")
496
536
  return json(res, 200, providerStatus());
537
+ if (p === "/api/releases" && req.method === "GET") {
538
+ // The same site/releases.json the landing page reads — one list, so
539
+ // the window and the page can never disagree about what shipped.
540
+ const f = path.join(PKG_ROOT, "site", "releases.json");
541
+ if (!fs.existsSync(f))
542
+ return json(res, 200, { releases: [] });
543
+ try {
544
+ return json(res, 200, JSON.parse(fs.readFileSync(f, "utf8")));
545
+ }
546
+ catch {
547
+ return json(res, 200, { releases: [] });
548
+ }
549
+ }
497
550
  if (p === "/api/presets" && req.method === "GET")
498
551
  return json(res, 200, Object.values(PRESETS));
499
552
  if (p === "/api/drafts" && req.method === "GET")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
5
5
  "keywords": [
6
6
  "demo",
@@ -40,8 +40,7 @@
40
40
  "scripts/patch-testreel.mjs",
41
41
  "AGENTS.md",
42
42
  "llms.txt",
43
- "site/index.html",
44
- "site/logos"
43
+ "site/releases.json"
45
44
  ],
46
45
  "scripts": {
47
46
  "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/ui && cp src/ui/*.html dist/ui/ && chmod +x dist/cli.js",