@agent-native/core 0.70.3 → 0.71.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.
Files changed (79) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/agent/durable-background.ts +192 -0
  7. package/corpus/core/src/agent/production-agent.ts +330 -13
  8. package/corpus/core/src/agent/run-manager.ts +66 -3
  9. package/corpus/core/src/agent/run-store.ts +129 -23
  10. package/corpus/core/src/agent/types.ts +20 -0
  11. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  12. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  13. package/corpus/core/src/deploy/build.ts +96 -0
  14. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  15. package/corpus/core/src/mcp/build-server.ts +22 -13
  16. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  17. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  18. package/corpus/templates/analytics/AGENTS.md +8 -0
  19. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  20. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  21. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  22. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  23. package/corpus/templates/clips/chrome-extension/src/background.ts +130 -32
  24. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  25. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +195 -34
  26. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  27. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  28. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  29. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  30. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  31. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  32. package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
  33. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  34. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  35. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  36. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  37. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  38. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  39. package/dist/agent/durable-background.d.ts +60 -0
  40. package/dist/agent/durable-background.d.ts.map +1 -0
  41. package/dist/agent/durable-background.js +144 -0
  42. package/dist/agent/durable-background.js.map +1 -0
  43. package/dist/agent/production-agent.d.ts +20 -0
  44. package/dist/agent/production-agent.d.ts.map +1 -1
  45. package/dist/agent/production-agent.js +292 -14
  46. package/dist/agent/production-agent.js.map +1 -1
  47. package/dist/agent/run-manager.d.ts +48 -0
  48. package/dist/agent/run-manager.d.ts.map +1 -1
  49. package/dist/agent/run-manager.js +45 -3
  50. package/dist/agent/run-manager.js.map +1 -1
  51. package/dist/agent/run-store.d.ts +30 -1
  52. package/dist/agent/run-store.d.ts.map +1 -1
  53. package/dist/agent/run-store.js +124 -24
  54. package/dist/agent/run-store.js.map +1 -1
  55. package/dist/agent/types.d.ts +20 -0
  56. package/dist/agent/types.d.ts.map +1 -1
  57. package/dist/agent/types.js.map +1 -1
  58. package/dist/client/AgentPanel.d.ts.map +1 -1
  59. package/dist/client/AgentPanel.js +2 -2
  60. package/dist/client/AgentPanel.js.map +1 -1
  61. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  62. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  63. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  64. package/dist/deploy/build.d.ts +29 -0
  65. package/dist/deploy/build.d.ts.map +1 -1
  66. package/dist/deploy/build.js +85 -0
  67. package/dist/deploy/build.js.map +1 -1
  68. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  69. package/dist/deploy/workspace-deploy.js +108 -0
  70. package/dist/deploy/workspace-deploy.js.map +1 -1
  71. package/dist/mcp/build-server.d.ts.map +1 -1
  72. package/dist/mcp/build-server.js +23 -9
  73. package/dist/mcp/build-server.js.map +1 -1
  74. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  75. package/dist/server/agent-chat-plugin.js +68 -13
  76. package/dist/server/agent-chat-plugin.js.map +1 -1
  77. package/docs/content/multi-app-workspace.md +2 -2
  78. package/docs/design/durable-agent-runs.md +458 -4
  79. package/package.json +1 -1
@@ -3,11 +3,11 @@
3
3
  // This document holds the getDisplayMedia()/getUserMedia() stream and the
4
4
  // MediaRecorder. Living in an offscreen document (reason DISPLAY_MEDIA) is what
5
5
  // lets a recording survive page navigations — the capture is decoupled from any
6
- // tab. The camera bubble and controls are rendered as on-page overlays by the
7
- // content script, so we no longer composite the camera into a canvas here; for
8
- // screen modes we record the display stream directly (+ mixed mic audio), and
9
- // the bubble shows up in the recording naturally when a full screen/window/tab
10
- // surface that contains it is captured.
6
+ // tab. Controls render as on-page overlays. For SCREEN+CAMERA we capture the
7
+ // camera HERE and composite it (canvas) into the recording, because the on-page
8
+ // bubble can't get the camera on pages that send `Permissions-Policy: camera=()`
9
+ // (an iframe can't escape its parent's policy). Screen-only records the display
10
+ // directly; camera-only records the webcam directly.
11
11
  //
12
12
  // Lifecycle: ACQUIRE (show picker, hold stream) → BEGIN (start recorder after
13
13
  // the countdown) → PAUSE/RESUME → STOP/CANCEL, plus RESTART (discard and start
@@ -21,6 +21,9 @@ type AcquireMessage = {
21
21
  mode: CaptureMode;
22
22
  surface: "browser" | "window" | "monitor";
23
23
  includeMicrophone: boolean;
24
+ // Screen+camera: capture the camera here and composite it into the recording
25
+ // (the on-page bubble can be blocked by the page's Permissions-Policy).
26
+ includeCamera: boolean;
24
27
  };
25
28
 
26
29
  type BeginMessage = {
@@ -94,6 +97,13 @@ type ActiveRecording = {
94
97
  restarting: boolean;
95
98
  // Pending pre-roll timer; non-null means the recorder hasn't started yet.
96
99
  startTimer: ReturnType<typeof setTimeout> | null;
100
+ // Canvas compositor draw loop (screen+camera only); stop it on teardown.
101
+ stopCompositor: (() => void) | null;
102
+ // Original capture streams, kept distinct so restart can re-home exactly the
103
+ // right ones (sourceStreams also holds the derived canvas stream).
104
+ displaySource: MediaStream | null;
105
+ cameraSource: MediaStream | null;
106
+ micSource: MediaStream | null;
97
107
  dimensions: { width: number; height: number };
98
108
  hasAudio: boolean;
99
109
  hasCamera: boolean;
@@ -194,22 +204,140 @@ function displayConstraints(
194
204
  } as MediaStreamConstraints;
195
205
  }
196
206
 
197
- async function getMicStream(): Promise<MediaStream | null> {
207
+ // The user's chosen camera/mic devices (set in the popup, saved to storage).
208
+ async function readDeviceIds(): Promise<{ video: string; audio: string }> {
198
209
  try {
199
- return await navigator.mediaDevices.getUserMedia({
200
- audio: {
201
- echoCancellation: true,
202
- noiseSuppression: true,
203
- autoGainControl: true,
204
- channelCount: 1,
205
- },
206
- video: false,
207
- });
210
+ const v = await chrome.storage.sync.get(["videoDeviceId", "audioDeviceId"]);
211
+ return {
212
+ video: typeof v.videoDeviceId === "string" ? v.videoDeviceId : "",
213
+ audio: typeof v.audioDeviceId === "string" ? v.audioDeviceId : "",
214
+ };
215
+ } catch {
216
+ return { video: "", audio: "" };
217
+ }
218
+ }
219
+
220
+ function cameraConstraints(deviceId: string): MediaTrackConstraints {
221
+ const base: MediaTrackConstraints = {
222
+ width: { ideal: 1280 },
223
+ height: { ideal: 720 },
224
+ };
225
+ if (deviceId) base.deviceId = { exact: deviceId };
226
+ else base.facingMode = "user";
227
+ return base;
228
+ }
229
+
230
+ async function getMicStream(deviceId: string): Promise<MediaStream | null> {
231
+ const audio: MediaTrackConstraints = {
232
+ echoCancellation: true,
233
+ noiseSuppression: true,
234
+ autoGainControl: true,
235
+ channelCount: 1,
236
+ };
237
+ if (deviceId) audio.deviceId = { exact: deviceId };
238
+ try {
239
+ return await navigator.mediaDevices.getUserMedia({ audio, video: false });
208
240
  } catch {
209
241
  return null;
210
242
  }
211
243
  }
212
244
 
245
+ /* ----------------------------------------------------- camera compositing --- */
246
+
247
+ // On many pages the on-page camera bubble can't run (the page sets
248
+ // `Permissions-Policy: camera=()`, which an iframe cannot escape). So for
249
+ // screen+camera we capture the camera HERE in the offscreen document (extension
250
+ // origin — always allowed) and draw it into a canvas on top of the screen, then
251
+ // record the canvas. The face ends up in the video on every page.
252
+
253
+ async function readyVideo(stream: MediaStream): Promise<HTMLVideoElement> {
254
+ const video = document.createElement("video");
255
+ video.muted = true;
256
+ video.playsInline = true;
257
+ video.srcObject = stream;
258
+ await waitForMetadata(video).catch(() => undefined);
259
+ await video.play().catch(() => undefined);
260
+ return video;
261
+ }
262
+
263
+ function drawCameraBubble(
264
+ ctx: CanvasRenderingContext2D,
265
+ camera: HTMLVideoElement,
266
+ canvas: HTMLCanvasElement,
267
+ ): void {
268
+ const size = Math.round(Math.min(canvas.width, canvas.height) * 0.22);
269
+ const margin = Math.round(Math.min(canvas.width, canvas.height) * 0.035);
270
+ const cx = margin + size / 2;
271
+ const cy = canvas.height - margin - size / 2;
272
+ const r = size / 2;
273
+ const vw = camera.videoWidth || size;
274
+ const vh = camera.videoHeight || size;
275
+ const scale = Math.max(size / vw, size / vh);
276
+ const dw = vw * scale;
277
+ const dh = vh * scale;
278
+
279
+ ctx.save();
280
+ ctx.beginPath();
281
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
282
+ ctx.closePath();
283
+ ctx.clip();
284
+ // Mirror the camera horizontally to match how people expect to see themselves.
285
+ ctx.translate(cx, cy);
286
+ ctx.scale(-1, 1);
287
+ ctx.drawImage(camera, -dw / 2, -dh / 2, dw, dh);
288
+ ctx.restore();
289
+
290
+ ctx.save();
291
+ ctx.beginPath();
292
+ ctx.arc(cx, cy, r - 1, 0, Math.PI * 2);
293
+ ctx.lineWidth = Math.max(3, Math.round(size * 0.03));
294
+ ctx.strokeStyle = "rgba(255,255,255,0.92)";
295
+ ctx.stroke();
296
+ ctx.restore();
297
+ }
298
+
299
+ type Compositor = {
300
+ videoTrack: MediaStreamTrack;
301
+ canvasStream: MediaStream;
302
+ stop: () => void;
303
+ };
304
+
305
+ async function buildCompositor(
306
+ displayStream: MediaStream,
307
+ cameraStream: MediaStream,
308
+ dims: { width: number; height: number },
309
+ ): Promise<Compositor> {
310
+ const displayVideo = await readyVideo(displayStream);
311
+ const cameraVideo = await readyVideo(cameraStream);
312
+ const canvas = document.createElement("canvas");
313
+ canvas.width = dims.width;
314
+ canvas.height = dims.height;
315
+ const ctx = canvas.getContext("2d");
316
+ if (!ctx) throw new Error("Canvas 2D context unavailable for compositing.");
317
+
318
+ let running = true;
319
+ let raf = 0;
320
+ const draw = () => {
321
+ if (!running) return;
322
+ ctx.drawImage(displayVideo, 0, 0, canvas.width, canvas.height);
323
+ if (cameraVideo.readyState >= 2) drawCameraBubble(ctx, cameraVideo, canvas);
324
+ raf = requestAnimationFrame(draw);
325
+ };
326
+ raf = requestAnimationFrame(draw);
327
+
328
+ const canvasStream = canvas.captureStream(30);
329
+ return {
330
+ videoTrack: canvasStream.getVideoTracks()[0],
331
+ canvasStream,
332
+ stop: () => {
333
+ running = false;
334
+ cancelAnimationFrame(raf);
335
+ displayVideo.srcObject = null;
336
+ cameraVideo.srcObject = null;
337
+ },
338
+ };
339
+ }
340
+
213
341
  async function createMixedAudio(
214
342
  streams: MediaStream[],
215
343
  ): Promise<{ audioContext: AudioContext | null; tracks: MediaStreamTrack[] }> {
@@ -287,6 +415,13 @@ async function uploadChunk(
287
415
  const text = await res.text().catch(() => "");
288
416
  const data = text ? (JSON.parse(text) as UploadResult) : {};
289
417
  if (!res.ok) {
418
+ console.error(
419
+ "[clips-offscreen] chunk upload failed",
420
+ res.status,
421
+ "hadAuth:",
422
+ Boolean(recording.authToken),
423
+ text.slice(0, 200),
424
+ );
290
425
  throw new Error(
291
426
  data?.error || `Upload failed (${res.status}): ${text || res.statusText}`,
292
427
  );
@@ -310,6 +445,7 @@ function disposePrepared(): void {
310
445
  }
311
446
 
312
447
  function cleanup(recording: ActiveRecording): void {
448
+ recording.stopCompositor?.();
313
449
  stopStreams([recording.outputStream, ...recording.sourceStreams]);
314
450
  void recording.audioContext?.close().catch(() => undefined);
315
451
  }
@@ -329,22 +465,29 @@ async function acquire(message: AcquireMessage): Promise<{
329
465
  let displayStream: MediaStream | null = null;
330
466
  let micStream: MediaStream | null = null;
331
467
  let cameraStream: MediaStream | null = null;
468
+ const devices = await readDeviceIds();
332
469
 
333
470
  if (message.mode === "camera") {
334
471
  cameraStream = await navigator.mediaDevices.getUserMedia({
335
- video: {
336
- width: { ideal: 1280 },
337
- height: { ideal: 720 },
338
- facingMode: "user",
339
- },
340
- audio: message.includeMicrophone,
472
+ video: cameraConstraints(devices.video),
473
+ audio: message.includeMicrophone
474
+ ? devices.audio
475
+ ? { deviceId: { exact: devices.audio } }
476
+ : true
477
+ : false,
341
478
  });
342
479
  } else {
343
480
  // Native "Choose what to share" picker. This is the screenshot Steve showed.
344
481
  displayStream = await navigator.mediaDevices.getDisplayMedia(
345
482
  displayConstraints(message.surface),
346
483
  );
347
- if (message.includeMicrophone) micStream = await getMicStream();
484
+ if (message.includeMicrophone)
485
+ micStream = await getMicStream(devices.audio);
486
+ // The screen+camera face comes from the on-page bubble (captured in the
487
+ // display pixels), NOT composited here: canvas/requestAnimationFrame does
488
+ // not run in a hidden offscreen document, so compositing produced an empty
489
+ // recording ("No chunks found"). We record the display stream directly.
490
+ void message.includeCamera;
348
491
  }
349
492
 
350
493
  const videoStream = displayStream ?? cameraStream;
@@ -401,8 +544,24 @@ async function begin(message: BeginMessage): Promise<{
401
544
  if (activeRecording) throw new Error("Clips is already recording.");
402
545
 
403
546
  const videoStream = ready.displayStream ?? ready.cameraStream;
404
- const videoTrack = videoStream?.getVideoTracks()[0];
405
- if (!videoTrack) throw new Error("Capture video track was lost.");
547
+ const directVideoTrack = videoStream?.getVideoTracks()[0];
548
+ if (!directVideoTrack) throw new Error("Capture video track was lost.");
549
+
550
+ // Screen + camera → composite the camera bubble into a canvas and record that,
551
+ // so the face is in the video even on pages that block the on-page bubble.
552
+ let compositor: Compositor | null = null;
553
+ let videoTrack = directVideoTrack;
554
+ if (ready.displayStream && ready.cameraStream) {
555
+ compositor = await buildCompositor(
556
+ ready.displayStream,
557
+ ready.cameraStream,
558
+ {
559
+ width: ready.width,
560
+ height: ready.height,
561
+ },
562
+ );
563
+ videoTrack = compositor.videoTrack;
564
+ }
406
565
 
407
566
  const audioInputs = [
408
567
  ...(ready.displayStream ? [ready.displayStream] : []),
@@ -444,6 +603,7 @@ async function begin(message: BeginMessage): Promise<{
444
603
  ...(ready.displayStream ? [ready.displayStream] : []),
445
604
  ...(ready.micStream ? [ready.micStream] : []),
446
605
  ...(ready.cameraStream ? [ready.cameraStream] : []),
606
+ ...(compositor ? [compositor.canvasStream] : []),
447
607
  ],
448
608
  audioContext: mixedAudio.audioContext,
449
609
  chunkIndex: 0,
@@ -452,6 +612,10 @@ async function begin(message: BeginMessage): Promise<{
452
612
  cancelled: false,
453
613
  restarting: false,
454
614
  startTimer: null,
615
+ stopCompositor: compositor?.stop ?? null,
616
+ displaySource: ready.displayStream,
617
+ cameraSource: ready.cameraStream,
618
+ micSource: ready.micStream,
455
619
  dimensions: { width: ready.width, height: ready.height },
456
620
  hasAudio: outputStream.getAudioTracks().length > 0,
457
621
  hasCamera:
@@ -518,6 +682,7 @@ function startRecorderNow(recording: ActiveRecording): void {
518
682
  try {
519
683
  recording.recorder.start(2000);
520
684
  recording.startedAtMs = Date.now();
685
+ console.log("[clips-offscreen] recorder.start ok");
521
686
  reportStatus(recording.sessionId, "recording", {
522
687
  recordingId: recording.recordingId,
523
688
  width: recording.dimensions.width,
@@ -685,22 +850,18 @@ async function restart(
685
850
  recording.restarting = true;
686
851
  recording.cancelled = true;
687
852
  if (recording.recorder.state !== "inactive") recording.recorder.stop();
688
- // Close only the old mixing context; keep the capture tracks alive.
853
+ // Tear down the old compositor + mixing context; keep the capture tracks alive
854
+ // so the next BEGIN can build a fresh compositor/recorder on the same streams.
855
+ recording.stopCompositor?.();
689
856
  void recording.audioContext?.close().catch(() => undefined);
690
857
  activeRecording = null;
691
858
 
692
- const videoStream =
693
- recording.sourceStreams.find((s) => s.getVideoTracks().length) ?? null;
694
- const isCamera = recording.mode === "camera";
695
859
  prepared = {
696
860
  sessionId: recording.sessionId,
697
861
  mode: recording.mode,
698
- displayStream: isCamera ? null : videoStream,
699
- cameraStream: isCamera ? videoStream : null,
700
- micStream:
701
- recording.sourceStreams.find(
702
- (s) => s.getAudioTracks().length && s !== videoStream,
703
- ) ?? null,
862
+ displayStream: recording.displaySource,
863
+ cameraStream: recording.cameraSource,
864
+ micStream: recording.micSource,
704
865
  width: recording.dimensions.width,
705
866
  height: recording.dimensions.height,
706
867
  endedListener: null,
@@ -39,10 +39,9 @@ body {
39
39
  height: 100%;
40
40
  border-radius: 50%;
41
41
  overflow: hidden;
42
- background: #0f172a;
43
- box-shadow:
44
- 0 10px 30px rgba(15, 23, 42, 0.35),
45
- 0 0 0 4px rgba(255, 255, 255, 0.92);
42
+ background: #18181b;
43
+ border: 3px solid rgba(255, 255, 255, 0.92);
44
+ box-shadow: 0 6px 18px rgba(9, 9, 11, 0.4);
46
45
  display: flex;
47
46
  align-items: center;
48
47
  justify-content: center;
@@ -75,7 +74,7 @@ body {
75
74
  display: flex;
76
75
  align-items: center;
77
76
  justify-content: center;
78
- background: rgba(2, 6, 23, 0.55);
77
+ background: rgba(9, 9, 11, 0.55);
79
78
  backdrop-filter: blur(2px);
80
79
  }
81
80
 
@@ -126,9 +125,9 @@ body {
126
125
  font-size: clamp(96px, 22vmin, 240px);
127
126
  font-weight: 700;
128
127
  color: #fff;
129
- text-shadow: 0 6px 30px rgba(2, 6, 23, 0.6);
128
+ text-shadow: 0 6px 30px rgba(9, 9, 11, 0.6);
130
129
  line-height: 1;
131
- animation: countdown-pop 1s ease-out forwards;
130
+ animation: countdown-pop 0.45s ease-out forwards;
132
131
  }
133
132
 
134
133
  .countdown-go {
@@ -138,21 +137,17 @@ body {
138
137
 
139
138
  @keyframes countdown-pop {
140
139
  0% {
141
- transform: scale(0.45);
140
+ transform: scale(0.55);
142
141
  opacity: 0;
143
142
  }
144
- 25% {
145
- transform: scale(1);
143
+ 45% {
144
+ transform: scale(1.08);
146
145
  opacity: 1;
147
146
  }
148
- 70% {
147
+ 100% {
149
148
  transform: scale(1);
150
149
  opacity: 1;
151
150
  }
152
- 100% {
153
- transform: scale(1.25);
154
- opacity: 0;
155
- }
156
151
  }
157
152
 
158
153
  .countdown-hint {
@@ -163,7 +158,7 @@ body {
163
158
  color: rgba(255, 255, 255, 0.85);
164
159
  font-size: 15px;
165
160
  font-weight: 500;
166
- background: rgba(2, 6, 23, 0.45);
161
+ background: rgba(9, 9, 11, 0.45);
167
162
  padding: 8px 16px;
168
163
  border-radius: 999px;
169
164
  }
@@ -181,19 +176,21 @@ body {
181
176
  }
182
177
 
183
178
  .toolbar-v {
179
+ /* Top-anchored so Stop/time/pause stay put and the pill grows DOWNWARD on
180
+ hover (centering shifted the buttons up and caused misclicks). */
184
181
  position: absolute;
185
- top: 50%;
182
+ top: 0;
186
183
  left: 50%;
187
- transform: translate(-50%, -50%);
184
+ transform: translateX(-50%);
188
185
  display: flex;
189
186
  flex-direction: column;
190
187
  align-items: center;
191
188
  gap: 8px;
192
189
  padding: 9px;
193
190
  border-radius: 20px;
194
- background: rgba(15, 23, 42, 0.94);
191
+ background: rgba(24, 24, 27, 0.94);
195
192
  border: 1px solid rgba(255, 255, 255, 0.1);
196
- box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
193
+ box-shadow: 0 10px 28px rgba(9, 9, 11, 0.45);
197
194
  color: #fff;
198
195
  -webkit-user-select: none;
199
196
  user-select: none;
@@ -323,9 +320,9 @@ body {
323
320
  gap: 9px;
324
321
  padding: 14px 16px;
325
322
  border-radius: 14px;
326
- background: rgba(15, 23, 42, 0.95);
323
+ background: rgba(24, 24, 27, 0.95);
327
324
  border: 1px solid rgba(255, 255, 255, 0.1);
328
- box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
325
+ box-shadow: 0 10px 28px rgba(9, 9, 11, 0.45);
329
326
  color: #fff;
330
327
  }
331
328
 
@@ -363,7 +360,7 @@ body {
363
360
  height: 100%;
364
361
  width: 40%;
365
362
  border-radius: 999px;
366
- background: #6366f1;
363
+ background: #d4d4d8;
367
364
  animation: saving-indeterminate 1.1s ease-in-out infinite;
368
365
  }
369
366
 
@@ -375,3 +372,56 @@ body {
375
372
  margin-left: 100%;
376
373
  }
377
374
  }
375
+
376
+ /* Bubble size dots (small / large), revealed on hover — desktop-style. */
377
+ .bubble-sizes {
378
+ position: absolute;
379
+ bottom: 7%;
380
+ left: 50%;
381
+ transform: translateX(-50%);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 9px;
385
+ padding: 5px 9px;
386
+ border-radius: 999px;
387
+ background: rgba(9, 9, 11, 0.55);
388
+ -webkit-backdrop-filter: blur(4px);
389
+ backdrop-filter: blur(4px);
390
+ opacity: 0;
391
+ transition: opacity 0.15s ease;
392
+ pointer-events: none;
393
+ }
394
+
395
+ .bubble:hover .bubble-sizes {
396
+ opacity: 1;
397
+ pointer-events: auto;
398
+ }
399
+
400
+ .bubble-size-dot {
401
+ border: none;
402
+ padding: 0;
403
+ border-radius: 50%;
404
+ background: rgba(255, 255, 255, 0.55);
405
+ cursor: pointer;
406
+ transition:
407
+ background 0.12s ease,
408
+ transform 0.1s ease;
409
+ }
410
+
411
+ .bubble-size-dot:hover {
412
+ background: #fff;
413
+ }
414
+
415
+ .bubble-size-dot:active {
416
+ transform: scale(0.9);
417
+ }
418
+
419
+ .bubble-size-sm {
420
+ width: 8px;
421
+ height: 8px;
422
+ }
423
+
424
+ .bubble-size-lg {
425
+ width: 13px;
426
+ height: 13px;
427
+ }
@@ -75,21 +75,82 @@ function send(type: string, extra: Record<string, unknown> = {}): void {
75
75
 
76
76
  /* ---------------------------------------------------------------- bubble --- */
77
77
 
78
+ function postBubble(kind: string, extra: Record<string, unknown> = {}): void {
79
+ try {
80
+ window.parent.postMessage(
81
+ { source: "clips-overlay", kind, part: "bubble", ...extra },
82
+ "*",
83
+ );
84
+ } catch {
85
+ /* parent gone */
86
+ }
87
+ }
88
+
78
89
  async function initBubble(): Promise<void> {
79
90
  const bubble = document.createElement("div");
80
91
  bubble.className = "bubble";
81
92
  const ring = document.createElement("div");
82
93
  ring.className = "bubble-ring";
83
94
  bubble.appendChild(ring);
95
+
96
+ // Drag: the content script owns the iframe position, so we just signal the
97
+ // start of a drag and it captures the pointer page-wide.
98
+ bubble.style.cursor = "grab";
99
+ bubble.addEventListener("pointerdown", (e) => {
100
+ if (e.button !== 0) return;
101
+ if ((e.target as HTMLElement).closest("[data-no-drag]")) return;
102
+ e.preventDefault();
103
+ bubble.style.cursor = "grabbing";
104
+ postBubble("bubble-drag-start");
105
+ const restore = (): void => {
106
+ bubble.style.cursor = "grab";
107
+ window.removeEventListener("pointerup", restore);
108
+ };
109
+ window.addEventListener("pointerup", restore);
110
+ });
111
+
112
+ // Size dots (small / large), revealed on hover — like the desktop bubble.
113
+ const sizes = document.createElement("div");
114
+ sizes.className = "bubble-sizes";
115
+ sizes.setAttribute("data-no-drag", "");
116
+ for (const key of ["sm", "lg"] as const) {
117
+ const dot = document.createElement("button");
118
+ dot.type = "button";
119
+ dot.className = `bubble-size-dot bubble-size-${key}`;
120
+ dot.title = key === "sm" ? "Small" : "Large";
121
+ dot.setAttribute(
122
+ "aria-label",
123
+ key === "sm" ? "Small bubble" : "Large bubble",
124
+ );
125
+ dot.setAttribute("data-no-drag", "");
126
+ dot.addEventListener("click", (e) => {
127
+ e.stopPropagation();
128
+ postBubble("bubble-size", { size: key });
129
+ });
130
+ sizes.appendChild(dot);
131
+ }
132
+ bubble.appendChild(sizes);
133
+
84
134
  root.appendChild(bubble);
85
135
 
86
136
  try {
137
+ const videoDeviceId = await new Promise<string>((resolve) => {
138
+ try {
139
+ chrome.storage.sync.get("videoDeviceId", (v) =>
140
+ resolve(typeof v.videoDeviceId === "string" ? v.videoDeviceId : ""),
141
+ );
142
+ } catch {
143
+ resolve("");
144
+ }
145
+ });
146
+ const videoConstraint: MediaTrackConstraints = {
147
+ width: { ideal: 640 },
148
+ height: { ideal: 640 },
149
+ };
150
+ if (videoDeviceId) videoConstraint.deviceId = { exact: videoDeviceId };
151
+ else videoConstraint.facingMode = "user";
87
152
  const stream = await navigator.mediaDevices.getUserMedia({
88
- video: {
89
- width: { ideal: 640 },
90
- height: { ideal: 640 },
91
- facingMode: "user",
92
- },
153
+ video: videoConstraint,
93
154
  audio: false,
94
155
  });
95
156
  const video = document.createElement("video");
@@ -99,7 +160,9 @@ async function initBubble(): Promise<void> {
99
160
  video.srcObject = stream;
100
161
  ring.appendChild(video);
101
162
  await video.play().catch(() => undefined);
102
- } catch {
163
+ console.log("[clips-overlay] camera bubble live");
164
+ } catch (err) {
165
+ console.warn("[clips-overlay] camera getUserMedia failed:", err);
103
166
  const empty = document.createElement("div");
104
167
  empty.className = "bubble-empty";
105
168
  empty.innerHTML = ICONS.cameraOff;
@@ -148,14 +211,20 @@ function initCountdown(): void {
148
211
  root.appendChild(wrap);
149
212
 
150
213
  let lastShown = "";
214
+ // Compute the fallback end-time ONCE (if the worker's countdownEndsAtMs never
215
+ // arrives) — recomputing it every frame froze the number on "3".
216
+ let fallbackEndsAt = 0;
151
217
  const render = (): void => {
152
- const fallback =
153
- Number(params.get("seconds") || String(COUNTDOWN_FALLBACK)) ||
154
- COUNTDOWN_FALLBACK;
155
- const endsAt =
156
- state.countdownEndsAtMs > 0
157
- ? state.countdownEndsAtMs
158
- : Date.now() + fallback * 1000;
218
+ let endsAt = state.countdownEndsAtMs;
219
+ if (endsAt <= 0) {
220
+ if (fallbackEndsAt === 0) {
221
+ const fallback =
222
+ Number(params.get("seconds") || String(COUNTDOWN_FALLBACK)) ||
223
+ COUNTDOWN_FALLBACK;
224
+ fallbackEndsAt = Date.now() + fallback * 1000;
225
+ }
226
+ endsAt = fallbackEndsAt;
227
+ }
159
228
  const remainingMs = endsAt - Date.now();
160
229
  if (state.phase !== "countdown" || remainingMs <= 0) {
161
230
  if (lastShown !== "Go") {