retake-demos 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,55 @@
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; }
174
+ .live .prog { margin-top:12px; }
175
+ .live .bar { height:4px; border-radius:99px; background:var(--rule); overflow:hidden; }
176
+ .live .bar i { display:block; height:100%; background:var(--amber); border-radius:99px;
177
+ transition:width .45s cubic-bezier(.2,.7,.3,1); }
178
+ .live .under { display:flex; align-items:baseline; gap:8px; margin-top:7px; font-size:12.5px; color:var(--muted); }
179
+ .live .under .sp { flex:1; }
180
+ .live .count { font-variant-numeric:tabular-nums; }
181
+ .live .quip { color:var(--ink); font-weight:500; }
182
+ .live .elapsed { font-variant-numeric:tabular-nums; opacity:.7; }
183
+ .spin { width:9px; height:9px; border-radius:99px; background:var(--amber); flex:none;
184
+ animation:pulse 1.4s ease-in-out infinite; }
185
+ @keyframes pulse { 0%,100% { opacity:.35; transform:scale(.82) } 50% { opacity:1; transform:scale(1) } }
186
+ @media (prefers-reduced-motion:reduce) { .spin { animation:none; opacity:.9 } .live .bar i { transition:none } }
126
187
  </style>
127
188
  </head>
128
189
  <body>
@@ -132,29 +193,41 @@
132
193
  <h1>Retake</h1>
133
194
  <span class="sp"></span>
134
195
  <a class="chip" href="/guide" style="text-decoration:none">How it works</a>
196
+ <button class="chip" id="newsBtn" title="What changed in this version">What's new</button>
135
197
  <button class="chip" id="gremlinBtn" title="Something's off? Tell me.">Bug?</button>
136
198
  </div>
137
199
  <div class="wrap two">
138
200
  <div class="col-main">
139
201
  <div class="live" id="live">
140
- <div class="head"><span class="dot live" id="liveDot"></span><b id="liveWho"></b><span id="liveWhat"></span></div>
202
+ <div class="head"><span class="spin" id="liveDot"></span><b id="liveWho"></b><span id="liveWhat"></span></div>
203
+ <div class="prog" id="liveProg" style="display:none">
204
+ <div class="bar"><i id="liveBar" style="width:0%"></i></div>
205
+ <div class="under"><span class="quip" id="liveQuip"></span><span class="sp"></span><span class="count" id="liveCount"></span><span class="elapsed" id="liveElapsed"></span></div>
206
+ </div>
141
207
  <div class="lines" id="liveLines"></div>
142
208
  </div>
143
209
  <div class="pin" id="pin">
144
210
  <video id="vid" controls playsinline></video>
145
211
  <div class="under">
146
- <span id="vidMeta"></span>
212
+ <span class="vtitle" id="vidName"></span>
213
+ <span class="vmeta" id="vidMeta"></span>
147
214
  <span class="sp"></span>
148
215
  <select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
149
216
  <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
217
  </select>
151
218
  <a id="vidDl" download>Download</a>
152
219
  <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
220
  </div>
156
- <div class="coverrow" id="coverrow"></div>
157
- <div class="stills" id="stills"></div>
221
+ <div class="frames">
222
+ <div class="fhead">
223
+ <span class="lab">Poster & frames</span>
224
+ <span class="hint">click any frame to make it the poster</span>
225
+ <span class="sp"></span>
226
+ <button class="chip" id="vidCover" title="Make the frame showing right now the poster">Use current frame</button>
227
+ </div>
228
+ <div class="coverrow" id="coverrow"></div>
229
+ <div class="stills" id="stills"></div>
230
+ </div>
158
231
  </div>
159
232
  <div class="empty" id="empty" style="display:none">
160
233
  <div class="mark"><i></i><i></i><i></i><i></i></div>
@@ -163,7 +236,7 @@
163
236
  </div>
164
237
  </div>
165
238
  <aside class="lib" id="lib" style="display:none">
166
- <h2 id="libTtl">Demos</h2>
239
+ <h2><span id="libTtl">Library</span><button class="selbtn" id="selBtn" title="Pick several to archive at once">Select</button></h2>
167
240
  <div id="demos"></div>
168
241
  </aside>
169
242
  </div>
@@ -188,6 +261,45 @@ async function setCover(body, marked) {
188
261
  } catch (e) { $('vidMeta').textContent = 'could not set the poster: ' + e.message; }
189
262
  }
190
263
 
264
+
265
+ // What's new — reads the same list the landing page does. The dot appears when
266
+ // the newest entry is one this browser has not seen, and only until it is read;
267
+ // nothing interrupts a person who is in the middle of something.
268
+ const SEEN_KEY = 'retake:seen-release';
269
+ let releases = null;
270
+ async function loadReleases() {
271
+ if (releases) return releases;
272
+ try { releases = (await api('/api/releases')).releases || []; } catch { releases = []; }
273
+ return releases;
274
+ }
275
+ (async () => {
276
+ const rs = await loadReleases();
277
+ const btn = $('newsBtn');
278
+ if (!rs.length) { btn.style.display = 'none'; return; }
279
+ if (localStorage.getItem(SEEN_KEY) !== rs[0].version) btn.insertAdjacentHTML('beforeend', '<span class="ndot"></span>');
280
+ })();
281
+ $('newsBtn').onclick = async () => {
282
+ const rs = await loadReleases();
283
+ if (!rs.length) return;
284
+ const fmt = (d) => { const x = new Date(d + 'T00:00:00'); return isNaN(x) ? '' : x.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }); };
285
+ const veil = document.createElement('div');
286
+ veil.className = 'veil';
287
+ veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3>' +
288
+ '<div class="scroll">' + rs.map((r, i) =>
289
+ '<div class="rel' + (i ? '' : ' latest') + '">' +
290
+ '<div class="relhead"><b>' + esc(r.headline) + '</b><span class="relmeta">' + esc(r.version) + (r.date ? ' · ' + fmt(r.date) : '') + '</span></div>' +
291
+ '<ul>' + (r.notes || []).map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul>' +
292
+ '</div>').join('') + '</div>' +
293
+ '<div class="row" style="margin-top:18px"><button class="btn" id="newsClose">Close</button></div></div>';
294
+ document.body.appendChild(veil);
295
+ localStorage.setItem(SEEN_KEY, rs[0].version);
296
+ $('newsBtn').querySelector('.ndot')?.remove();
297
+ const shut = () => veil.remove();
298
+ veil.querySelector('#newsClose').onclick = shut;
299
+ veil.onclick = (e) => { if (e.target === veil) shut(); };
300
+ document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { shut(); document.removeEventListener('keydown', esc2); } });
301
+ };
302
+
191
303
  document.getElementById('gremlinBtn').onclick = async () => {
192
304
  const veil = document.createElement('div');
193
305
  veil.className = 'veil';
@@ -266,7 +378,8 @@ async function pin(name) {
266
378
  }
267
379
  const t = d.take;
268
380
  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}` : ''}`;
381
+ $('vidName').textContent = name;
382
+ $('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
383
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
271
384
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
272
385
  $('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
@@ -278,15 +391,17 @@ $('vidCover').onclick = () => setCover({ at: $('vid').currentTime });
278
391
  // here, not guessed: any scene still, any frame of the take, a generated
279
392
  // cover, or the person's own image. All of it just rewrites thumbnail.png,
280
393
  // so it costs no re-render.
394
+ // One tray: generated covers first, then every scene frame, then upload —
395
+ // a single row a person scans once, left to right.
281
396
  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
397
  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>`) +
398
+ $('coverrow').innerHTML = '';
399
+ $('stills').innerHTML =
400
+ 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('') +
401
+ 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
402
  `<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);
403
+ for (const img of $('stills').querySelectorAll('img[data-still]')) img.onclick = () => setCover({ still: img.dataset.still }, img);
404
+ for (const img of $('stills').querySelectorAll('img[data-file]')) img.onclick = () => setCover({ file: img.dataset.file }, img);
290
405
  $('coverUp').onchange = (e) => {
291
406
  const f = e.target.files?.[0];
292
407
  if (!f) return;
@@ -306,7 +421,7 @@ async function drawDemos() {
306
421
  $('empty').style.display = has ? 'none' : '';
307
422
  $('lib').style.display = has ? '' : 'none';
308
423
  document.body.classList.toggle('rail', has);
309
- $('libTtl').textContent = `Demos · ${demos.length}`;
424
+ $('libTtl').textContent = `Library · ${demos.length}`;
310
425
  $('demos').innerHTML = '';
311
426
  const groups = new Map();
312
427
  for (const p of demos) { const g = p.group || 'unsorted'; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(p); }
@@ -316,7 +431,26 @@ async function drawDemos() {
316
431
  det.className = 'group';
317
432
  const holdsCurrent = items.some((x) => x.name === current);
318
433
  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>`;
434
+ 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>`;
435
+ // A whole folder in one click — with 35 demos in a handful of projects,
436
+ // ticking them one at a time is the thing this is meant to replace.
437
+ const gbox = det.querySelector('.gpick');
438
+ const names = items.map((x) => x.name);
439
+ gbox.checked = names.length > 0 && names.every((n) => picked.has(n));
440
+ // Clicking a checkbox inside <summary> also toggles the folder open/shut,
441
+ // and preventDefault would stop BOTH — including the tick, which is what
442
+ // makes "select the folder" look like it did nothing. So the fold is
443
+ // allowed to happen and then put straight back, and the tick is left
444
+ // entirely alone.
445
+ gbox.onclick = (ev) => {
446
+ ev.stopPropagation();
447
+ const wasOpen = det.open;
448
+ const all = names.every((n) => picked.has(n));
449
+ for (const n of names) all ? picked.delete(n) : picked.add(n);
450
+ for (const b of det.querySelectorAll('.demo .pick')) b.checked = !all;
451
+ drawSelBar();
452
+ setTimeout(() => { det.open = wasOpen; }, 0);
453
+ };
320
454
  det.addEventListener('toggle', () => {
321
455
  const now = [...document.querySelectorAll('details.group[open] summary b')].map((b) => b.textContent);
322
456
  localStorage.setItem('retake.openGroups', JSON.stringify(now));
@@ -326,8 +460,16 @@ async function drawDemos() {
326
460
  el.className = 'demo' + (p.name === current ? ' on' : '');
327
461
  const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
328
462
  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); };
463
+ 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>`;
464
+ const box = el.querySelector('.pick');
465
+ const syncGroup = () => { const gb = det.querySelector('.gpick'); if (gb) gb.checked = items.every((x) => picked.has(x.name)); };
466
+ box.onclick = (ev) => { ev.stopPropagation(); box.checked ? picked.add(p.name) : picked.delete(p.name); syncGroup(); drawSelBar(); };
467
+ el.onclick = () => {
468
+ // In select mode the whole row is the checkbox — hunting for a 15px
469
+ // target is exactly the fiddliness this is meant to remove.
470
+ if (selecting) { box.checked = !box.checked; box.checked ? picked.add(p.name) : picked.delete(p.name); syncGroup(); drawSelBar(); return; }
471
+ if (p.lastTake) pin(p.name);
472
+ };
331
473
  el.querySelector('.zap').onclick = async (ev) => {
332
474
  ev.stopPropagation();
333
475
  // Archive, not destroy — but moving someone's work still deserves a beat.
@@ -341,8 +483,59 @@ async function drawDemos() {
341
483
  $('demos').appendChild(det);
342
484
  }
343
485
  await drawArchive();
486
+ drawSelBar();
344
487
  }
345
488
 
489
+
490
+ // Bulk archive. Off by default: a list you can accidentally multi-select from
491
+ // is worse than one that needs a deliberate "Select" first.
492
+ let selecting = false;
493
+ const picked = new Set();
494
+
495
+ function drawSelBar() {
496
+ const old = document.querySelector('.selbar'); if (old) old.remove();
497
+ if (!selecting) return;
498
+ const bar = document.createElement('div');
499
+ bar.className = 'selbar';
500
+ const n = picked.size;
501
+ bar.innerHTML = `<small>${n ? n + ' selected' : 'none selected'}</small><span class="sp"></span>` +
502
+ `<button id="selAll">${n === demos.length ? 'None' : 'All'}</button>` +
503
+ `<button class="go" id="selGo"${n ? '' : ' disabled'}>Archive${n ? ' ' + n : ''}</button>`;
504
+ $('lib').appendChild(bar);
505
+ bar.querySelector('#selAll').onclick = () => {
506
+ if (picked.size === demos.length) picked.clear(); else for (const d of demos) picked.add(d.name);
507
+ for (const b of document.querySelectorAll('.demo .pick')) b.checked = picked.has(b.parentElement.querySelector('.nm').textContent);
508
+ drawSelBar();
509
+ };
510
+ bar.querySelector('#selGo').onclick = async () => {
511
+ const names = [...picked];
512
+ if (!names.length) return;
513
+ if (!(await ask(`Archive ${names.length} demo${names.length === 1 ? '' : 's'}?`,
514
+ names.slice(0, 8).join(', ') + (names.length > 8 ? ` and ${names.length - 8} more` : '') +
515
+ '. Their videos are removed. The demos move to the archive below and can be restored any time.', 'Archive'))) return;
516
+ const failed = [];
517
+ for (const nm of names) {
518
+ try { await api('/api/demos/' + nm, { method: 'DELETE' }); } catch (e) { failed.push(nm + ' (' + e.message + ')'); }
519
+ }
520
+ if (current && picked.has(current)) { current = null; $('pin').classList.remove('show'); }
521
+ picked.clear();
522
+ selecting = false;
523
+ document.body.classList.remove('selecting');
524
+ $('selBtn').classList.remove('on');
525
+ await drawDemos();
526
+ if (failed.length) alert('Could not archive:\n' + failed.join('\n'));
527
+ };
528
+ }
529
+
530
+ $('selBtn').onclick = () => {
531
+ selecting = !selecting;
532
+ picked.clear();
533
+ document.body.classList.toggle('selecting', selecting);
534
+ $('selBtn').classList.toggle('on', selecting);
535
+ $('selBtn').textContent = selecting ? 'Done' : 'Select';
536
+ drawDemos();
537
+ };
538
+
346
539
  async function drawArchive() {
347
540
  let items = [];
348
541
  try { items = await api('/api/trash'); } catch { return; }
@@ -350,7 +543,7 @@ async function drawArchive() {
350
543
  if (!items.length) return;
351
544
  const det = document.createElement('details');
352
545
  det.className = 'group arch';
353
- det.innerHTML = `<summary><span class="tri">▶</span><b>Archive</b><small>${items.length}</small></summary>`;
546
+ 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
547
  for (const t of items) {
355
548
  const el = document.createElement('div');
356
549
  el.className = 'row2';
@@ -360,12 +553,15 @@ async function drawArchive() {
360
553
  }
361
554
  const clear = document.createElement('button');
362
555
  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;
556
+ clear.textContent = `Empty the archive — delete ${items.length} demo${items.length === 1 ? '' : 's'} for good`;
557
+ const empty = async (ev) => {
558
+ if (ev) { ev.stopPropagation(); ev.preventDefault(); } // the summary must not fold
559
+ 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
560
  await api('/api/trash/clear', { method: 'POST' });
367
561
  await drawDemos();
368
562
  };
563
+ clear.onclick = empty;
564
+ det.querySelector('#emptyArch').onclick = empty;
369
565
  det.appendChild(clear);
370
566
  $('demos').appendChild(det);
371
567
  }
@@ -434,14 +630,107 @@ function askSecrets(r) {
434
630
 
435
631
  // ---- the live feed: an agent working shows up here as it works -------------
436
632
  function watchActivity() {
633
+
634
+ // While a take runs there is nothing to look at and a lot of time to look at
635
+ // it. A bar for how far in, a clock for how long, and something to read that
636
+ // is not a spinner. Recording-flavoured, because that is what is happening,
637
+ // and none of them claim progress they do not have.
638
+ const QUIPS = [
639
+ 'Rolling.', 'Lining up the shot.', 'Waiting on your app.',
640
+ 'Not fumbling the take.', 'Holding the frame.', 'Letting the app think.',
641
+ 'Clicking things, patiently.', 'Doing the boring part.',
642
+ 'Take one. Also the only take.', 'Typing at a speed people can read.',
643
+ 'Watching the spinner so you do not have to.', 'No sighing this time.',
644
+ 'Getting it right the first time, on purpose.',
645
+ ];
646
+ let quipAt = 0, quipTimer = null, elapsedTimer = null, runStart = 0, lastActivity = null;
647
+
648
+ function fmtElapsed(ms) {
649
+ const t = Math.max(0, Math.round(ms / 1000));
650
+ return t < 60 ? t + 's' : Math.floor(t / 60) + 'm ' + String(t % 60).padStart(2, '0') + 's';
651
+ }
652
+
653
+ function startLiveTickers(startedAt) {
654
+ const want = startedAt || Date.now();
655
+ if (runStart !== want) runStart = want; // a new run restarts the clock
656
+ if (!quipTimer) {
657
+ // Slow enough to read, and it only moves while something is happening.
658
+ quipTimer = setInterval(() => {
659
+ quipAt = (quipAt + 1) % QUIPS.length;
660
+ const q = $('liveQuip'); if (q) q.textContent = QUIPS[quipAt];
661
+ }, 4200);
662
+ }
663
+ if (!elapsedTimer) {
664
+ elapsedTimer = setInterval(() => {
665
+ const e = $('liveElapsed'); if (e) e.textContent = fmtElapsed(Date.now() - runStart);
666
+ // No events arrive while ffmpeg works, so the render bar advances on
667
+ // the clock or it would look frozen for the whole render.
668
+ if (lastActivity && lastActivity.progress && lastActivity.progress.phase === 'rendering') drawProgress(lastActivity);
669
+ }, 1000);
670
+ }
671
+ }
672
+ function stopLiveTickers() {
673
+ clearInterval(quipTimer); clearInterval(elapsedTimer);
674
+ quipTimer = elapsedTimer = null;
675
+ }
676
+
677
+ function drawProgress(a) {
678
+ const box = $('liveProg'); if (!box) return;
679
+ if (a) lastActivity = a;
680
+ const p = a && a.progress;
681
+ if (!a || !a.active) { box.style.display = 'none'; stopLiveTickers(); return; }
682
+ // Nothing has reported in five minutes: the run is over, or whatever was
683
+ // running is gone. Either way the bar is lying, so stop showing it.
684
+ if (p && p.since && Date.now() - p.since > 5 * 60_000) { box.style.display = 'none'; stopLiveTickers(); return; }
685
+ if (!p) { box.style.display = 'none'; stopLiveTickers(); return; }
686
+ // Measure THIS run, not the activity feed. The feed goes active on the
687
+ // first line and stays that way, so a window left open all day reported a
688
+ // four-minute take as 529 minutes.
689
+ startLiveTickers(p.runStart || a.startedAt);
690
+ if (!$('liveQuip').textContent) $('liveQuip').textContent = QUIPS[0];
691
+ $('liveElapsed').textContent = fmtElapsed(Date.now() - runStart);
692
+ // Rendering: there are no steps to count, but the last render of this demo
693
+ // measured itself, so the bar can run on that. Capped at 95% — it must not
694
+ // claim finished before it is, and an estimate is never a promise.
695
+ if (p && p.phase === 'rendering') {
696
+ box.style.display = '';
697
+ const eta = (p.etaSec || 0) * 1000;
698
+ const gone = Date.now() - (p.since || Date.now());
699
+ $('liveBar').style.width = (eta ? Math.min(95, Math.round((gone / eta) * 100)) : 0) + '%';
700
+ const left = Math.max(0, Math.round((eta - gone) / 1000));
701
+ $('liveCount').textContent = eta
702
+ ? (left > 0 ? `rendering · about ${left}s left` : 'rendering · nearly there')
703
+ : 'rendering';
704
+ return;
705
+ }
706
+ if (!p || !p.total) {
707
+ // Working, but nothing countable yet — show the clock, not a fake bar.
708
+ box.style.display = '';
709
+ $('liveBar').style.width = '0%';
710
+ $('liveCount').textContent = '';
711
+ return;
712
+ }
713
+ box.style.display = '';
714
+ // The last STEP is not the end of the take: the final hold still has to
715
+ // play, the video still has to be finalised and its dead tail trimmed. A
716
+ // bar sitting at 100% while the thing is visibly still going is the kind
717
+ // of small lie that makes someone stop trusting the rest of the window.
718
+ const pct = Math.min(96, Math.round((p.step / p.total) * 96));
719
+ $('liveBar').style.width = pct + '%';
720
+ $('liveCount').textContent = `step ${p.step} of ${p.total}`;
721
+ }
722
+
437
723
  const es = new EventSource('/api/activity/stream');
438
724
  const apply = (a) => {
439
725
  if (!a || (!a.active && !a.lines?.length)) return;
440
726
  $('live').classList.add('show');
441
- $('liveDot').className = 'dot ' + (a.active ? 'live' : 'ok');
727
+ // 'spin' has to survive: this line used to replace it wholesale, so the
728
+ // spinner existed in the markup and never once appeared.
729
+ $('liveDot').className = a.active ? 'spin' : 'dot ok';
442
730
  $('liveWho').textContent = a.active ? (a.who || 'your agent') : 'Done';
443
731
  $('liveWhat').textContent = a.demo ? `· ${a.demo}` : '';
444
732
  $('liveLines').innerHTML = (a.lines || []).slice(-4).map((l) => `<div>${esc(l)}</div>`).join('');
733
+ drawProgress(a);
445
734
  };
446
735
  es.addEventListener('state', (ev) => {
447
736
  const d = JSON.parse(ev.data);
@@ -452,6 +741,7 @@ function watchActivity() {
452
741
  for (const r of d.secrets || []) askSecrets(r);
453
742
  });
454
743
  es.addEventListener('line', (ev) => apply(JSON.parse(ev.data)));
744
+ es.addEventListener('progress', (ev) => apply(JSON.parse(ev.data)));
455
745
  es.addEventListener('secrets', (ev) => askSecrets(JSON.parse(ev.data)));
456
746
  es.addEventListener('finished', async (ev) => { apply(JSON.parse(ev.data)); await drawDemos(); await newest(); setTimeout(() => $('live').classList.remove('show'), 8000); });
457
747
  es.onerror = () => { es.close(); setTimeout(watchActivity, 3000); };