From 30bf9713c8a7c39b3428fa9c8355404e34ec9a92 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 3 Sep 2026 13:47:22 +0530 Subject: [PATCH 1/8] feat(chrome-extension): streamline recording bar into compact draggable badge on recording start --- apps/chrome-extension/src/content/overlay.css | 191 ++++++++ apps/chrome-extension/src/content/overlay.tsx | 427 +++--------------- .../src/content/recording-bar.tsx | 387 +++++++++------- 3 files changed, 490 insertions(+), 515 deletions(-) diff --git a/apps/chrome-extension/src/content/overlay.css b/apps/chrome-extension/src/content/overlay.css index 7456ae6ab53..cdbe0153e1a 100644 --- a/apps/chrome-extension/src/content/overlay.css +++ b/apps/chrome-extension/src/content/overlay.css @@ -1288,3 +1288,194 @@ transform: translateY(0) scale(1); } } + +.cap-extension-active-recording-container { + position: fixed; + z-index: 2147483647; + display: flex; + align-items: center; + user-select: none; + touch-action: none; + animation: cap-extension-badge-in 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.cap-extension-active-recording-container.is-dragging { + cursor: grabbing; +} + +.cap-extension-recording-badge { + position: relative; + display: flex; + align-items: center; + gap: 10px; + height: 48px; + padding: 6px 14px 6px 6px; + border: 1px solid rgba(18, 18, 23, 0.12); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 14px 34px rgba(0, 0, 0, 0.18), + 0 4px 12px rgba(0, 0, 0, 0.08); + cursor: grab; + backdrop-filter: blur(20px); + transition: + transform 160ms ease, + box-shadow 160ms ease, + border-color 160ms ease; +} + +.cap-extension-recording-badge.is-counting { + width: 48px; + padding: 0; + justify-content: center; + border-color: #ef4444; + box-shadow: + 0 0 0 4px rgba(239, 68, 68, 0.2), + 0 14px 34px rgba(0, 0, 0, 0.18); +} + +.cap-extension-badge-countdown-ring { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.cap-extension-badge-countdown-number { + font-size: 22px; + font-weight: 800; + color: #ef4444; + line-height: 1; + animation: cap-extension-countdown-pop 400ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes cap-extension-countdown-pop { + 0% { + transform: scale(0.6); + opacity: 0.5; + } + 50% { + transform: scale(1.15); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.cap-extension-recording-badge:hover { + transform: scale(1.05); + border-color: rgba(18, 18, 23, 0.25); + box-shadow: + 0 18px 42px rgba(0, 0, 0, 0.22), + 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.cap-extension-recording-badge.is-active { + border-color: #3b82f6; + box-shadow: + 0 0 0 3px rgba(59, 130, 246, 0.25), + 0 14px 34px rgba(0, 0, 0, 0.18); +} + +.cap-extension-recording-badge-icon-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; +} + +.cap-extension-recording-badge-logo { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: contain; +} + +.cap-extension-recording-badge-icon-wrap .cap-extension-control-bar-dot { + position: absolute; + right: -1px; + bottom: -1px; + width: 10px; + height: 10px; + border: 2px solid #fff; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3); +} + +.cap-extension-recording-badge-time { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #18181b; + white-space: nowrap; +} + +.cap-extension-recording-badge-time.is-warning { + color: #e5484d; +} + +.cap-extension-recording-popover { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 10; + display: flex; + align-items: center; + padding: 4px 8px; + border: 1px solid rgba(18, 18, 23, 0.1); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 16px 36px rgba(0, 0, 0, 0.2), + 0 4px 12px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(20px); +} + +.cap-extension-popover-header { + display: flex; + align-items: center; + gap: 8px; +} + +.cap-extension-popover-status { + display: flex; + align-items: center; + gap: 6px; +} + +.cap-extension-popover-grip { + color: #a1a1aa; + cursor: grab; +} + +.cap-extension-popover-time { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #18181b; +} + +.cap-extension-popover-time.is-warning { + color: #e5484d; +} + +.cap-extension-popover-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +@keyframes cap-extension-badge-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/apps/chrome-extension/src/content/overlay.tsx b/apps/chrome-extension/src/content/overlay.tsx index 1a55d5a6e75..12bb50b0016 100644 --- a/apps/chrome-extension/src/content/overlay.tsx +++ b/apps/chrome-extension/src/content/overlay.tsx @@ -1,12 +1,4 @@ -import { - Circle, - FlipHorizontal, - Maximize2, - PictureInPicture, - RectangleHorizontal, - Square, - X, -} from "lucide-react"; +import { X } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, useCallback, @@ -56,10 +48,6 @@ import { replayStartupMessages, setStartupMessages } from "./startup-messages"; const ROOT_ID = "cap-extension-recorder-overlay"; const WINDOW_PADDING = 20; const BAR_HEIGHT = 52; -// The iframe tokens are readable from the host page DOM (they sit in the -// iframe src), so token checks alone cannot authenticate window messages. -// Frames we embed always run on the extension origin; require it too. -const EXTENSION_ORIGIN = new URL(chrome.runtime.getURL("")).origin; const createSecureToken = () => { if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); const bytes = new Uint8Array(16); @@ -73,12 +61,6 @@ const PREVIEW_TOKEN = createSecureToken(); const PREVIEW_URL = chrome.runtime.getURL("camera-preview.html"); const PREVIEW_SRC = `${PREVIEW_URL}#${encodeURIComponent(PREVIEW_TOKEN)}`; const PREVIEW_ERROR_DELAY_MS = 1200; -const PANEL_TOKEN = createSecureToken(); -const PANEL_URL = chrome.runtime.getURL("popup.html"); -const PANEL_SRC = `${PANEL_URL}#${encodeURIComponent(PANEL_TOKEN)}`; -const PANEL_WIDTH = 300; -const PANEL_DEFAULT_HEIGHT = 460; -const PANEL_MARGIN = 16; const CAMERA_MIN_SIZE = 120; const CAMERA_MAX_SIZE = 420; const CAMERA_RESIZE_CORNERS = ["nw", "ne", "sw", "se"] as const; @@ -325,11 +307,6 @@ const connectCameraPreview = async ( peer.close(); throw new Error(response.error); } - if (!response.answer) { - peer.close(); - throw new Error("Camera preview did not return an answer."); - } - await peer.setRemoteDescription(response.answer); return { peer, @@ -337,212 +314,6 @@ const connectCameraPreview = async ( }; }; -type PanelFrameMessage = - | { - source: "cap-extension-panel"; - token: string; - type: "size"; - height: number; - } - | { - source: "cap-extension-panel"; - token: string; - type: "dismiss"; - }; - -const isPanelFrameMessage = (value: unknown): value is PanelFrameMessage => { - if (!value || typeof value !== "object") return false; - const candidate = value as Partial; - if ( - candidate.source !== "cap-extension-panel" || - candidate.token !== PANEL_TOKEN - ) { - return false; - } - if (candidate.type === "dismiss") return true; - return ( - candidate.type === "size" && - typeof candidate.height === "number" && - Number.isFinite(candidate.height) - ); -}; - -function RecorderPanelOverlay({ - onDismiss, - onOpenChange, -}: { - onDismiss: () => void; - onOpenChange: (open: boolean) => void; -}) { - const [open, setOpen] = useState(false); - const [pageVisible, setPageVisible] = useState( - () => document.visibilityState === "visible", - ); - const [contentHeight, setContentHeight] = useState(PANEL_DEFAULT_HEIGHT); - const [viewportHeight, setViewportHeight] = useState( - () => window.innerHeight, - ); - const [tokenReady, setTokenReady] = useState(false); - const startupReplayedRef = useRef(false); - - useEffect(() => { - onOpenChange(open); - }, [onOpenChange, open]); - - useEffect(() => { - if (!open || tokenReady) return; - let disposed = false; - void ensureOverlayTokensRegistered().then((registered) => { - if (!disposed && registered) setTokenReady(true); - }); - return () => { - disposed = true; - }; - }, [open, tokenReady]); - - // The open flag lives in chrome.storage.session so the panel follows the - // user across tabs: opening or closing it anywhere applies everywhere. - useEffect(() => { - let disposed = false; - - const syncPanelState = () => { - loadSharedUiState() - .then((state) => { - if (!disposed) setOpen(state.panelOpen); - }) - .catch(() => undefined); - }; - - const handleStorageChange = ( - changes: Record, - areaName: string, - ) => { - if (areaName === "session" && changes[SHARED_UI_STATE_KEY]) { - syncPanelState(); - } - }; - - syncPanelState(); - chrome.storage.onChanged.addListener(handleStorageChange); - return () => { - disposed = true; - chrome.storage.onChanged.removeListener(handleStorageChange); - }; - }, []); - - useEffect(() => { - const handleVisibility = () => - setPageVisible(document.visibilityState === "visible"); - document.addEventListener("visibilitychange", handleVisibility); - return () => - document.removeEventListener("visibilitychange", handleVisibility); - }, []); - - const closePanel = useCallback(() => { - setOpen(false); - onDismiss(); - void updateSharedUiState((current) => ({ - ...current, - panelOpen: false, - updatedAt: Date.now(), - })).catch(() => undefined); - }, [onDismiss]); - - useEffect(() => { - const handleMessage = ( - message: unknown, - _sender: chrome.runtime.MessageSender, - sendResponse: (response?: unknown) => void, - ) => { - if (!isOverlayMessage(message)) return false; - if (message.type === "overlay-panel-toggle") { - sendResponse({ ok: true }); - // Flip the shared flag; every tab (this one included) follows the - // storage change. Reopening also resurfaces a dismissed ready bar. - void updateSharedUiState((current) => ({ - ...current, - panelOpen: !current.panelOpen, - readyBarDismissed: current.panelOpen - ? current.readyBarDismissed - : false, - updatedAt: Date.now(), - })) - .then((state) => setOpen(state.panelOpen)) - .catch(() => undefined); - return false; - } - if (message.type === "overlay-panel-hide") { - sendResponse({ ok: true }); - closePanel(); - return false; - } - return false; - }; - - chrome.runtime.onMessage.addListener(handleMessage); - if (!startupReplayedRef.current) { - startupReplayedRef.current = true; - replayStartupMessages(handleMessage); - } - return () => chrome.runtime.onMessage.removeListener(handleMessage); - }, [closePanel]); - - useEffect(() => { - const handleFrameMessage = (event: MessageEvent) => { - if (event.origin !== EXTENSION_ORIGIN) return; - if (!isPanelFrameMessage(event.data)) return; - if (event.data.type === "size") { - setContentHeight(Math.max(320, Math.ceil(event.data.height))); - return; - } - closePanel(); - }; - - window.addEventListener("message", handleFrameMessage); - return () => window.removeEventListener("message", handleFrameMessage); - }, [closePanel]); - - useEffect(() => { - if (!open) return; - const handleResize = () => setViewportHeight(window.innerHeight); - handleResize(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [open]); - - // Only the tab on screen renders the iframe; hidden tabs keep just the - // shared flag so the panel reappears instantly when they come forward. - // The iframe also waits for its token registration so the panel page can - // verify it was embedded by this extension. - if (!open || !pageVisible || !tokenReady) return null; - - const height = Math.min(contentHeight, viewportHeight - PANEL_MARGIN * 2); - - return ( - <> -