retake-demos 0.2.2 → 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/tidy.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Reclaiming disk without losing anything you cannot get back.
3
+ *
4
+ * Measured across 37 real demos: 506 MB of outputs, of which 345 MB was
5
+ * DERIVED — master.mp4, demo.mp4, stills, thumbnails and gifs all re-render
6
+ * from `take.json` plus the raw recording, in seconds. Only the raw `.webm`
7
+ * and `take.json` are irreplaceable: lose those and the demo has to be
8
+ * performed again, which is the one cost this tool exists to avoid.
9
+ *
10
+ * So tidy never touches those two, reports before it removes anything, and
11
+ * separates "safe, re-renders in seconds" from "the whole take is rubbish".
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ const bytesOf = (p) => {
16
+ try {
17
+ const st = fs.statSync(p);
18
+ if (!st.isDirectory())
19
+ return st.size;
20
+ let n = 0;
21
+ for (const f of fs.readdirSync(p))
22
+ n += bytesOf(path.join(p, f));
23
+ return n;
24
+ }
25
+ catch {
26
+ return 0;
27
+ }
28
+ };
29
+ export const mb = (n) => `${(n / 1e6).toFixed(0)} MB`;
30
+ /** Derived files: every one of these comes back from a re-render. */
31
+ const DERIVED = ["master.mp4", "thumbnail.png", "cover.png", "cover-titled.png", "contact.png", "stills"];
32
+ const DERIVED_RE = [/\.gif$/, /^\.card-.*\.mp4$/, /^\.callout-.*\.webm$/, /^\.paced\.mp4$/, /^\.joined\.mp4$/, /^debug-filter\.txt$/, /^stalled-\d+\.png$/, /^dry-failed-\d+\.png$/, /^failed-step\.png$/, /^not-the-app\.png$/];
33
+ export function planTidy(outRoot, o) {
34
+ const groups = [];
35
+ const add = (what, why, files) => {
36
+ if (!files.length)
37
+ return;
38
+ groups.push({ what, why, files, bytes: files.reduce((n, f) => n + bytesOf(f), 0) });
39
+ };
40
+ if (!fs.existsSync(outRoot))
41
+ return { groups: [], bytes: 0, keptBytes: 0 };
42
+ const failed = [], orphaned = [], derived = [], deliverables = [];
43
+ let keptBytes = 0;
44
+ for (const name of fs.readdirSync(outRoot)) {
45
+ const dir = path.join(outRoot, name);
46
+ if (!fs.statSync(dir).isDirectory())
47
+ continue;
48
+ const takePath = path.join(dir, "take.json");
49
+ const hasManifest = fs.existsSync(path.join(o.demosDir, `${name}.yaml`)) || fs.existsSync(path.join(o.demosDir, `${name}.json`));
50
+ // A whole folder with no manifest cannot be re-rendered or re-recorded
51
+ // from anything in this workspace — it is a leftover, not an asset.
52
+ if (!hasManifest) {
53
+ orphaned.push(dir);
54
+ continue;
55
+ }
56
+ let take = null;
57
+ try {
58
+ take = JSON.parse(fs.readFileSync(takePath, "utf8"));
59
+ }
60
+ catch { /* no take */ }
61
+ // A take whose steps failed is not something anyone goes back to.
62
+ if (take && take.ok === false) {
63
+ failed.push(dir);
64
+ continue;
65
+ }
66
+ for (const f of fs.readdirSync(dir)) {
67
+ const full = path.join(dir, f);
68
+ if (f === "take.json" || f.endsWith(".webm") || f === ".retake-lock" || f === ".poster" || f === ".history" || f === "manifest.used.yaml" || f === "proof-log.md" || f === "facts.json") {
69
+ keptBytes += bytesOf(full);
70
+ continue;
71
+ }
72
+ if (f === "demo.mp4") {
73
+ (o.deep ? deliverables : null)?.push(full);
74
+ if (!o.deep)
75
+ keptBytes += bytesOf(full);
76
+ continue;
77
+ }
78
+ if (DERIVED.includes(f) || DERIVED_RE.some((re) => re.test(f))) {
79
+ derived.push(full);
80
+ continue;
81
+ }
82
+ keptBytes += bytesOf(full);
83
+ }
84
+ }
85
+ add("re-renderable", "master, stills, thumbnails, gifs, working files — all come back from `retake render`, in seconds", derived);
86
+ add("failed takes", "their steps did not pass; nobody goes back to a broken recording", failed);
87
+ if (o.orphans)
88
+ add("orphaned folders", "no manifest in demos/ — nothing here can be re-recorded or re-rendered", orphaned);
89
+ if (o.deep)
90
+ add("deliverables", "demo.mp4 — re-renders from the recording, but it IS the file you hand over", deliverables);
91
+ const bytes = groups.reduce((n, g) => n + g.bytes, 0);
92
+ return { groups, bytes, keptBytes };
93
+ }
94
+ export function applyTidy(plan) {
95
+ let freed = 0;
96
+ for (const g of plan.groups) {
97
+ for (const f of g.files) {
98
+ freed += bytesOf(f);
99
+ fs.rmSync(f, { recursive: true, force: true });
100
+ }
101
+ }
102
+ return freed;
103
+ }
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 */
@@ -56,39 +56,60 @@
56
56
  /* the video */
57
57
  .pin { display:none; }
58
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; }
59
+ /* The element takes the video's own shape: width follows the height cap, so
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: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; }
62
66
  .under a:hover, .under button:hover { border-color:var(--ink); }
63
67
  /* A native select with its arrow removed leaves lopsided padding; make it
64
68
  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; }
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; }
67
71
  .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; }
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; }
70
80
  .stills img:hover { border-color:var(--ink); }
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; }
85
+ .coverrow img:hover { border-color:var(--ink); }
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; }
88
+ .coverrow label.up:hover { border-color:var(--ink); color:var(--ink); }
71
89
  /* 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; }
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; }
74
92
  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; }
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; }
76
94
  details.group summary::-webkit-details-marker { display:none; }
77
95
  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; }
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; }
83
104
  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; }
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; }
88
109
  .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; }
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; }
92
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; }
93
114
  .demo .zap:hover { background:#f3e9e6; }
94
115
  .demo:hover .zap { visibility:visible; }
@@ -114,6 +135,42 @@
114
135
  .empty h2 { color:var(--ink); font-size:17px; margin:0 0 6px; }
115
136
  .empty p { margin:0; font-size:14px; }
116
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; }
117
174
  </style>
118
175
  </head>
119
176
  <body>
@@ -123,6 +180,7 @@
123
180
  <h1>Retake</h1>
124
181
  <span class="sp"></span>
125
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>
126
184
  <button class="chip" id="gremlinBtn" title="Something's off? Tell me.">Bug?</button>
127
185
  </div>
128
186
  <div class="wrap two">
@@ -134,16 +192,25 @@
134
192
  <div class="pin" id="pin">
135
193
  <video id="vid" controls playsinline></video>
136
194
  <div class="under">
137
- <span id="vidMeta"></span>
195
+ <span class="vtitle" id="vidName"></span>
196
+ <span class="vmeta" id="vidMeta"></span>
138
197
  <span class="sp"></span>
139
198
  <select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
140
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>
141
200
  </select>
142
201
  <a id="vidDl" download>Download</a>
143
202
  <button id="vidFolder">Show in folder</button>
144
- <!-- The workshop (/classic) still exists but is not advertised until it has been tested as carefully as this page. -->
145
203
  </div>
146
- <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>
147
214
  </div>
148
215
  <div class="empty" id="empty" style="display:none">
149
216
  <div class="mark"><i></i><i></i><i></i><i></i></div>
@@ -152,7 +219,7 @@
152
219
  </div>
153
220
  </div>
154
221
  <aside class="lib" id="lib" style="display:none">
155
- <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>
156
223
  <div id="demos"></div>
157
224
  </aside>
158
225
  </div>
@@ -164,6 +231,58 @@ let current = null, demos = [], settings = null;
164
231
  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
232
 
166
233
  const REPO = 'https://github.com/glebbogachev00/retake';
234
+ async function setCover(body, marked) {
235
+ try {
236
+ await api('/api/cover/' + current, { method: 'POST', body: JSON.stringify(body) });
237
+ // Cache-bust: the poster keeps its filename, so the browser needs telling.
238
+ const v = '?v=' + Date.now();
239
+ $('vid').poster = `/out/${current}/thumbnail.png` + v;
240
+ for (const i of document.querySelectorAll('.stills img, .coverrow img')) i.classList.remove('poster');
241
+ if (marked) marked.classList.add('poster');
242
+ const meta = $('vidMeta').textContent;
243
+ $('vidMeta').textContent = meta.replace(/ · poster set$/, '') + ' · poster set';
244
+ } catch (e) { $('vidMeta').textContent = 'could not set the poster: ' + e.message; }
245
+ }
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
+
167
286
  document.getElementById('gremlinBtn').onclick = async () => {
168
287
  const veil = document.createElement('div');
169
288
  veil.className = 'veil';
@@ -242,16 +361,37 @@ async function pin(name) {
242
361
  }
243
362
  const t = d.take;
244
363
  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}` : ''}`;
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}` : ''}`;
246
366
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
247
367
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
248
368
  $('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
369
+ $('vidCover').onclick = () => setCover({ at: $('vid').currentTime });
249
370
  const tempo = (demos.find((x) => x.name === name)?.settings?.tempo) || 1;
250
371
  $('vidSpeed').value = String(tempo);
251
372
  $('vid').playbackRate = 1; // the file already carries its tempo
373
+ // The poster is the first thing anyone sees of a video, so it is chosen
374
+ // here, not guessed: any scene still, any frame of the take, a generated
375
+ // cover, or the person's own image. All of it just rewrites thumbnail.png,
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.
252
379
  const st = (d.files || []).filter((f) => f.startsWith('stills/')).sort();
253
- $('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="${esc(f.slice(7, -4))}" loading="lazy">`).join('');
254
- for (const img of $('stills').querySelectorAll('img')) img.onclick = () => window.open(img.src, '_blank');
380
+ const covers = (d.files || []).filter((f) => f === 'cover.png' || f === 'cover-titled.png');
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('') +
385
+ `<label class="up">Upload an image<input type="file" accept="image/png,image/jpeg,image/webp" hidden id="coverUp"></label>`;
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);
388
+ $('coverUp').onchange = (e) => {
389
+ const f = e.target.files?.[0];
390
+ if (!f) return;
391
+ const r = new FileReader();
392
+ r.onload = () => setCover({ dataUrl: r.result });
393
+ r.readAsDataURL(f);
394
+ };
255
395
  $('pin').classList.add('show');
256
396
  drawDemos();
257
397
  return true;
@@ -264,7 +404,7 @@ async function drawDemos() {
264
404
  $('empty').style.display = has ? 'none' : '';
265
405
  $('lib').style.display = has ? '' : 'none';
266
406
  document.body.classList.toggle('rail', has);
267
- $('libTtl').textContent = `Demos · ${demos.length}`;
407
+ $('libTtl').textContent = `Library · ${demos.length}`;
268
408
  $('demos').innerHTML = '';
269
409
  const groups = new Map();
270
410
  for (const p of demos) { const g = p.group || 'unsorted'; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(p); }
@@ -274,7 +414,26 @@ async function drawDemos() {
274
414
  det.className = 'group';
275
415
  const holdsCurrent = items.some((x) => x.name === current);
276
416
  if (open.has(g) || groups.size === 1 || holdsCurrent) det.open = true;
277
- 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
+ };
278
437
  det.addEventListener('toggle', () => {
279
438
  const now = [...document.querySelectorAll('details.group[open] summary b')].map((b) => b.textContent);
280
439
  localStorage.setItem('retake.openGroups', JSON.stringify(now));
@@ -284,8 +443,16 @@ async function drawDemos() {
284
443
  el.className = 'demo' + (p.name === current ? ' on' : '');
285
444
  const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
286
445
  const when = p.lastTake ? new Date(p.lastTake.finishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'no take yet';
287
- 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>`;
288
- 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
+ };
289
456
  el.querySelector('.zap').onclick = async (ev) => {
290
457
  ev.stopPropagation();
291
458
  // Archive, not destroy — but moving someone's work still deserves a beat.
@@ -299,8 +466,59 @@ async function drawDemos() {
299
466
  $('demos').appendChild(det);
300
467
  }
301
468
  await drawArchive();
469
+ drawSelBar();
302
470
  }
303
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
+
304
522
  async function drawArchive() {
305
523
  let items = [];
306
524
  try { items = await api('/api/trash'); } catch { return; }
@@ -308,7 +526,7 @@ async function drawArchive() {
308
526
  if (!items.length) return;
309
527
  const det = document.createElement('details');
310
528
  det.className = 'group arch';
311
- 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>`;
312
530
  for (const t of items) {
313
531
  const el = document.createElement('div');
314
532
  el.className = 'row2';
@@ -318,12 +536,15 @@ async function drawArchive() {
318
536
  }
319
537
  const clear = document.createElement('button');
320
538
  clear.className = 'clear';
321
- clear.textContent = 'Clear archive — gone for good';
322
- clear.onclick = async () => {
323
- 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;
324
543
  await api('/api/trash/clear', { method: 'POST' });
325
544
  await drawDemos();
326
545
  };
546
+ clear.onclick = empty;
547
+ det.querySelector('#emptyArch').onclick = empty;
327
548
  det.appendChild(clear);
328
549
  $('demos').appendChild(det);
329
550
  }
@@ -355,6 +576,41 @@ $('vidSpeed').addEventListener('change', async () => {
355
576
  } catch (e) { $('vidMeta').textContent = `${current} · could not re-render: ${e.message}`; }
356
577
  });
357
578
 
579
+ // ---- secrets: the agent asks by name; the value goes to .env, never to it ----
580
+ const secretOpen = new Set();
581
+ function askSecrets(r) {
582
+ if (!r || secretOpen.has(r.id)) return;
583
+ secretOpen.add(r.id);
584
+ const isSecret = (n) => /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n);
585
+ const veil = document.createElement('div');
586
+ veil.className = 'veil';
587
+ veil.innerHTML = `<div class="modal" role="dialog" aria-modal="true" style="width:min(92vw,460px)">
588
+ <h3>Your agent needs a sign-in</h3>
589
+ <p>${esc(r.why || 'The demo starts behind a login.')}</p>
590
+ <p style="font-size:13px;color:var(--muted)"><b>This stays on your computer.</b> What you type is written to one local file — <code>${esc(settings?.envPath || '.env')}</code>, readable by you only — so Retake can sign in during recording. It is not sent to the agent, to any server, or to us; the agent only learns that the names are set. Use a <b>demo account</b>: the result is a video.</p>
591
+ <form id="secForm" autocomplete="off">
592
+ ${r.names.map((n) => `<label style="display:block;margin:10px 0 4px;font-size:12.5px;letter-spacing:.04em;color:var(--muted)">${esc(n)}</label>
593
+ <input name="${esc(n)}" type="${isSecret(n) ? 'password' : 'text'}" autocomplete="${isSecret(n) ? 'new-password' : 'off'}" style="width:100%;border:1px solid var(--rule2);border-radius:10px;padding:10px 12px;font:inherit" />`).join('')}
594
+ <div class="row" style="margin-top:16px">
595
+ <button class="btn" type="button" data-no>Not now</button>
596
+ <button class="btn" type="submit" style="background:var(--accent);border-color:var(--accent);color:#fff">Save to .env</button>
597
+ </div>
598
+ </form>
599
+ </div>`;
600
+ const close = () => { veil.remove(); secretOpen.delete(r.id); };
601
+ veil.querySelector('[data-no]').onclick = close;
602
+ veil.querySelector('#secForm').onsubmit = async (e) => {
603
+ e.preventDefault();
604
+ const values = {};
605
+ for (const n of r.names) { const v = veil.querySelector(`input[name="${n}"]`).value; if (v.trim()) values[n] = v; }
606
+ if (!Object.keys(values).length) return;
607
+ try { await api('/api/secrets/fill', { method: 'POST', body: JSON.stringify({ id: r.id, values }) }); close(); }
608
+ catch (err) { veil.querySelector('h3').textContent = 'Could not save: ' + err.message; }
609
+ };
610
+ document.body.appendChild(veil);
611
+ setTimeout(() => veil.querySelector('input')?.focus(), 40);
612
+ }
613
+
358
614
  // ---- the live feed: an agent working shows up here as it works -------------
359
615
  function watchActivity() {
360
616
  const es = new EventSource('/api/activity/stream');
@@ -372,8 +628,10 @@ function watchActivity() {
372
628
  if (window.__boot && d.boot && d.boot !== window.__boot) location.reload();
373
629
  window.__boot = d.boot || window.__boot;
374
630
  apply(d);
631
+ for (const r of d.secrets || []) askSecrets(r);
375
632
  });
376
633
  es.addEventListener('line', (ev) => apply(JSON.parse(ev.data)));
634
+ es.addEventListener('secrets', (ev) => askSecrets(JSON.parse(ev.data)));
377
635
  es.addEventListener('finished', async (ev) => { apply(JSON.parse(ev.data)); await drawDemos(); await newest(); setTimeout(() => $('live').classList.remove('show'), 8000); });
378
636
  es.onerror = () => { es.close(); setTimeout(watchActivity, 3000); };
379
637
  }
@@ -75,8 +75,9 @@
75
75
  <section>
76
76
  <h3>Good to know</h3>
77
77
  <ul>
78
+ <li><b>Two kinds of take.</b> A <i>demo</i> proves how something works — a client walkthrough, a clip for a pull request, a lesson — and keeps everything but your product out of the frame. That is the default. A <i>launch</i> video presents your product in public, and only then do a title card, music and a closing card belong. Your agent asks where it is going; if you shrug, it makes a demo.</li>
78
79
  <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><b>Apps behind a login:</b> when the agent needs a sign-in, a form appears here asking for a demo account. What you type is written to one local file (<code>.env</code> in your workspace) and never sent to the agent or anywhere else. The sign-in runs before the camera and is cut off; the session is kept, so later takes skip it. For codes from your phone, SSO or a captcha: <code>retake signin demos/&lt;name&gt;.yaml</code> — log in once by hand, Retake keeps the session.</li>
80
81
  <li>A failed step names what was on screen at the time — read the proof log before guessing.</li>
81
82
  </ul>
82
83
  </section>