# Contributing to BetterShot Thanks for helping improve BetterShot. Contributions can be code, documentation, clear bug reports, or feedback on accessibility and everyday workflows. This guide covers getting a development build running, finding the right code, and validating a change. Read [AGENTS.md](AGENTS.md) before editing: it is the shared source of truth for native UI, capture behavior, persistence, and agent rules. [CLAUDE.md](CLAUDE.md) imports those same instructions. Our [Code of Conduct](CODE_OF_CONDUCT.md) applies to project participation. [Getting started](#getting-started) · [Code map](#code-map) · [Making a change](#making-a-change) · [Validation](#validation) · [Submitting changes](#submitting-changes) ## Before you start Search [existing issues](https://github.com/KartikLabhshetwar/better-shot/issues) for related work. Small fixes and documentation improvements can go straight to a pull request. For a substantial feature or a change to an established workflow, open an issue describing the problem and proposed behavior first. A useful bug report includes: - BetterShot version, macOS version, and Mac architecture. - Steps to reproduce, expected behavior, and actual behavior. - Relevant context, such as multiple displays, capture mode, or export settings. - A screenshot, short recording, or error message when available, with private information removed. ## Getting started ### Requirements - macOS 14 or later to run BetterShot. Microphone narration needs macOS 15, and narration subtitles and teleprompter speech tracking need macOS 26. Gate those APIs with `#available` and keep the macOS 14 fallbacks working. - Xcode 26 or later, including its command-line tools. Open Xcode once to finish setup and select it under **Xcode > Settings > Locations > Command Line Tools**. - [XcodeGen](https://github.com/yonaskolb/XcodeGen), available through Homebrew. Check the active toolchain with `xcodebuild -version`. Apple's standalone Command Line Tools installation does not replace the full Xcode app for this project. ### First build Fork the repository on GitHub, then clone your fork (replace `YOUR_USERNAME`): ```bash brew install xcodegen git clone https://github.com/YOUR_USERNAME/better-shot.git cd better-shot git switch -c fix/describe-the-change make release open .build/Build/Products/Release/BetterShot.app ``` `make release` generates the Xcode project and produces an **unsigned Release build**. It is the simplest build path for a fork and the one CI uses. No maintainer signing identity or cloud credentials are needed to build or test. ### Working in Xcode ```bash make generate open BetterShot.xcodeproj ``` Choose your own signing team or **Sign to Run Locally** for interactive development. Keep signing changes local. `make build` and `make run` inherit the signing settings in [project.yml](project.yml), which reference the maintainer's identity. Treat `project.yml` as the project configuration source. `make generate` regenerates the project and syncs version/build values from [version.json](version.json), so changes made only in the generated project may be overwritten. The app uses Swift 5 language compatibility with main-actor default isolation and approachable concurrency. Standalone checks compile in Swift 6 mode. Keep the existing compiler settings unless changing them is the task itself. ### Everyday commands Run these from the repository root: | Command | Purpose | | --- | --- | | `make generate` | Sync version/build and regenerate the Xcode project | | `make release` | Build unsigned Release, as CI does | | `make build` | Build Debug with configured signing | | `make run` | Build Debug, stop the running BetterShot process, and launch the new app | | `make test` | Build unsigned Debug and run standalone, editor, and export checks | | `make test-build` | Clean and build unsigned Release | | `make clean` | Remove build artifacts | | `make version` | Print the version from `version.json` | **Finish active captures and save your work before `make run`**: it terminates the running app. `make lint` can display compiler diagnostics, but its recipe can mask a failed build; use a real build or `make test` as the gate. ### Website development [bettershot-landing/](bettershot-landing/) is a separate Next.js/React/Tailwind project. It is not part of the native app build. With Node.js and pnpm installed: ```bash cd bettershot-landing pnpm install --frozen-lockfile pnpm dev ``` Before submitting website changes, run `pnpm lint` and `pnpm build` from that directory. Follow the website rules in [AGENTS.md](AGENTS.md), including use of existing Tailwind tokens, `cn`, and installed Radix components. Website-only changes do not need README or changelog edits unless explicitly requested. ## Code map | Location | Start here for | | --- | --- | | [Sources/App/](Sources/App/) | App lifecycle, onboarding, and URL actions | | [Sources/Capture/](Sources/Capture/) | Screenshot orchestration, selection, OCR, and color picking | | [Sources/Preview/](Sources/Preview/) | Floating capture deck, private staging, and pinned images | | [Sources/History/](Sources/History/) | Capture records, retention, and source/export path resolution | | [Sources/Models/](Sources/Models/) | Preferences, capture models, backgrounds, and onboarding state | | [Sources/Services/](Sources/Services/) | Shortcut catalog and dispatch, image framing, and updater | | [Sources/Settings/](Sources/Settings/) | Native settings and preference bindings | | [Sources/Sharing/](Sources/Sharing/) | R2 credentials, signed requests, uploads, and share manifests | | [Sources/Views/](Sources/Views/) | Menu tray, onboarding views, shared surfaces, and status toasts | | [Sources/BetterShot/](Sources/BetterShot/) | Image editor, recording, video studio, and rendering | | [Resources/](Resources/) | App/menu icons, backgrounds, entitlements, and onboarding media | | [Tests/](Tests/) | Standalone regression checks and integration programs | | [bettershot-landing/](bettershot-landing/) | Website and public share viewer | Inside `Sources/BetterShot/`, look for `Anno*` and `Annotation*` files for image editing, `Recording*` for video capture and editing, and `Teleprompter*` for the script overlay. ## Making a change Trace the user action through its callers, storage, preview, and export before editing. Reuse existing helpers and native controls; fix shared behavior where all affected callers meet. Keep unrelated cleanup out of the diff. ### Capture and saving The screenshot path runs through `ShortcutService`, `CaptureOrchestrator`, `ScreenCapture`, private staging/history, and the preview or image editor. Scrolling Capture is the `.scrollCapture` action: the menu bar, capture bar, shortcut, and `bettershot://capture/scroll` all reach it through `CaptureOrchestrator.performCapture`. It selects an area with `RegionSelectionOverlay` and uses `ScrollCaptureSessionPresenter` to Stop into that same private preview flow or Cancel/Escape without staging a file. `ScrollCaptureController` ports MacShot's engine: frames come from `CGWindowListCreateImage` (loaded at runtime) for windows below the selection outline, so BetterShot's panels never appear in them. Vision registration and merging run off the main actor, and manual, automatic, and final captures are serialized. The reference frame only advances when a strip is appended or the page is unchanged, so scrolling back after a missed join recovers. Every join redraws the overlap below the pinned header from the newer frame, and Stop's final frame refreshes the page end even without further scrolling, so content that fades in on scroll is captured settled. The live preview is downscaled off the main actor. `ScrollFrameAnalyzer` compares native pixel strides and channel layouts; never assume captured rows are packed. Auto Scroll posts continuous pixel-unit scroll events sized to the area, which mouse utilities such as Mac Mouse Fix pass through unchanged; line events get reversed or smoothed. It pauses on app changes and stops at page end, after repeated misses, or at the size limit. Run `BETTERSHOT_CHECK_CAPTURE_UI=1 bash Tests/run-exports.sh` after a test build to check stitching and the compact capture controls in both appearances. Region screenshots use `RegionSelectionOverlay` with the previous area (`AppPreferences.lastRegionRect`) preselected, so Return captures it again, then reactivate the previously frontmost app before `screencapture -R` takes the shot. `AppPreferences.preselectPreviousRegion`, exposed as Start with the last area selected in Settings > Capture > Region, defaults on. Turning it off opens every use of the selector empty, including scrolling and recording areas, and still stores the chosen area for Capture Previous Region. `ScreenCapturePermission.isReadyToCapture` runs before every screenshot except the color picker, and before recording sources load. Once BetterShot has seen Screen Recording access, a later launch that preflights as denied, as a login item can before macOS applies the grant, offers a restart once instead of failing. It never runs at launch. OCR keeps macOS's native `/usr/sbin/screencapture -i` selector. Region screenshots can instead use macOS's native selector via `AppPreferences.nativeRegionSelector`, exposed as Use native macOS region selector in Settings > Capture > Region. It defaults off. `ScreenCapture.captureRegion` checks it first and reuses `interactiveShot(window: false, includeShadow: false)` without activating BetterShot, so open menus and popovers remain available for capture. It does not update the remembered rectangle; Capture Previous Region keeps the last area chosen with BetterShot's selector. Scrolling and recording area selection still use BetterShot's selector because they need the rectangle. `AppPreferences.captureRegionOnRelease`, exposed as Capture as soon as I let go in Settings > Capture > Region, chooses immediate capture on release or adjustment before confirmation in BetterShot's selector, which preserves the remembered rectangle on both paths. Native selection always captures on release, so the control is disabled there. `AppPreferences.freezeScreenDuringRegionCapture`, exposed as Freeze the screen while selecting, snapshots every display with `FrozenScreenCapture` before the selector appears. `RegionSelectionOverlay` shows each frame behind its selection view, and `FrozenScreenFrame` crops the confirmed area from that frame at native pixel size; never take a second shot after confirmation. It defaults off and applies only to region screenshots using BetterShot's selector: the countdown runs first, and native-selector, window, OCR, previous-region, scrolling, and recording captures stay live. The setting is disabled while native selection is on, and native mode must never snapshot displays. `Tests/FrozenScreenFrameCheck.swift` covers crop origins, scales, and PNG pixels. Window screenshots use `/usr/sbin/screencapture -i -w`, retaining cancellation, shadow options, native pixel dimensions, and private PNG staging. OCR and color results copy through `CaptureOrchestrator.completeTextCapture`; empty OCR must not erase the clipboard. Recording areas use BetterShot's adjustable AppKit selector. `RecordingPickerControls` owns the compact `RecordingOptionsView`. Keep the 0.5.4 Display/Window/Area and Camera/Mic/Audio/Script strip in the floating bar. Discard and Start Over, including their shortcuts, use `RecordingBarPresenter.confirmRecordingAction` and a standalone `NSAlert.runModal()`. Do not attach SwiftUI alerts or sheets to the transparent recording panel: its oversized hosting area becomes an opaque backdrop. Cancel preserves the recording. Run `BETTERSHOT_CHECK_CAPTURE_UI=1 bash Tests/run-exports.sh` after building for focused light/dark capture layout checks without generating a video fixture. Keep source selection separate from starting a recording, preserve permission checks for camera/microphone, and keep screenshot and recording delays distinct. Every screenshot starts in `DeckStaging`. Copy only updates the clipboard and retains a private file for file-based paste targets. Edit, Pin, Share, and drag-out retain working files internally. Only explicit Save/Export, capture-and-save, or enabled automatic saving writes a deliverable to the configured folder. Automatic screenshot saving defaults on for new installs. At launch, use `OnboardingState.prepareForLaunch` before other preference migrations to identify a new install, then `AfterCaptureActions.prepareForLaunch` to preserve upgrades' explicit choices and previous unset/off behavior. Unfinished onboarding is not a new install. Restore General defaults uses the new-install saving default. Explicit Copy/Edit/Pin actions bypass automatic saving, and failed saves keep the capture for retry. Keeping screenshot previews open protects only private staged files. Saved and retained previews follow Hide After. The timer checks the live pointer and tool focus while the panel is key; hover/focus callbacks must not cancel its task. Image-editor Save commits to history and creates or atomically replaces the associated export, even for an untouched image. Copy and Share do not update that export. Export opens a save panel for a new destination. A recording package's `exportPath` metadata names its save-folder file. `RecordingDeliverable.save` replaces that file in its own format while it exists, and otherwise writes a new file and records it. Video-editor Save re-renders it; video-editor Export always writes a new file. `checkStudioSaveUpdatesSavedVideo` in `Tests/ExportIntegration.swift` covers this against the production editor. [ScreenshotFileNaming](Sources/BetterShot/ScreenshotFileNaming.swift) is the shared, Foundation-only deliverable name renderer. Preserve sanitization and keep previews from advancing counters. Recording package directory names identify projects and must not change with the deliverable template. `captureName` already removes the format extension; append the requested extension without stripping another dotted suffix. Video saves stage the complete copy/remux before replacing an export, and choose collision-free default names after asynchronous conversion. Keep the naming and failed-save regressions in `Tests/ExportIntegration.swift` against these production paths. ### Editing, rendering, and persistence Image annotations use source-pixel coordinates. `AnnoShapeDrawing` is shared by the canvas and export; `AnnotationImageTransform` handles full-resolution rotation and reflection while retaining editable annotations and undo history. A low-resolution preview must never become an export source. Recording capture starts at `RecordingCaptureEntry` and `ScreenRecordingManager`. Packages retain `screen.mov`, optional `camera.mov`, input/capture/edit metadata, and a flattened deliverable. Source movies remain unchanged. Input capture is limited to the supported pointer and shortcut events; never record plain typing. `RecordingStudioLayout`, in [RecordingStudioStyle.swift](Sources/BetterShot/RecordingStudioStyle.swift), supplies shared screen/camera geometry. Preview, export, saved projects, undo, and render cache invalidation must agree on camera layouts, crop, masks, cursor styling, and effects. Camera frame ratio is independent of canvas ratio. Older projects must retain compatible defaults and saved artwork, including legacy Hand cursors and the `macOS` storage key for Arrow. `ViewportTimeline.build` adapts Cap's `zoom_spring.rs`: magnification and a travel-space center (0 is flush left/top, 1 flush right/bottom) use separate springs, so every frame stays inside the source. The center pre-aims while unzoomed, holds its last framing while zooming out, and Auto cues follow fixed pointer regions (50% by 70% of the zoomed view) instead of raw samples. Instant cues snap within 0.1 seconds of their boundaries. `checkZoomCamera` in the export integration run covers these rules against production code. `GradientPreset.presets` in [BackgroundStyle.swift](Sources/Models/BackgroundStyle.swift) is the palette source. General > Default Look initializes new media; editing a saved project must not overwrite those defaults. No Background retains framing settings for reuse. MP4 has no alpha channel, so uncovered areas render black. ### UI, shortcuts, and automation Use the shared editor chrome, inspector components, and `InspectorSlider`; keep both inspectors on the left. Preserve keyboard focus, text editing, accessible action names, light/dark appearances, and reduced transparency/motion. The full interaction requirements live in [AGENTS.md](AGENTS.md). Media Gallery treats local availability and cloud links independently: a shared item with a local source belongs in both locations. Preserve package-based resolution when recording exports move or disappear, native table sorting, single-click selection, double-click opening, and deletion confirmations. Gallery and Settings use resizable native navigation columns. `ShortcutRecorderField` uses a native search-field bezel, focus ring, and inline clear button. Recording suspends global shortcuts through the existing `ShortcutService` recording guard, commits through `validationError`, and cancels on focus/window loss. With `clearsOnDelete`, set for global-scope rows, unmodified Delete clears; editor-scope rows record it (`imageDelete` and `videoDelete` default to it), and modified Delete is always recordable. [ShortcutCatalog.swift](Sources/Services/ShortcutCatalog.swift) defines action IDs, groups, scopes, and defaults. Do not renumber persisted IDs. New actions start unassigned; customized and disabled bindings must survive migration. Active editor bindings take priority over matching global bindings without intercepting normal text-field behavior. `KeyLabel` owns layout-aware shortcut labels, special-key glyphs, VoiceOver names, and the recording overlay's existing special-key style. Input-source changes refresh labels without changing stored key codes. Number-row labels stay digits, and printable keypad keys still pass through the recording modifier guard. [CaptureURLAction.swift](Sources/App/CaptureURLAction.swift) parses the supported `bettershot://` routes. The app delegate dispatches them. Preserve malformed-URL rejection and the guard against starting a second recording session. ### 3D video shots `Recording3DShot.swift` owns pose limits, perspective projection, presets, and the sorted, binary-searched shot timeline. Times refer to the edited movie, as mask ranges do; clip trims/speed changes clamp the effective track without deleting authored shots. Missing `shots3D` fields mean no effect in legacy projects. `RecordingStudioModel` handles undo, draft/save/discard, and render invalidation. `Recording3DInspector` and `Recording3DLane` use the existing inspector controls. The timeline’s **+ Add** menu creates Zoom and 3D Shot segments at the playhead. Only allocate the cut-marker row when there are cuts; lane backgrounds, edit content, and total height must use the same visibility rules. The optional `Recording3DPose.camera` stores independent camera orbit, screen fold, distance, vertical field of view, and camera-plane pan. Legacy poses omit it and keep the previous projection. Preset selection writes both endpoints, linear timing, and zero boundary transition; angles include a slow drift, with Still available explicitly. Named scenes retain their relative shot durations. `Recording3DTimeline.autoScene` shares Cap's scene pool, one-second minimum, weighted boundaries, and nearby clip-cut snapping with named scenes. Short ranges use a prefix of the scene; manually authored/legacy shots retain their 0.2-second minimum. Clip boundaries come from cumulative edited durations, including speed. `suggested3DScene` is transient: only `previewTimeline3D` reads it. Cancel restores the playhead, Apply writes one undoable edit, and save/export never read suggestions. Keep Looks, Camera, Depth Blur, Keyframes, and Timing expanded as separate cards. The fixed section shortcuts above the inspector scroll to their section IDs; do not hide granular controls behind an Edit menu. Screen fold and lens controls stay visible with the other camera sliders. Animated-property buttons select the existing keyframe editor, including its graph and numeric Bézier handles. Start/End thumbnails select the pose being edited. `Recording3DOrbitPad` changes only its tilt axes through the same model update and undo group as numeric entry; disable the pad while either axis has keyframes. Keep exact sliders available for keyboard and accessibility use. Preset/scene grids adapt to inspector width. `Recording3DAnimation` stores optional focus settings and per-property keyframes. Keyframe positions are fractions of a shot, so resizing preserves relative timing. Tracks are normalized at edit/load time and binary-searched while rendering; Bézier control points are bounded and inverted with a fixed iteration limit. Format 8 keeps new fields optional for older projects; independent entry/exit times fall back to the legacy shared transition. Reversing or flipping shots also transforms their curves and focus settings. For projects with 3D shots, `Recording3DPreview` reads AVPlayer video outputs and uses `StudioFrameCompositor.composedImage` directly in a Metal view. Keep at most two preview frames in flight and remove the outputs when the view disappears. The preview player supplies already-masked pixels; the compositor must not apply those masks a second time. Subtitles stay in SwiftUI, above the effect. Preview and export share one perspective warp plus `Recording3DBlurRenderer`’s Gaussian/three-ring disc kernels adapted from Cap. Kernels compile once, and blur strength scales from 1080p, and the None/flat paths skip the extra passes. The screen, masks, pointer, and camera share the transform. Keyboard captions stay sharp above blur, while the background stays in canvas space and participates in focus blur. Crop and mask editing keep the native flat player so source-coordinate handles remain usable. No video pixels are read back to CPU. The inverse warp clips rays behind the camera instead of pulling the camera back. A transparent full-canvas input prevents texture-edge clamping from filling padded areas with video. Zoom magnifies this whole plane and recenters its target, while flat card/camera shadows fade with shot activity. Gaussian taps use bilinear pairs with unchanged weights; the reference radius cap bounds the loop at 160 pixels. `Tests/Fixtures/Cap3DReference.json` contains all 13 upstream presets and 248 geometry/zoom/transition cases generated by compiling Cap's actual `camera3d.rs`. `Cap3DScenes.json` adds 138 auto/named scenes generated by executing the upstream `three-d.ts`, covering short clips, minimum durations, weighted timing, cut snapping, and all endpoint/blur values. Both fixtures use the same pinned commit. Regenerate from a pinned Cap checkout with `python3 scripts/generate-cap-3d-reference.py /path/to/Cap` (requires Bun and Rust only for regeneration). Normal tests consume the fixture without network access or an upstream runtime. The GPU checks separately cover ring positions/highlight gain, Gaussian impulse weights, horizon clipping, transparent padding, shadow suppression, and whole-card zoom. Adapted rendering code carries Cap's AGPLv3 attribution and license in `Resources/Licenses/`, bundled with the application. Keep those notices and the corresponding-source/build instructions with distributions; see the repository `LICENSE`. `make test` includes projection/normalization checks, model persistence/undo, compact light/dark snapshots, production compositor checks, and encoded 30/60 fps video checks. For focused checks after a build: ```bash BETTERSHOT_CHECK_3D_ONLY=1 bash Tests/run-exports.sh # With existing Screen Recording permission, capture displayed player windows: BETTERSHOT_CHECK_3D_ONLY=1 BETTERSHOT_CHECK_3D_WINDOWS=1 bash Tests/run-exports.sh # With optimized Release objects, measure warm 1080p/4K transform, Gaussian, and bokeh GPU frame times: BETTERSHOT_DERIVED_DATA=.build BETTERSHOT_BUILD_CONFIGURATION=Release BETTERSHOT_CHECK_3D_ONLY=1 BETTERSHOT_BENCHMARK_3D=1 bash Tests/run-exports.sh ``` Offscreen snapshots do not validate live AVPlayer layers. The optional window check plays and seeks the production editor in both appearances using fixture media. It does not automate pointer dragging or keyboard entry in the controls. ## Validation ### Native changes ```bash make test ``` This builds unsigned Debug in `.build/tests` with testability enabled, runs [scripts/run-checks.sh](scripts/run-checks.sh), then [Tests/run-exports.sh](Tests/run-exports.sh). The runners set `BETTERSHOT_TESTING=1` so tests use isolated storage and do not access real R2 Keychain credentials. `Tests/run-exports.sh` names each run's program `ExportIntegration-`, which gives its `UserDefaults.standard` a private domain that the runner deletes afterwards, so overlapping runs in other worktrees cannot rewrite its save folder or dismiss delay. The separate test build directory keeps unsigned tests from replacing a running signed dev app and invalidating its Screen Recording permission. Quit the dev app before rebuilding its `.build` bundle. Always use the runners; never substitute production credentials to make a test pass. For a logic regression, add a small check against production code. Standalone checks are `Tests/*Check.swift`; an optional matching `.sources` file lists the production files to compile. For example, [FileNamingCheck.sources](Tests/FileNamingCheck.sources) points directly to the production filename renderer. Run the standalone suite with `bash scripts/run-checks.sh`. It compiles each check for the `minimumOS` in `version.json` and runs `scripts/check-sf-symbols.py`, which fails on any SF Symbol name that is missing or newer than that minimum. ### Focused integration checks After `make test` has produced Debug objects, these commands reuse them. Rebuild with `make test` when production code changes. | Area | Command | | --- | --- | | Live scroll capture, automatic page-end detection, and ordered pixels (requires existing Screen Recording and Accessibility permissions) | `BETTERSHOT_CHECK_SCROLL_CAPTURE=1 bash Tests/run-exports.sh` | | Interactive native window selection, pixels, staging, preview, and Escape (requires existing Screen Recording permission) | `BETTERSHOT_CHECK_WINDOW_CAPTURE=1 bash Tests/run-exports.sh` | | Screenshot Copy/Save and private storage | `BETTERSHOT_CHECK_SCREENSHOT_SAVING=1 bash Tests/run-exports.sh` | | Video compositing and encoded exports | `BETTERSHOT_CHECK_VIDEO_EXPORTS=1 bash Tests/run-exports.sh` | | Displayed Gallery and Settings windows | `BETTERSHOT_CHECK_LIBRARY_WINDOWS=1 bash Tests/run-exports.sh` | | Editor focus, full screen, and tray handoff | `BETTERSHOT_CHECK_EDITOR_WINDOWS=1 bash Tests/run-exports.sh` | | Actual SwiftUI editor scenes, automatic/manual full screen | `BETTERSHOT_CHECK_EDITOR_SCENES=1 bash Tests/run-exports.sh` | The screenshot check uses the production post-capture path with fixture media; it does not request capture permission. Video checks cover decoded colors, masks, camera ratios, cached frames, 30/60 fps, audio, render invalidation, and cancellation with GPU work in flight. Editor snapshots are written to `.build/editor-snapshots/`. Review narrow and wide layouts in both appearances. Displayed-window checks need a logged-in desktop session; Gallery/Settings screenshots also need existing Screen Recording permission. Editor-window checks can exercise fullscreen captures with existing permission, but do not automate region selection or start microphone/camera recordings. Offscreen snapshots cannot prove live AVPlayer rendering, native toolbars, global shortcuts, capture selection, or permissions. Test affected interactions manually and state what remains unverified. ### Export performance Build optimized objects, then run the synthetic export benchmark without another build running alongside it: ```bash make generate xcodebuild -project BetterShot.xcodeproj -scheme BetterShot -configuration Release \ -derivedDataPath .build CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES \ SWIFT_COMPILATION_MODE=incremental build BETTERSHOT_DERIVED_DATA=.build BETTERSHOT_BUILD_CONFIGURATION=Release BETTERSHOT_BENCHMARK=1 bash Tests/run-exports.sh ``` It measures two-minute 1080p clips with plain and heavy effects, including 60/30 fps variants and upload preparation. It does not upload or read R2 credentials. See [export-performance.md](docs/export-performance.md) for workloads, measurements, and their limits. ### Local installer checks Install `create-dmg` with `brew install create-dmg`, then run `make dmg` for an unsigned local-test installer. Packaging needs a logged-in desktop session and Finder automation permission. To package an existing Release build and validate the result: ```bash bash scripts/create-dmg.sh .build/Build/Products/Release/BetterShot.app release/BetterShot-installer-preview.dmg bash Tests/check-dmg.sh release/BetterShot-installer-preview.dmg .build/Build/Products/Release/BetterShot.app ``` Check the background, bundled clover volume icon, icon positions after remounting, and Applications link in both appearances. ## Submitting changes External contributors should open a focused PR from a branch on their fork. Maintainer tasks go directly to `main` unless a PR is explicitly requested. Before submitting: - Explain the concrete problem and resulting behavior; link the relevant issue. - Run checks appropriate to the change: `make test` for native logic/rendering, and `pnpm lint` plus `pnpm build` for the website. For documentation-only edits, verify commands and links against the repository; no native rebuild is needed. - Include screenshots for UI changes and name the appearances, window sizes, and interactions actually tested. Report failures or gaps honestly. - Update user and contributor documentation when behavior or workflows change, and add a changelog entry when appropriate. Preserve historical entries and contributor credit. Website-only work follows the documentation exception above. - Review the diff for unrelated edits, personal signing identities, credentials, generated build output, and local configuration. Use short, descriptive commit messages, such as `fix: preserve cursor hotspot in Retina exports`. A PR description should let a reviewer understand the problem, change, and validation without reading the original conversation. [CI](.github/workflows/build.yml) currently runs `make release` and checks that the app exists. It does **not** run the full regression suite, so a green CI build alone does not replace local validation. [version.json](version.json) is the version source. Do not bump a version or publish release binaries as part of a routine contribution. `make ship` is a maintainer-only signed/notarized release workflow that depends on local release tooling and credentials; it is not a development check. Releases require an explicit maintainer request. Contributions are licensed under the project's [BSD 3-Clause License](LICENSE). ### Onboarding and release notes `OnboardingView` is a four-step native flow: Welcome, Permissions, Saving, and Practice. Each `OnboardingStep` page has a hero, a large title, and its content above one bottom bar with Skip or Back, progress dots, and Continue or Done. Permissions asks only for screen capture and Accessibility through `OnboardingPermissionRow` and `OnboardingPermissions`, the model behind Settings' `SettingsPermissionRow`; recordings request the microphone and camera when they need them. Saving edits the same folder and automatic-saving preferences as Settings › General. Practice watches `CaptureOrchestrator.lastCaptureURL` and shows the first capture taken after it appears. Every step change is saved through `OnboardingState.save(step:)`, so a permission restart reopens setup on that step. Skip and Done finish through `OnboardingWindowController.finish`, and closing the window also marks onboarding seen. Pages change with a 0.2-second ease-out move, or a fade with Reduce Motion. Settings › About › Take the Tour reopens the same flow. Run `make test` for the onboarding checks. `CHANGELOG.md` is bundled directly. Every `version.json` version must have a `## [x.y.z]` entry. `ReleaseNotes` parses these entries and tracks dismissed versions separately from onboarding completion; do not reset either marker on upgrades. The launch flow presents pending setup first, otherwise unread release notes, then honors the capture-bar startup preference. Offline notes include skipped versions; Settings → About can reopen the notes or tour. 3D insertion hover and click share `Recording3DTimeline.insertionRange`. Keep the ghost non-interactive and avoid project mutations/render-cache invalidation while skimming. Tests cover placement boundaries, undo/persistence, and compact light/dark snapshots of the ghost, onboarding steps, and release notes. For window screenshot changes, also select a window and cancel with Escape in the signed dev app. Standalone test executables use their terminal’s capture identity and cannot establish that the installed app’s picker authorization works.