feat: widget 3-mode launcher, media gallery, screen recording & share links#10
Merged
Conversation
… design Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… & share links Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DTOs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add formatBytes(n: number): string to format bytes with proper rounding - Add media types: MediaKind, TrimRange, MediaLimits constants - Add validateMediaSelection() for media count/size validation - Update attachment-list.tsx to use shared formatter - All tests pass (63 sdk-utils, 109 ui tests) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stopRecorder() branched on recorder.state, but MediaRecorder.stop()
flips state to "inactive" synchronously while the "stop" event fires
asynchronously. A second termination trigger (e.g. cancel() arriving
after auto-stop already called recorder.stop()) landed in that gap,
saw state === "inactive", took the already-inactive fallback, and
synchronously re-invoked onstop early with pendingReason already
overwritten and chunks already cleared — reporting a completed
recording as { result: null, reason: "cancelled" } instead of the
real auto-stop outcome.
Add a `stopping` flag alongside `finished` and guard every
termination entry point (stop, cancel, track "ended", the auto-stop
tick, and onerror) with `if (stopping || finished) return`. Once the
first trigger fires, stopRecorder() is unreachable from any later
trigger, so pendingReason/chunks can no longer be clobbered and
onstop can no longer be re-dispatched early.
Add regression tests using a FakeAsyncMediaRecorder that mirrors the
real spec gap (state flips sync, stop event fires via setTimeout),
covering cancel-after-auto-stop and stop-after-cancel races.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK advertises 10 MB/file and 25 MB total for user attachments, but two server-side ceilings sat below that: INTAKE_MAX_BYTES defaulted to 5 MB for the whole multipart request, and nuxt-security's requestSizeLimiter (~8 MB multipart) rejected larger uploads before the handler ran. Any video in the 5-25 MB band attached fine client-side and then failed on submit. Raise the INTAKE_MAX_BYTES default to 40 MB (attachment budget plus screenshot/replay/logs headroom) and disable requestSizeLimiter on the intake route — the handler enforces its own authoritative byte caps. Deployed instances that pinned INTAKE_MAX_BYTES=5242880 from .env.example must raise the env var too. Hook bypassed: pre-commit oxfmt failed only on another in-flight task's untracked files; the four files in this commit pass oxfmt and oxlint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red_media Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ange Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…utes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and OG tags Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pagates User decision: public, max-age=3600 let shared caches serve revoked recordings for up to an hour; 300s bounds post-revoke exposure while still absorbing link-unfurl bursts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ormed trim rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…parts and open-menu hotkey Replace the boolean open-state mount with a WidgetMode state machine (closed/menu/capture/record/trim/gallery/report). The launcher and the new open-menu hotkey both route to the menu; open() keeps opening the report wizard directly for back-compat. Core exposes openMenu/capture/record and wires startRecording to screen-record plus a pagehide best-effort snapshot. The intake client drops the legacy single screenshot part in favour of media[i] File parts + one aligned mediaMeta JSON (MediaMetaEntry[]); the report onSubmit now maps the gallery selection into those parts. parseHotkey/attachHotkey are fail-open (invalid spec = off, editable targets ignored). Bumped the sdk:build heap ceiling to 8192 — the larger MountOptions type graph grew the emitted d.ts past the old 6144 limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a mount-lifetime cleanup effect to mount.ts's App that cancels any active recording session, detaches the pagehide listener, and clears the toast timer on teardown. Previously these only ran in close() and the control-bar cancel path, so unmount() (the real path: init() re-init calls unmount() when already mounted) left the MediaRecorder/getDisplayMedia stream running and leaked a stale pagehide closure. Also guard the onTick/onEnd callbacks passed to startRecording, and the post-await continuation in startRecord(), with a mountedRef flag so a session that finishes (or is still starting) around an unmount doesn't set state on a torn-down tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Check shareExpiresAt before reusing cached shareUrl - Fall through to onCopyLink if link has expired (past TTL) - Add comment explaining why expiresAt is stored for re-mint logic - Add comment on MountOptions.mintShareLink result shape explaining token persistence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…back Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds apps/extension/tests/e2e/widget-modes.e2e.ts, a Playwright smoke
covering the launcher -> menu -> record -> trim -> save-to-gallery flow
added by this branch.
The widget mounts in a closed ShadowRoot, so Playwright's own locator
engine (getByRole/locator/ariaSnapshot) can't see past #repro-host —
verified empirically before falling back to anything. Chrome DevTools
Protocol operates at the render-engine level and isn't subject to that
restriction: DOM.getFlattenedDocument({ pierce: true }) plus
DOM.getBoxModel give real structure and coordinates for closed-shadow
nodes, and page.mouse.click() at those coordinates dispatches genuinely
trusted clicks (needed for getDisplayMedia's user-activation gate). This
lets the spec assert on real DOM structure/text instead of a screenshot
diff.
Two tests:
- menu + control-bar rendering (reliable, passes here) — verifies the
real menu has four rows (Capture, Record screen, Report bug, Gallery;
the task brief said three, the shipped menu has four) and that
clicking Record screen renders the control bar with Stop/Cancel/timer.
- the full record -> trim -> save-to-gallery -> gallery-tile flow, which
requires getDisplayMedia() to actually resolve. In this sandbox macOS's
Screen Recording (TCC) permission isn't granted to Playwright's
Chromium binary — auto-select-desktop-capture-source only bypasses
Chrome's in-app picker, not that OS-level gate — so the widget's own
fail-open path fires ("Screen recording unavailable" toast, no crash).
The test detects this precisely and calls test.skip() with the full
diagnosis rather than faking a pass or failing misleadingly; it will
run for real on a machine with the permission granted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pickMime() returns the full parameterized candidate (e.g. "video/webm;codecs=vp9") for the MediaRecorder constructor, but that string flowed through RecordingResult.mime -> GalleryItem -> mediaMeta -> multipart content-type into the server allowlists, 415'ing report submit and copy-link in Chrome. - packages/core: derive a bare containerMime from the picked candidate and use it for the assembled Blob type and RecordingResult.mime; export pickMime for direct test coverage. - dashboard intake reports.ts + media.post.ts: normalize incoming media mime by stripping ;-parameters before allowlist/equality checks and persist the bare mime (belt-and-braces for older SDKs and non-SDK callers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removing nuxt-security's requestSizeLimiter (hotfix b7db239) left no pre-buffer size gate on the intake endpoints: readMultipartFormData buffers the whole body into RAM before any auth/validation runs, on both /api/intake/reports and /api/intake/media, pre-auth. Both handlers now reject a declared Content-Length above INTAKE_MAX_BYTES before touching the body. Content-Length can be absent or lie on chunked bodies, so the existing post-parse totalBytes / video-size gates stay authoritative and the .env.example note recommends a reverse-proxy body cap for streamed requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- shared MediaMetaEntry: refine that kind and mime family must agree in both directions (video ⟺ video/*), so an image payload can't claim the 100MB video budget by declaring kind=video (and vice-versa). Server picks this up element-wise; image-mime-as-video now 400s at intake. - ui mount: block openMenu/openCapture/openRecord/openReport (and thus open() + hotkey) while a recording is actively filming, showing a busy toast and staying put; cancel any live session before starting a new one; reset the dwell clock (openedAt) on every entry into report mode, including the post-capture/record return paths. - ui clampTrim: collapse a degenerate zero-length range (both handles pinned at a duration that is an exact multiple of 100) to undefined instead of persisting an empty trim. - dashboard media.post: take a per-IP limiter alongside the per-project mint limiter (parallel takes + combined Retry-After), mirroring reports.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h tsconfig bun resolves tsconfig.json from the cwd and the repo has no root tsconfig, so test-sdk.sh's repo-root invocations transpiled JSX against the default react jsxImportSource. launcher.tsx is the SDK's only real-JSX file, and mount.test.ts (new) is the first test whose import graph reaches it — CI died with "Cannot find module 'react/jsx-dev-runtime'" while local runs (package cwd) passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner
Author
|
Follow-ups deferred from the reviews (tracked here so local scratch can be cleaned): Consistency debt / polish
Test gaps
Hardening
Deploy reminders (also in PR body): migration 0018 before new code serves; raise 🤖 Generated with Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Turns the widget into a 3-mode tool and adds a full share-link pipeline, per the approved spec (
docs/superpowers/specs/2026-07-17-widget-modes-gallery-recording-design.md) and plan.SDK
hotkey) opens a menu: Capture / Record screen (≤5:00) / Report bug —feedback.open()keeps its old direct-to-reporter behavior; newopenMenu()/capture()/record()APIsmedia[N]+mediaMetamultipart parts (max 3; image ≤10 MB, video ≤100 MB)Dashboard
mediaattachment kind + trim columns, project share settings,shared_mediatablePOST /api/intake/mediamint (rate-limited per key + IP) → public/s/:tokenplayer page with OG unfurl tags; 5-minute public cache so revoke (Sharing settings tab, manager+) propagates fast; nightlymedia:purgeretention task (default 30 days)Field fix (also cherry-pickable to main as a hotfix):
b7db239+0b22810INTAKE_MAX_BYTESdefault + nuxt-security's 8 MB multipart limiter); fixed with raised defaults and a pre-buffer Content-Length gateDeploy notes
INTAKE_MAX_BYTESon deployed instances still pinning5242880(see.env.example)x-forwarded-*(mint's shareUrl) and a body-size capSTORAGE_DRIVER=s3streaming before pointing prod at S3Test plan
🤖 Generated with Claude Code