@agent-native/core 0.71.0 → 0.72.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 (164) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +38 -0
  3. package/corpus/core/docs/content/actions.md +23 -0
  4. package/corpus/core/docs/content/audit-log.md +111 -0
  5. package/corpus/core/package.json +2 -1
  6. package/corpus/core/src/action.ts +80 -1
  7. package/corpus/core/src/agent/production-agent.ts +27 -0
  8. package/corpus/core/src/agent/run-store.ts +14 -0
  9. package/corpus/core/src/application-state/store.ts +5 -0
  10. package/corpus/core/src/audit/actions/get-audit-event.ts +23 -0
  11. package/corpus/core/src/audit/actions/list-audit-events.ts +65 -0
  12. package/corpus/core/src/audit/cleanup-job.ts +100 -0
  13. package/corpus/core/src/audit/config.ts +91 -0
  14. package/corpus/core/src/audit/index.ts +43 -0
  15. package/corpus/core/src/audit/record.ts +143 -0
  16. package/corpus/core/src/audit/redact.ts +109 -0
  17. package/corpus/core/src/audit/store.ts +244 -0
  18. package/corpus/core/src/audit/types.ts +125 -0
  19. package/corpus/core/src/chat-threads/store.ts +10 -0
  20. package/corpus/core/src/client/AssistantChat.tsx +6 -0
  21. package/corpus/core/src/client/chat/repo-helpers.ts +38 -0
  22. package/corpus/core/src/db/client.ts +4 -0
  23. package/corpus/core/src/db/widen-columns.ts +75 -0
  24. package/corpus/core/src/mcp/build-server.ts +1 -0
  25. package/corpus/core/src/oauth-tokens/store.ts +6 -0
  26. package/corpus/core/src/provider-api/custom-registry.ts +8 -0
  27. package/corpus/core/src/resources/store.ts +10 -0
  28. package/corpus/core/src/scripts/runner.ts +6 -3
  29. package/corpus/core/src/server/action-discovery.ts +6 -0
  30. package/corpus/core/src/server/action-routes.ts +1 -0
  31. package/corpus/core/src/server/auth.ts +5 -0
  32. package/corpus/core/src/server/core-routes-plugin.ts +14 -0
  33. package/corpus/core/src/server/security-headers.ts +9 -6
  34. package/corpus/core/src/settings/store.ts +6 -0
  35. package/corpus/core/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  36. package/corpus/core/src/usage/store.ts +6 -0
  37. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +60 -6
  38. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +6 -0
  39. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +20 -43
  40. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +5 -1
  41. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +3 -51
  42. package/corpus/templates/clips/changelog/2026-06-23-public-clips-now-play-inline-in-slack-connect-a-workspac.md +6 -0
  43. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +72 -0
  44. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -10
  45. package/corpus/templates/clips/chrome-extension/src/background.ts +34 -1
  46. package/corpus/templates/clips/chrome-extension/src/content-script.ts +138 -11
  47. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +50 -33
  48. package/corpus/templates/clips/chrome-extension/src/overlay.css +11 -27
  49. package/corpus/templates/clips/chrome-extension/src/overlay.html +7 -1
  50. package/corpus/templates/clips/chrome-extension/src/overlay.ts +33 -38
  51. package/corpus/templates/clips/chrome-extension/src/popup.ts +6 -24
  52. package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
  53. package/corpus/templates/clips/server/lib/media-permissions.ts +5 -1
  54. package/corpus/templates/clips/shared/recording-audio.ts +62 -0
  55. package/corpus/templates/clips/shared/recording-core.ts +94 -0
  56. package/dist/action.d.ts +31 -0
  57. package/dist/action.d.ts.map +1 -1
  58. package/dist/action.js +45 -1
  59. package/dist/action.js.map +1 -1
  60. package/dist/agent/context-xray/schema.d.ts +1 -1
  61. package/dist/agent/observational-memory/schema.d.ts +1 -1
  62. package/dist/agent/production-agent.d.ts.map +1 -1
  63. package/dist/agent/production-agent.js +28 -0
  64. package/dist/agent/production-agent.js.map +1 -1
  65. package/dist/agent/run-store.d.ts.map +1 -1
  66. package/dist/agent/run-store.js +14 -0
  67. package/dist/agent/run-store.js.map +1 -1
  68. package/dist/application-state/store.d.ts.map +1 -1
  69. package/dist/application-state/store.js +5 -0
  70. package/dist/application-state/store.js.map +1 -1
  71. package/dist/audit/actions/get-audit-event.d.ts +11 -0
  72. package/dist/audit/actions/get-audit-event.d.ts.map +1 -0
  73. package/dist/audit/actions/get-audit-event.js +22 -0
  74. package/dist/audit/actions/get-audit-event.js.map +1 -0
  75. package/dist/audit/actions/list-audit-events.d.ts +22 -0
  76. package/dist/audit/actions/list-audit-events.d.ts.map +1 -0
  77. package/dist/audit/actions/list-audit-events.js +61 -0
  78. package/dist/audit/actions/list-audit-events.js.map +1 -0
  79. package/dist/audit/cleanup-job.d.ts +12 -0
  80. package/dist/audit/cleanup-job.d.ts.map +1 -0
  81. package/dist/audit/cleanup-job.js +93 -0
  82. package/dist/audit/cleanup-job.js.map +1 -0
  83. package/dist/audit/config.d.ts +30 -0
  84. package/dist/audit/config.d.ts.map +1 -0
  85. package/dist/audit/config.js +65 -0
  86. package/dist/audit/config.js.map +1 -0
  87. package/dist/audit/index.d.ts +13 -0
  88. package/dist/audit/index.d.ts.map +1 -0
  89. package/dist/audit/index.js +6 -0
  90. package/dist/audit/index.js.map +1 -0
  91. package/dist/audit/record.d.ts +24 -0
  92. package/dist/audit/record.d.ts.map +1 -0
  93. package/dist/audit/record.js +106 -0
  94. package/dist/audit/record.js.map +1 -0
  95. package/dist/audit/redact.d.ts +28 -0
  96. package/dist/audit/redact.d.ts.map +1 -0
  97. package/dist/audit/redact.js +109 -0
  98. package/dist/audit/redact.js.map +1 -0
  99. package/dist/audit/store.d.ts +14 -0
  100. package/dist/audit/store.d.ts.map +1 -0
  101. package/dist/audit/store.js +219 -0
  102. package/dist/audit/store.js.map +1 -0
  103. package/dist/audit/types.d.ts +114 -0
  104. package/dist/audit/types.d.ts.map +1 -0
  105. package/dist/audit/types.js +15 -0
  106. package/dist/audit/types.js.map +1 -0
  107. package/dist/chat-threads/store.d.ts.map +1 -1
  108. package/dist/chat-threads/store.js +10 -0
  109. package/dist/chat-threads/store.js.map +1 -1
  110. package/dist/client/AssistantChat.d.ts.map +1 -1
  111. package/dist/client/AssistantChat.js +6 -1
  112. package/dist/client/AssistantChat.js.map +1 -1
  113. package/dist/client/chat/repo-helpers.d.ts +16 -0
  114. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  115. package/dist/client/chat/repo-helpers.js +40 -0
  116. package/dist/client/chat/repo-helpers.js.map +1 -1
  117. package/dist/db/client.d.ts.map +1 -1
  118. package/dist/db/client.js +3 -0
  119. package/dist/db/client.js.map +1 -1
  120. package/dist/db/widen-columns.d.ts +39 -0
  121. package/dist/db/widen-columns.d.ts.map +1 -0
  122. package/dist/db/widen-columns.js +73 -0
  123. package/dist/db/widen-columns.js.map +1 -0
  124. package/dist/mcp/build-server.d.ts.map +1 -1
  125. package/dist/mcp/build-server.js +1 -0
  126. package/dist/mcp/build-server.js.map +1 -1
  127. package/dist/oauth-tokens/store.d.ts.map +1 -1
  128. package/dist/oauth-tokens/store.js +6 -0
  129. package/dist/oauth-tokens/store.js.map +1 -1
  130. package/dist/provider-api/custom-registry.d.ts.map +1 -1
  131. package/dist/provider-api/custom-registry.js +8 -0
  132. package/dist/provider-api/custom-registry.js.map +1 -1
  133. package/dist/resources/store.d.ts.map +1 -1
  134. package/dist/resources/store.js +9 -0
  135. package/dist/resources/store.js.map +1 -1
  136. package/dist/scripts/runner.js +4 -3
  137. package/dist/scripts/runner.js.map +1 -1
  138. package/dist/server/action-discovery.d.ts.map +1 -1
  139. package/dist/server/action-discovery.js +6 -0
  140. package/dist/server/action-discovery.js.map +1 -1
  141. package/dist/server/action-routes.d.ts.map +1 -1
  142. package/dist/server/action-routes.js +1 -0
  143. package/dist/server/action-routes.js.map +1 -1
  144. package/dist/server/auth.d.ts.map +1 -1
  145. package/dist/server/auth.js +5 -0
  146. package/dist/server/auth.js.map +1 -1
  147. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  148. package/dist/server/core-routes-plugin.js +13 -0
  149. package/dist/server/core-routes-plugin.js.map +1 -1
  150. package/dist/server/security-headers.d.ts +8 -5
  151. package/dist/server/security-headers.d.ts.map +1 -1
  152. package/dist/server/security-headers.js +9 -6
  153. package/dist/server/security-headers.js.map +1 -1
  154. package/dist/settings/store.d.ts.map +1 -1
  155. package/dist/settings/store.js +6 -0
  156. package/dist/settings/store.js.map +1 -1
  157. package/dist/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  158. package/dist/usage/store.d.ts.map +1 -1
  159. package/dist/usage/store.js +5 -0
  160. package/dist/usage/store.js.map +1 -1
  161. package/docs/content/actions.md +23 -0
  162. package/docs/content/audit-log.md +111 -0
  163. package/package.json +2 -1
  164. package/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
@@ -1,10 +1,15 @@
1
- // Loom-style in-page overlay host. This content script is injected into every
2
- // page (declared for <all_urls>) and is wrapped in an IIFE so it emits a single
3
- // self-contained classic script with no module imports/exports and leaks no
4
- // names into the shared global scope. Its only job is to mount/unmount the
5
- // overlay iframes; all UI and control logic lives inside the extension-origin
6
- // overlay pages (src/overlay.html). The background service worker is the source
7
- // of truth for which "parts" are visible and pushes them here.
1
+ // Loom-style in-page overlay host. The background service worker injects this
2
+ // into the LAUNCH TAB on demand via chrome.scripting.executeScript (covered by
3
+ // the activeTab permission the user grants when they click the extension), NOT
4
+ // declaratively on every page that would need broad "<all_urls>" host access
5
+ // and Chrome's in-depth review. So the overlay lives on the tab the recording
6
+ // was started from; it does not follow across tabs unless CROSS_TAB_FOLLOW is
7
+ // re-enabled in background.ts (see PERMISSIONS.md). Wrapped in an IIFE so it
8
+ // emits a single self-contained classic script with no module imports/exports
9
+ // and leaks no names into the shared global scope. Its only job is to
10
+ // mount/unmount the overlay iframes; all UI and control logic lives inside the
11
+ // extension-origin overlay pages (src/overlay.html). The worker is the source of
12
+ // truth for which "parts" are visible and pushes them here.
8
13
 
9
14
  (function clipsOverlayHost() {
10
15
  type OverlayPart = "bubble" | "countdown" | "toolbar" | "saving";
@@ -194,7 +199,7 @@
194
199
  position: "absolute",
195
200
  border: "none",
196
201
  background: "transparent",
197
- colorScheme: "normal",
202
+ colorScheme: "dark",
198
203
  pointerEvents: "auto",
199
204
  });
200
205
  frame.setAttribute("allowtransparency", "true");
@@ -208,24 +213,39 @@
208
213
  bottom: "24px",
209
214
  width: `${size}px`,
210
215
  height: `${size}px`,
216
+ // Clip the IFRAME itself to a circle so the iframe's opaque canvas (which
217
+ // a declared color-scheme always paints, dark or white) can never show as
218
+ // a square box around the bubble.
219
+ borderRadius: "50%",
220
+ overflow: "hidden",
211
221
  zIndex: "3",
212
222
  });
213
223
  } else if (part === "toolbar") {
214
224
  // Left-edge vertical pill (desktop layout). Height grows on hover via the
215
- // resize message below.
225
+ // resize message below. Clipped to the pill's radius (the pill fills the
226
+ // iframe) so the opaque canvas can't show as a box; shadow on the iframe so
227
+ // the clip doesn't cut it.
216
228
  Object.assign(frame.style, {
217
229
  left: "16px",
218
230
  top: "calc(50% - 77px)",
219
231
  width: "68px",
220
232
  height: "154px",
233
+ borderRadius: "20px",
234
+ overflow: "hidden",
235
+ boxShadow: "0 10px 28px rgba(9, 9, 11, 0.45)",
221
236
  zIndex: "2",
222
237
  });
223
238
  } else if (part === "saving") {
239
+ // Compact card: caption + a single indeterminate bar (no circular spinner).
240
+ // Clipped to the card radius (card fills the iframe) so no canvas box shows.
224
241
  Object.assign(frame.style, {
225
242
  left: "24px",
226
243
  bottom: "24px",
227
- width: "264px",
228
- height: "96px",
244
+ width: "240px",
245
+ height: "64px",
246
+ borderRadius: "14px",
247
+ overflow: "hidden",
248
+ boxShadow: "0 10px 28px rgba(9, 9, 11, 0.45)",
229
249
  zIndex: "2",
230
250
  });
231
251
  } else {
@@ -253,22 +273,124 @@
253
273
  if (part === "bubble") applyBubbleGeom();
254
274
  }
255
275
 
276
+ // Camera-ready gating + connecting spinner: while the camera connects we show a
277
+ // simple centered spinner and keep the bubble hidden, then reveal the bubble and
278
+ // start the countdown once the feed is live — so the "3" never hangs and there's
279
+ // no half-loaded, un-draggable bubble during the wait. The bubble posts
280
+ // "camera-ready" when its video plays (or fails); a fallback timer proceeds
281
+ // anyway if the camera never connects.
282
+ const CONNECTING_ID = `${CONTAINER_ID}-connecting`;
283
+ let cameraReady = false;
284
+ let countdownDeferred = false;
285
+ let countdownFallbackTimer: ReturnType<typeof setTimeout> | undefined;
286
+
287
+ function showConnecting(container: HTMLDivElement): void {
288
+ if (document.getElementById(CONNECTING_ID)) return;
289
+ const wrap = document.createElement("div");
290
+ wrap.id = CONNECTING_ID;
291
+ Object.assign(wrap.style, {
292
+ position: "absolute",
293
+ inset: "0",
294
+ display: "flex",
295
+ alignItems: "center",
296
+ justifyContent: "center",
297
+ pointerEvents: "none",
298
+ zIndex: "4",
299
+ });
300
+ const chip = document.createElement("div");
301
+ Object.assign(chip.style, {
302
+ display: "flex",
303
+ alignItems: "center",
304
+ justifyContent: "center",
305
+ width: "84px",
306
+ height: "84px",
307
+ borderRadius: "20px",
308
+ background: "rgba(24, 24, 27, 0.92)",
309
+ boxShadow: "0 10px 28px rgba(9, 9, 11, 0.45)",
310
+ });
311
+ const spinner = document.createElement("div");
312
+ Object.assign(spinner.style, {
313
+ width: "40px",
314
+ height: "40px",
315
+ borderRadius: "50%",
316
+ border: "4px solid rgba(255, 255, 255, 0.18)",
317
+ borderTopColor: "rgba(255, 255, 255, 0.92)",
318
+ });
319
+ chip.appendChild(spinner);
320
+ wrap.appendChild(chip);
321
+ container.appendChild(wrap);
322
+ try {
323
+ spinner.animate(
324
+ [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
325
+ { duration: 800, iterations: Infinity },
326
+ );
327
+ } catch {
328
+ /* Web Animations unavailable — a static ring is fine */
329
+ }
330
+ }
331
+
332
+ function hideConnecting(): void {
333
+ document.getElementById(CONNECTING_ID)?.remove();
334
+ }
335
+
336
+ function setBubbleHidden(hidden: boolean): void {
337
+ const frame = document.getElementById(partFrameId("bubble"));
338
+ if (frame) (frame as HTMLElement).style.visibility = hidden ? "hidden" : "";
339
+ }
340
+
341
+ function mountDeferredCountdown(): void {
342
+ countdownDeferred = false;
343
+ clearTimeout(countdownFallbackTimer);
344
+ hideConnecting();
345
+ setBubbleHidden(false);
346
+ const container = document.getElementById(
347
+ CONTAINER_ID,
348
+ ) as HTMLDivElement | null;
349
+ if (container && !document.getElementById(partFrameId("countdown"))) {
350
+ mountPart(container, "countdown");
351
+ }
352
+ }
353
+
256
354
  function reconcile(parts: OverlayPart[]): void {
257
355
  console.log("[clips-cs] reconcile parts:", parts, "on", location.href);
258
356
  const wanted = new Set(parts.filter((p) => ALL_PARTS.includes(p)));
357
+ // Leaving the countdown phase (recording / saving / idle): cancel any pending
358
+ // deferred countdown + spinner so a late fallback timer can't pop the "3-2-1"
359
+ // back up over a later overlay (this was the "countdown over the saving card"
360
+ // bug).
361
+ if (!wanted.has("countdown")) {
362
+ countdownDeferred = false;
363
+ clearTimeout(countdownFallbackTimer);
364
+ hideConnecting();
365
+ }
259
366
  if (wanted.size === 0) {
260
367
  document.getElementById(CONTAINER_ID)?.remove();
368
+ cameraReady = false;
261
369
  return;
262
370
  }
263
371
  const container = ensureContainer();
372
+ const gateCountdown =
373
+ wanted.has("countdown") && wanted.has("bubble") && !cameraReady;
264
374
  for (const part of ALL_PARTS) {
265
375
  const existing = document.getElementById(partFrameId(part));
266
376
  if (wanted.has(part)) {
377
+ // Hold the countdown (showing the spinner) until the camera feed is live.
378
+ if (part === "countdown" && gateCountdown) {
379
+ if (!existing && !countdownDeferred) {
380
+ countdownDeferred = true;
381
+ clearTimeout(countdownFallbackTimer);
382
+ countdownFallbackTimer = setTimeout(mountDeferredCountdown, 12000);
383
+ }
384
+ continue;
385
+ }
267
386
  if (!existing) mountPart(container, part);
387
+ // Keep the bubble hidden behind the spinner until the feed is live.
388
+ if (part === "bubble" && gateCountdown) setBubbleHidden(true);
268
389
  } else if (existing) {
269
390
  existing.remove();
270
391
  }
271
392
  }
393
+ if (gateCountdown) showConnecting(container);
272
394
  }
273
395
 
274
396
  // Guard against rare double-injection (SPA soft-reloads re-running the script).
@@ -311,6 +433,11 @@
311
433
  }
312
434
  return;
313
435
  }
436
+ if (data.kind === "camera-ready") {
437
+ cameraReady = true;
438
+ if (countdownDeferred) mountDeferredCountdown();
439
+ return;
440
+ }
314
441
  if (data.kind === "bubble-drag-start") {
315
442
  startBubbleDrag();
316
443
  return;
@@ -12,6 +12,12 @@
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
14
14
  // over on the same stream).
15
+ //
16
+ // MIME selection and the chunk-upload URL/param protocol are shared with the web
17
+ // app recorder via @shared/recording-core so the server contract can't drift.
18
+
19
+ import { chunkUploadUrl, pickMimeType } from "@shared/recording-core";
20
+ import { scheduleReadyChime } from "@shared/recording-audio";
15
21
 
16
22
  type CaptureMode = "screen" | "camera";
17
23
 
@@ -128,20 +134,6 @@ function reportStatus(
128
134
  });
129
135
  }
130
136
 
131
- function chooseMimeType(): string {
132
- const preferred = [
133
- "video/webm;codecs=vp9,opus",
134
- "video/webm;codecs=vp8,opus",
135
- "video/webm;codecs=opus",
136
- "video/webm",
137
- ];
138
- if (typeof MediaRecorder === "undefined") return "video/webm";
139
- return (
140
- preferred.find((type) => MediaRecorder.isTypeSupported(type)) ??
141
- "video/webm"
142
- );
143
- }
144
-
145
137
  function waitForMetadata(video: HTMLVideoElement): Promise<void> {
146
138
  if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
147
139
  return Promise.resolve();
@@ -358,21 +350,6 @@ async function createMixedAudio(
358
350
  return { audioContext, tracks: destination.stream.getAudioTracks() };
359
351
  }
360
352
 
361
- function appendUploadParams(
362
- uploadUrl: string,
363
- params: Record<string, string | number | boolean | undefined>,
364
- ): string {
365
- const url = new URL(uploadUrl);
366
- for (const [key, value] of Object.entries(params)) {
367
- if (value === undefined) continue;
368
- url.searchParams.set(
369
- key,
370
- typeof value === "boolean" ? (value ? "1" : "0") : String(value),
371
- );
372
- }
373
- return url.toString();
374
- }
375
-
376
353
  async function uploadChunk(
377
354
  recording: ActiveRecording,
378
355
  blob: Blob,
@@ -387,10 +364,10 @@ async function uploadChunk(
387
364
  hasCamera?: boolean;
388
365
  } = {},
389
366
  ): Promise<UploadResult> {
390
- const url = appendUploadParams(recording.uploadUrl, {
367
+ const url = chunkUploadUrl(recording.uploadUrl, {
391
368
  index,
392
369
  total: extra.total,
393
- isFinal: extra.isFinal ? 1 : 0,
370
+ isFinal: extra.isFinal,
394
371
  mimeType: recording.mimeType,
395
372
  durationMs: extra.durationMs,
396
373
  width: extra.width,
@@ -573,7 +550,7 @@ async function begin(message: BeginMessage): Promise<{
573
550
  const mixedAudio = await createMixedAudio(audioInputs);
574
551
 
575
552
  const outputStream = new MediaStream([videoTrack, ...mixedAudio.tracks]);
576
- const mimeType = chooseMimeType();
553
+ const mimeType = pickMimeType() || "video/webm";
577
554
  const recorder = new MediaRecorder(outputStream, {
578
555
  mimeType,
579
556
  // Crisp 1080p capture — matches the web/desktop recorders. Files upload
@@ -640,6 +617,10 @@ async function begin(message: BeginMessage): Promise<{
640
617
  return;
641
618
  }
642
619
  const index = recording.chunkIndex++;
620
+ // Record the failure and stop, but do NOT re-throw: re-throwing leaves a
621
+ // rejected promise that surfaces as an "Uncaught (in promise)" error (bad
622
+ // look in a Chrome Web Store review). finalizeStop reads recording.upload-
623
+ // Failure and surfaces it through the normal error path instead.
643
624
  const upload = uploadChunk(recording, event.data, index).catch((err) => {
644
625
  recording.uploadFailure =
645
626
  err instanceof Error ? err : new Error(String(err));
@@ -647,7 +628,6 @@ async function begin(message: BeginMessage): Promise<{
647
628
  error: recording.uploadFailure.message,
648
629
  });
649
630
  if (recorder.state !== "inactive") recorder.stop();
650
- throw recording.uploadFailure;
651
631
  });
652
632
  recording.uploadPromises.push(upload);
653
633
  });
@@ -677,9 +657,25 @@ async function begin(message: BeginMessage): Promise<{
677
657
  };
678
658
  }
679
659
 
660
+ // The canonical Clips "ready" chime when recording starts — shared with the web
661
+ // app recorder (and matching the desktop app) so every surface sounds the same.
662
+ function playStartChime(): void {
663
+ try {
664
+ const ctx = new AudioContext();
665
+ void ctx.resume().catch(() => undefined);
666
+ void scheduleReadyChime(ctx).finally(() => {
667
+ void ctx.close().catch(() => undefined);
668
+ });
669
+ } catch {
670
+ /* audio unavailable */
671
+ }
672
+ }
673
+
680
674
  function startRecorderNow(recording: ActiveRecording): void {
681
675
  if (recording.cancelled) return;
682
676
  try {
677
+ // Chime first (on "Go") so it mostly lands before the recording begins.
678
+ playStartChime();
683
679
  recording.recorder.start(2000);
684
680
  recording.startedAtMs = Date.now();
685
681
  console.log("[clips-offscreen] recorder.start ok");
@@ -724,6 +720,27 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
724
720
  : new Error(String(rejected.reason));
725
721
  }
726
722
  const durationMs = Math.max(0, Date.now() - recording.startedAtMs);
723
+ // Surface WHY a finalize might fail before the server's cryptic "No chunks
724
+ // found": 0 chunks means the recorder emitted no non-empty data (empty
725
+ // capture / never started), which is a different problem than an auth 401.
726
+ if (recording.chunkIndex === 0) {
727
+ console.warn(
728
+ "[clips-offscreen] finalizing with 0 chunks — empty recording.",
729
+ "recorderStarted:",
730
+ recording.startedAtMs > 0,
731
+ "durationMs:",
732
+ durationMs,
733
+ "hadAuth:",
734
+ Boolean(recording.authToken),
735
+ );
736
+ } else {
737
+ console.log(
738
+ "[clips-offscreen] finalizing",
739
+ recording.chunkIndex,
740
+ "chunk(s), durationMs:",
741
+ durationMs,
742
+ );
743
+ }
727
744
  const result = await uploadChunk(
728
745
  recording,
729
746
  new Blob([], { type: recording.mimeType }),
@@ -1,5 +1,8 @@
1
1
  :root {
2
- color-scheme: light dark;
2
+ /* "dark" to match the dark overlay: a declared color-scheme always paints an
3
+ opaque iframe canvas, so we keep it dark rather than white. Shaped parts clip
4
+ the canvas away (see content-script styleFrame); the countdown uses it. */
5
+ color-scheme: dark;
3
6
  }
4
7
 
5
8
  * {
@@ -41,7 +44,6 @@ body {
41
44
  overflow: hidden;
42
45
  background: #18181b;
43
46
  border: 3px solid rgba(255, 255, 255, 0.92);
44
- box-shadow: 0 6px 18px rgba(9, 9, 11, 0.4);
45
47
  display: flex;
46
48
  align-items: center;
47
49
  justify-content: center;
@@ -176,21 +178,22 @@ body {
176
178
  }
177
179
 
178
180
  .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). */
181
+ /* Fill the iframe so the (opaque) iframe canvas is fully covered by the pill
182
+ the iframe is clipped to this same 20px radius in styleFrame, so no canvas
183
+ box shows. Top-anchored content (justify-content:flex-start) keeps Stop/time/
184
+ pause put as the pill grows DOWNWARD on hover (centering caused misclicks).
185
+ Shadow lives on the iframe element so the clip doesn't cut it off. */
181
186
  position: absolute;
182
- top: 0;
183
- left: 50%;
184
- transform: translateX(-50%);
187
+ inset: 0;
185
188
  display: flex;
186
189
  flex-direction: column;
187
190
  align-items: center;
191
+ justify-content: flex-start;
188
192
  gap: 8px;
189
193
  padding: 9px;
190
194
  border-radius: 20px;
191
195
  background: rgba(24, 24, 27, 0.94);
192
196
  border: 1px solid rgba(255, 255, 255, 0.1);
193
- box-shadow: 0 10px 28px rgba(9, 9, 11, 0.45);
194
197
  color: #fff;
195
198
  -webkit-user-select: none;
196
199
  user-select: none;
@@ -322,28 +325,9 @@ body {
322
325
  border-radius: 14px;
323
326
  background: rgba(24, 24, 27, 0.95);
324
327
  border: 1px solid rgba(255, 255, 255, 0.1);
325
- box-shadow: 0 10px 28px rgba(9, 9, 11, 0.45);
326
328
  color: #fff;
327
329
  }
328
330
 
329
- .saving-card .saving-spinner {
330
- position: absolute;
331
- top: 14px;
332
- right: 14px;
333
- width: 16px;
334
- height: 16px;
335
- border-radius: 50%;
336
- border: 2px solid rgba(255, 255, 255, 0.25);
337
- border-top-color: #fff;
338
- animation: saving-spin 0.8s linear infinite;
339
- }
340
-
341
- @keyframes saving-spin {
342
- to {
343
- transform: rotate(360deg);
344
- }
345
- }
346
-
347
331
  .saving-caption {
348
332
  font-size: 14px;
349
333
  font-weight: 600;
@@ -2,7 +2,13 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="color-scheme" content="light dark" />
5
+ <!-- A declared color-scheme always makes Chrome paint an OPAQUE iframe canvas
6
+ (white for light, dark for dark) — it can't be made transparent. The
7
+ overlay is dark-themed, so we use "dark" to keep that canvas dark rather
8
+ than a jarring white. The shaped parts (bubble/toolbar/saving) clip their
9
+ iframe to the content shape so the canvas never shows; only the
10
+ full-screen countdown relies on this dark value. -->
11
+ <meta name="color-scheme" content="dark" />
6
12
  <title>Clips overlay</title>
7
13
  </head>
8
14
  <body>
@@ -161,12 +161,17 @@ async function initBubble(): Promise<void> {
161
161
  ring.appendChild(video);
162
162
  await video.play().catch(() => undefined);
163
163
  console.log("[clips-overlay] camera bubble live");
164
+ // Tell the host the feed is live so it can start the countdown — the "3"
165
+ // shouldn't appear until the camera is actually showing.
166
+ postBubble("camera-ready");
164
167
  } catch (err) {
165
168
  console.warn("[clips-overlay] camera getUserMedia failed:", err);
166
169
  const empty = document.createElement("div");
167
170
  empty.className = "bubble-empty";
168
171
  empty.innerHTML = ICONS.cameraOff;
169
172
  ring.appendChild(empty);
173
+ // Still release the countdown — a blocked/failed camera must not hang it.
174
+ postBubble("camera-ready");
170
175
  }
171
176
  }
172
177
 
@@ -210,43 +215,36 @@ function initCountdown(): void {
210
215
  wrap.append(controls, hint);
211
216
  root.appendChild(wrap);
212
217
 
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;
217
- const render = (): void => {
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
- }
228
- const remainingMs = endsAt - Date.now();
229
- if (state.phase !== "countdown" || remainingMs <= 0) {
230
- if (lastShown !== "Go") {
231
- number.textContent = "Go";
232
- number.classList.add("countdown-go");
233
- lastShown = "Go";
218
+ // Each number is shown via a CHAINED setTimeout — the next step is scheduled
219
+ // one second after the current one actually renders, not on a fixed interval.
220
+ // This is deliberate: when the camera is slow to connect (e.g. an iPhone
221
+ // Continuity Camera) it can hog the main thread and stall a tick. A setInterval
222
+ // would then fire all the missed ticks back-to-back ("3"… then "2 1 Go" in a
223
+ // burst); chaining means a stall only delays the next number, it never bursts.
224
+ // At "Go" we tell the worker to start the recorder; the worker's own timer is
225
+ // just a fallback for pages where no overlay can be injected.
226
+ const STEP_MS = 1000;
227
+ const steps = ["3", "2", "1", "Go"];
228
+ let doneSent = false;
229
+
230
+ const showStep = (index: number): void => {
231
+ const text = steps[index];
232
+ number.textContent = text;
233
+ number.classList.toggle("countdown-go", text === "Go");
234
+ number.style.animation = "none";
235
+ void number.offsetWidth;
236
+ number.style.animation = "";
237
+ if (text === "Go") {
238
+ if (!doneSent) {
239
+ doneSent = true;
240
+ send("CLIPS_OVERLAY_COUNTDOWN_DONE");
234
241
  }
235
242
  return;
236
243
  }
237
- const text = String(Math.ceil(remainingMs / 1000));
238
- if (text !== lastShown) {
239
- number.textContent = text;
240
- number.classList.remove("countdown-go");
241
- number.style.animation = "none";
242
- void number.offsetWidth;
243
- number.style.animation = "";
244
- lastShown = text;
245
- }
244
+ window.setTimeout(() => showStep(index + 1), STEP_MS);
246
245
  };
247
- countdownRender = render;
248
- window.setInterval(render, 100);
249
- render();
246
+
247
+ showStep(0); // "3" immediately, then chain "2" → "1" → "Go"
250
248
  }
251
249
 
252
250
  /* --------------------------------------------------------------- toolbar --- */
@@ -341,8 +339,6 @@ function initToolbar(): void {
341
339
  function initSaving(): void {
342
340
  const card = document.createElement("div");
343
341
  card.className = "saving-card";
344
- const spinner = document.createElement("div");
345
- spinner.className = "saving-spinner";
346
342
  const caption = document.createElement("div");
347
343
  caption.className = "saving-caption";
348
344
  caption.textContent = "Saving clip…";
@@ -351,7 +347,8 @@ function initSaving(): void {
351
347
  const fill = document.createElement("div");
352
348
  fill.className = "saving-bar-fill";
353
349
  bar.appendChild(fill);
354
- card.append(spinner, caption, bar);
350
+ // One progress indicator only — the indeterminate bar (no circular spinner).
351
+ card.append(caption, bar);
355
352
  root.appendChild(card);
356
353
  }
357
354
 
@@ -371,7 +368,6 @@ const state: OverlayState = {
371
368
  countdownEndsAtMs: 0,
372
369
  };
373
370
  let toolbarRender: (() => void) | null = null;
374
- let countdownRender: (() => void) | null = null;
375
371
 
376
372
  chrome.runtime.onMessage.addListener((message) => {
377
373
  if (!message || typeof message !== "object") return;
@@ -386,7 +382,6 @@ chrome.runtime.onMessage.addListener((message) => {
386
382
  if (typeof next.countdownEndsAtMs === "number")
387
383
  state.countdownEndsAtMs = next.countdownEndsAtMs;
388
384
  toolbarRender?.();
389
- countdownRender?.();
390
385
  });
391
386
 
392
387
  if (part === "bubble") void initBubble();
@@ -834,27 +834,11 @@ async function init(): Promise<void> {
834
834
  window.setInterval(() => renderActiveRecording(activeRecording), 1000);
835
835
  }
836
836
 
837
- // Pre-record preview: while this popup is open and idle, show the live face
838
- // bubble on the active tab (desktop-app style). The port stays open for the
839
- // popup's lifetime; its disconnect (popup closed) tells the worker to remove
840
- // the preview. Keep the reference so the port isn't garbage-collected early.
841
- const previewPort = chrome.runtime.connect({ name: "clips-preview" });
842
- const syncPreview = (): void => {
843
- const wantsCamera =
844
- !activeRecording &&
845
- !isUnsupportedPage(activeTab?.url) &&
846
- (settings.captureSurface === "camera" || settings.includeCamera);
847
- try {
848
- chrome.runtime.sendMessage(
849
- { type: "CLIPS_PREVIEW", camera: wantsCamera },
850
- () => void chrome.runtime.lastError,
851
- );
852
- } catch {
853
- /* worker asleep; will re-sync on next interaction */
854
- }
855
- };
856
- void previewPort;
857
- syncPreview();
837
+ // No on-page pre-record preview. A Chrome action popup closes the instant you
838
+ // click the page, so an interactive on-page bubble before recording isn't
839
+ // possible the face bubble appears only once recording starts. syncPreview
840
+ // is kept as a no-op so the settings handlers below stay unchanged.
841
+ const syncPreview = (): void => {};
858
842
 
859
843
  for (const button of document.querySelectorAll<HTMLButtonElement>(
860
844
  ".mode-option",
@@ -1080,7 +1064,7 @@ async function init(): Promise<void> {
1080
1064
  start.addEventListener("click", async () => {
1081
1065
  start.disabled = true;
1082
1066
  signIn.hidden = true;
1083
- setStatus("Checking sign in...");
1067
+ setStatus(""); // no chatty "Checking…/Starting…" text — the disabled button is enough
1084
1068
  authStatus = await readAuthStatus(settings);
1085
1069
  if (authStatus === "signed-out") {
1086
1070
  start.disabled = false;
@@ -1089,7 +1073,6 @@ async function init(): Promise<void> {
1089
1073
  setStatus("");
1090
1074
  return;
1091
1075
  }
1092
- setStatus("Checking video storage...");
1093
1076
  const storageConfigured = await readVideoStorageConfigured(settings);
1094
1077
  if (!storageConfigured) {
1095
1078
  start.disabled = false;
@@ -1112,7 +1095,6 @@ async function init(): Promise<void> {
1112
1095
  window.close();
1113
1096
  return;
1114
1097
  }
1115
- setStatus("Starting recording...");
1116
1098
  await saveSettings(settings);
1117
1099
  const response = await sendStartMessage(settings);
1118
1100
  if (response.ok) {
@@ -8,6 +8,11 @@ export default defineConfig({
8
8
  root,
9
9
  base: "./",
10
10
  publicDir: "public",
11
+ // Share recording primitives with the web app recorder (templates/clips/shared)
12
+ // — matches the "@shared/*" tsconfig path so imports resolve in both builds.
13
+ resolve: {
14
+ alias: { "@shared": resolve(root, "../shared") },
15
+ },
11
16
  build: {
12
17
  outDir: "dist",
13
18
  emptyOutDir: true,
@@ -1,5 +1,9 @@
1
+ // camera=* (not (self)) so the Clips browser extension's camera bubble — injected
2
+ // as a cross-origin iframe — can run on Clips pages too; (self) only allows the
3
+ // app's own same-origin camera. Display-capture stays same-origin. The browser
4
+ // still prompts for camera/mic permission per origin.
1
5
  export const MEDIA_CAPTURE_PERMISSIONS_POLICY =
2
- "camera=(self), microphone=(self), display-capture=(self), geolocation=(), screen-wake-lock=()";
6
+ "camera=*, microphone=(self), display-capture=(self), geolocation=(), screen-wake-lock=()";
3
7
 
4
8
  export function withMediaCapturePermissions(response: Response): Response {
5
9
  const headers = new Headers(response.headers);