Skip to content

feat(record-tour): clearer keyframe layout, recapture, and two-step save#886

Merged
giswqs merged 11 commits into
mainfrom
feat/issue-874-record-tour-ux
Jun 25, 2026
Merged

feat(record-tour): clearer keyframe layout, recapture, and two-step save#886
giswqs merged 11 commits into
mainfrom
feat/issue-874-record-tour-ux

Conversation

@giswqs

@giswqs giswqs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Addresses the Record Map Tour UX report in #874. Three changes to RecordTourDialog:

  1. Clearer keyframe layout / no clipped labels. Each keyframe is now laid out over two rows: the view (click to fly to it) plus its actions on top, and the transition control on its own line reading Transition [N] seconds with the full word spelled out. This removes the cramped 4/s clipping reported on narrow panels, addressing the layout and label-visibility asks without needing a manual resize.
  2. Recapture / update a keyframe in place. Every row gets a camera (recapture) action next to up/down/delete. Navigate to a keyframe, pan/zoom to re-frame, and click it to overwrite that keyframe's camera while keeping its position in the sequence and its segment duration, instead of deleting and recreating the step.
  3. Two-step record → save with a custom name. Recording and saving are decoupled. When capture finishes, the take is held in memory and the panel shows a File name field (defaulting to map-tour) with explicit Save video… and Discard buttons, rather than an automatic map-tour.webm download firing the instant recording ends. The chosen name flows through to both the native Save As dialog (File System Access) and the browser anchor-download fallback used by browsers like Safari. Editing the tour after recording discards the now-stale take.

The keyframe coordinates are kept (they identify each stop and power the click-to-preview fly-to) but no longer crowd the transition control, which now has its own dedicated line as requested.

Verification

Drove the real app at localhost:5173 with Playwright in both light and dark themes using basemap views:

  • Confirmed the two-line layout renders the full Transition N seconds label with no clipping.
  • Recaptured keyframe 2 after panning and confirmed its coordinates updated in place while keeping its slot and duration.
  • Recorded a 3-keyframe tour, confirmed it stops at the new "ready" step (no auto-download), renamed the file, and saved a real my-custom-tour.webm via the download fallback; confirmed the native picker path is taken when showSaveFilePicker is available.
  • Confirmed Discard returns to the idle Record state.

npm run build and pre-commit run (eslint, build, formatting) pass; frontend tests green.

Fixes #874

Summary by CodeRabbit

  • New Features
    • Updated map tour recording to a two-step flow: capture first, then review a held “ready” recording with an editable filename and explicit Save/Discard actions.
    • Refreshed recording UI text, including improved seconds duration wording.
  • Bug Fixes
    • Prevents saving empty recordings; zero-byte captures now cancel back to idle.
    • Disables close and freezes editing controls while capturing/saving or when a take is held ready.
  • Improvements
    • Improved save handling to support reliably persisting the held recording data, including filename normalization.

… save

Address the Record Map Tour UX report (#874):

- Lay each keyframe out over two rows so the transition control gets its own
  line with a full "Transition N seconds" label that no longer clips in a
  narrow panel.
- Add a Recapture (camera) action to every keyframe row so a stop can be
  re-framed in place from the current map view, instead of deleting and
  re-adding it (which lost its order and segment duration).
- Decouple recording from saving: a finished take is held in memory and the
  user names it and clicks Save (or Discard to re-record), rather than an
  automatic map-tour.webm download firing the instant capture ends. The custom
  name flows through to both the native Save As dialog and the browser
  download fallback.

Fixes #874
@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit 41c9a52
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a3d9b6686a7bd0008b39d4f
😎 Deploy Preview https://deploy-preview-886--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 36027574-1776-42e5-86db-aff2f119f6ed

📥 Commits

Reviewing files that changed from the base of the PR and between c08a3f4 and 41c9a52.

📒 Files selected for processing (1)
  • apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

📝 Walkthrough

Walkthrough

RecordTourDialog now holds finished recordings until the user saves or discards them. The dialog adds a ready state, freezes editing while a take is held, and updates the save helper and localized labels for the new flow.

Changes

Record tour save workflow

Layer / File(s) Summary
State and editing
apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
Adds ready-state bookkeeping, pending recording storage, editing locks, and result-message resets when the tour changes.
Record, save, and discard
apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx, apps/geolibre-desktop/src/lib/tauri-io.ts
Recording now ends in an in-memory ready state, then saves or discards the held blob; the binary save helper accepts Blob content in browser and Tauri paths.
UI wiring and copy
apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx, apps/geolibre-desktop/src/i18n/locales/en.json
Updates keyframe row disabling and preview behavior, pluralizes the segment duration unit, and adds the new record/save locale strings.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • opengeos/GeoLibre#822: Introduced the earlier RecordTourDialog recording flow that this PR changes into a separate record-then-save path.

Poem

A bunny pressed record with a hop,
Then paused while the filename popped.
I stash the take neat,
Add a save and a treat,
And discard any hops that I flopped. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main changes: clearer layout, recapture, and a two-step save flow for record tours.
Linked Issues check ✅ Passed The PR addresses #874 by improving label clarity, adding recapture, and replacing auto-download with a save/discard flow.
Out of Scope Changes check ✅ Passed The changes stay focused on record-tour UX, with supporting i18n and file-save plumbing only.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-874-record-tour-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Cloudflare Pages preview

Item Value
Preview https://f84a0611.geolibre-preview.pages.dev
Demo app https://f84a0611.geolibre-preview.pages.dev/demo/
Commit 8638c8b

Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Three findings follow, each with an inline comment on the relevant lines. No security, performance, or CLAUDE.md issues were identified.


Bugs / Logic

Silent discard of pending recording when keyframes are edited — medium confidence
All keyframe-edit handlers (addCurrentView, recaptureKeyframe, removeKeyframe, move, setSegmentSeconds) call invalidateRecording(), and all of those controls remain enabled while status === "ready" (because disabled={busy} and busy is false in the ready state). Any accidental edit — a stray click on Recapture, nudging a segment duration — silently drops the captured blob. The UI message says "or discard to re-record", implying the Discard button is the deliberate path; the implicit discard-on-edit is not surfaced. Options: disable edit controls while in the ready state (symmetric with how they're disabled while busy), or show a warning before the action. See inline comment at line 119.


Quality

Wrong error message for save failures — high confidence
The catch block in handleSave re-uses t("recordTour.recordError") = "Could not record the tour. Please try again." A user who successfully recorded but hit a disk/permission error during the save step will see a message that blames the recording. A separate saveError key would be accurate. See inline comment at line 315.

Minor nits on the filename input (grouped) — low confidence
Two small improvements: (1) no autoFocus when the ready panel appears, so keyboard users still need an extra click; (2) fileName state is not reset after a successful save or discard, so a second recording inherits the previous filename. See inline comment at lines 516–524.


Checked and found nothing to raise

  • Security: no injection surface — the filename is sanitised (.trim().replace(/\.webm$/i, "")) before being passed to the file-picker helper; user input never reaches a shell command or URL template.
  • Performance: the new Uint8Array(await blob.arrayBuffer()) copy was already present in the old save path; no new allocation cost.
  • CLAUDE.md guidelines: new i18n keys are in en.json only; other locales fall back correctly via fallbackLng: DEFAULT_LANGUAGE in i18n/index.ts. No direct MapLibre mutations; no commits to main.
  • State machine: the "idle" → "recording" → "ready" → "saving" → "idle" transitions are consistent; canRecord (requires status === "idle") correctly prevents double-recording; busy flag correctly guards the close button and FPS input.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx`:
- Around line 33-37: The default output name in RecordTourDialog is hard-coded
and bypasses localization; replace the DEFAULT_FILE_NAME usage in
RecordTourDialog so the initial filename comes from react-i18next via t(), and
add the corresponding translation key to the dialog’s locale entries with
en.json as the source of truth. Update any other references in the dialog flow
around the filename field and save handling so they use the localized value
instead of the literal map-tour string.
- Around line 256-258: Separate the recording-cancel and save-failure states in
RecordTourDialog so the UI message matches the phase that failed. Update the
zero-byte branch in the recording flow and the disk-write failure branch in the
save flow to use distinct status/error state instead of reusing saveCancelled
and recordTour.recordError across both phases. Use the existing RecordTourDialog
handlers around the blob-size check and the save-to-disk logic to set a
recording-cancel message when the recording never produced data, and a
save-failure message only when the write step fails after a successful
recording.
- Around line 95-97: The RecordTourDialog state keeps pendingBlob in memory
after the dialog is closed, since the component stays mounted and no open-driven
cleanup runs. Update RecordTourDialog to watch the open prop and clear
pendingBlob whenever the dialog transitions to closed, reusing the existing
pendingBlob/setPendingBlob state and any existing reset helper if present so the
ready-state blob is released on close.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2abd9dff-a876-4f1a-9753-410ff10a5ba4

📥 Commits

Reviewing files that changed from the base of the PR and between 7faa912 and f4fa770.

📒 Files selected for processing (2)
  • apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
  • apps/geolibre-desktop/src/i18n/locales/en.json

Comment on lines +33 to +37
// "ready" holds a finished recording in memory so saving is a deliberate second
// step (name + Save) rather than an automatic download the moment recording ends.
type Status = "idle" | "recording" | "ready" | "saving";

const DEFAULT_FILE_NAME = "map-tour";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Localize the default output name.

Line 37 hard-codes "map-tour", and that value is shown in the filename field and native/browser save flow. It should come from i18n like the rest of this dialog copy, with a corresponding key in apps/geolibre-desktop/src/i18n/locales/en.json.

As per coding guidelines, "UI strings must be translatable via react-i18next; add new user-facing strings using t() function and update locales in apps/geolibre-desktop/src/i18n/locales/*.json (en.json is source of truth)".

Also applies to: 95-97

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx` around
lines 33 - 37, The default output name in RecordTourDialog is hard-coded and
bypasses localization; replace the DEFAULT_FILE_NAME usage in RecordTourDialog
so the initial filename comes from react-i18next via t(), and add the
corresponding translation key to the dialog’s locale entries with en.json as the
source of truth. Update any other references in the dialog flow around the
filename field and save handling so they use the localized value instead of the
literal map-tour string.

Source: Coding guidelines

Comment on lines +95 to +97
// The finished recording, held until the user names it and clicks Save.
const [pendingBlob, setPendingBlob] = useState<Blob | null>(null);
const [fileName, setFileName] = useState(DEFAULT_FILE_NAME);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all call sites for RecordTourDialog and inspect whether callers
# conditionally render it or always keep it mounted with open={...}.
rg -n -C3 --type=tsx '\bRecordTourDialog\b' apps/geolibre-desktop/src

Repository: opengeos/GeoLibre

Length of output: 183


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C5 --type ts '\bRecordTourDialog\b' apps/geolibre-desktop/src

Repository: opengeos/GeoLibre

Length of output: 3719


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Inspect the RecordTourDialog implementation, specifically looking for logic handling the 'open' prop change
# and the 'pendingBlob' state.
sed -n '79,150p' apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Repository: opengeos/GeoLibre

Length of output: 3567


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Inspect the usage of the 'open' prop and 'useEffect' hooks in RecordTourDialog
sed -n '120,200p' apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Repository: opengeos/GeoLibre

Length of output: 3063


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Search for useEffect hooks in the component, specifically looking for 'open' in the dependency array
rg -A 10 "useEffect.*open" apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Repository: opengeos/GeoLibre

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Search for Effect hooks to see how the component reacts to prop changes
rg -B 2 -A 10 "useEffect\(" apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Repository: opengeos/GeoLibre

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Search for the word "effect" or "Effect" to find any effects in the file
rg -in 'effect' apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Repository: opengeos/GeoLibre

Length of output: 155


Close action does not release pendingBlob

The component is always mounted (parent never unmounts it), and there is no useEffect hook listening to the open prop. Closing the dialog while in the ready state leaves the large blob in component memory. Please explicitly clear pendingBlob when the dialog closes.

Relevant snippet from `RecordTourDialog.tsx`
  const [pendingBlob, setPendingBlob] = useState<Blob | null>(null);
  // ...
  // No useEffect watches `open` to trigger clearPendingBlob()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx` around
lines 95 - 97, The RecordTourDialog state keeps pendingBlob in memory after the
dialog is closed, since the component stays mounted and no open-driven cleanup
runs. Update RecordTourDialog to watch the open prop and clear pendingBlob
whenever the dialog transitions to closed, reusing the existing
pendingBlob/setPendingBlob state and any existing reset helper if present so the
ready-state blob is released on close.

Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
- Surface the discard consequence in the "ready" UI: the message now warns
  that editing the tour discards the captured recording, so the silent drop
  when a keyframe is edited is no longer a hidden footgun.
- Use a dedicated recordTour.saveError ("Could not save the video.") in the
  handleSave catch instead of recordError, which wrongly blamed the recording
  step for a file-write failure.
- autoFocus the file name input when the panel switches to the save step, and
  reset the name to the default after a successful save or a discard so a
  second recording doesn't show the previous name.
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
Comment thread apps/geolibre-desktop/src/i18n/locales/en.json
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

Finding Confidence
Accidental recording discard in "ready" state. The "Add current view" button is only gated on busy (recording/saving), so it stays active when status === "ready". One click calls invalidateRecording(), silently drops pendingBlob, and returns to idle. The sole warning — the footer message "Editing the tour discards this recording." — is only visible if the keyframe list is not scrolled. A user who records a long tour then scrolls up to inspect keyframes can destroy the take with no confirmation and no undo. See inline comment at line 389. Medium-high
Concurrent handleSave invocations before React re-render. Both the Enter key handler and the Save button call the async handleSave. The React state update that swaps in the "Saving..." spinner happens after the current event, so a second Enter keydown (keyboard repeat) or a double-click before that re-render lands can pass the if (!pendingBlob) return guard a second time and invoke saveBinaryFileWithFallback twice concurrently — triggering two native file pickers on Tauri or two browser downloads. See inline comment at lines 529-542. Low-medium

Performance

Finding Confidence
Uint8Array copy on every save attempt, including cancel-and-retry. pendingBlob.arrayBuffer() copies the entire recording into a new typed array each time handleSave is invoked. On cancel-then-retry cycles the blob stays intact but the copy runs again. For large tours this briefly doubles heap usage. See inline comment at line 294. Medium

Security

Nothing of concern in this diff. The fileName input strips trailing .webm and trims whitespace before use; on the browser path the download filename is sanitised by the browser, and on Tauri the native save dialog controls the actual write path.

Quality

  • The new captureView() helper is a clean extraction: it removes duplicated view-reading logic from both addCurrentView and the new recaptureKeyframe, and handles the null case consistently.
  • State machine transitions (idle -> recording -> ready -> saving -> idle/ready) are well-defined. invalidateRecording() uses a functional setStatus update to avoid stale-closure bugs, and the handleRecord error/finally split (status goes to "idle" in the catch, setProgress(0) always in finally) is correct.
  • Renaming the recording boolean prop to disabled in KeyframeRow is a cleaner abstraction that decouples the row from parent status semantics.

CLAUDE.md

  • i18n: Eight new keys and two renamed keys (start -> startingView, secondsUnit -> secondsLong) are added only to en.json. The 13 other locale files contain no recordTour section at all and already fell back to English for the whole block, so there are no regressions — but the new keys will also render in English in those locales until a translation pass covers them. See inline comment at en.json lines 486-510. (Consistent with the CLAUDE.md convention that en.json is the source of truth.)

- Freeze the keyframe-editing controls (Add current view, recapture, reorder,
  remove, segment duration) while a finished take is held in the "ready" state,
  so the recording can only be dropped via the explicit Discard button rather
  than a one-click edit whose footer warning may have scrolled off-screen. The
  ready-state message reverts to "discard to re-record" accordingly.
- Guard handleSave with a savingRef so a fast double-click or Enter keyboard
  repeat can't fire two concurrent saves (two pickers / two downloads) before
  the "saving" state re-renders.
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed RecordTourDialog.tsx and en.json. The overall design of the two-step record→save flow, the recapture feature, and the two-row keyframe layout is solid. State machine transitions are handled correctly and the savingRef double-submit guard is well placed. Below are findings grouped by category.


Bugs

Finding Confidence
FPS field editable during "ready" statedisabled={busy} leaves the FPS input live while a pending recording is held. busy excludes "ready", so a user can edit FPS even though the captured take's frame-rate is already fixed. This silently ignores the change. All other mutating controls correctly use editingFrozen. Fix: change the FPS input's disabled from {busy} to {editingFrozen}. See inline comment on line 453. Medium

Performance

Finding Confidence
2× peak memory on savenew Uint8Array(await pendingBlob.arrayBuffer()) creates a full in-memory copy of the recorded video alongside the source Blob, so peak RSS is roughly double the recording size. This is a constraint of saveBinaryFileWithFallback's Uint8Array signature, not a new regression. Flagged as a pointer for a future refactor to pass a Blob / ReadableStream directly. See inline comment on line 304. Low

Quality

Finding Confidence
Preview/fly-to frozen during "ready" state — The keyframe preview button is gated on the same disabled={editingFrozen} prop as the mutating actions (recapture, reorder, delete). But previewKeyframe is a pure camera move with no state side-effects. Blocking it forces a Discard→verify→re-record cycle when the user simply wants to check a keyframe position before saving. Splitting the prop so preview only blocks during busy would improve UX. See inline comment on line 639. Low–medium
invalidateRecording / discardRecording asymmetry on fileNamediscardRecording resets fileName to the default; invalidateRecording does not. Intentional, but the difference is implicit. Noted as a maintainability point in case future callers are added. See inline comment on line 127. Low

Security

Nothing to flag. fileName is passed as a suggestion to the OS save dialog or the browser anchor-download fallback, both of which sanitize path separators. No injection risk.


CLAUDE.md

New i18n strings are added only to en.json (the source-of-truth per CLAUDE.md). The i18n-catalogs test treats non-English coverage as informational (not a CI gate), so this is fine by convention. No other CLAUDE.md guidelines are implicated.


Overall: the PR is well-structured and the core logic is correct. The one actionable item is the FPS disabled condition; the rest are low-priority UX and maintainability notes.

giswqs added 2 commits June 25, 2026 15:48
- Widen saveBinaryFileWithFallback (and saveBinaryFileBrowser) to accept
  Blob | Uint8Array. The browser path now writes a Blob straight through, and
  the Tauri path materializes bytes only after the save dialog is confirmed.
- RecordTourDialog passes the recorded Blob directly instead of copying it into
  a Uint8Array on every handleSave call, so a cancel-and-retry no longer
  re-copies a potentially several-hundred-MB recording. Existing Uint8Array
  callers are unaffected (backward-compatible union type).
- Freeze the FPS input while a recording is held (status "ready"), matching the
  other editing controls: a captured take's frame rate is already fixed, so
  editing FPS then would only mislead.
- Keep the fly-to preview available in the "ready" state via a separate
  previewDisabled prop gated on busy alone. Previewing only moves the map
  camera and can't produce a stale recording, so users can verify the tour
  before choosing Save or Discard without having to discard first.
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/lib/tauri-io.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Close button not disabled in "ready" state (medium-high confidence) — RecordTourDialog.tsx:379. The component never unmounts (if (!open) return null), so pendingBlob persists in memory when the dialog is closed without saving or discarding. For a long tour the blob can be hundreds of MB. Reopening also lands the user unexpectedly in the "ready" step with an old recording. The simplest fix: add || status === "ready" to the close button's disabled condition, consistent with how "recording" and "saving" are already guarded.

Quality

  • Dead status guard in invalidateRecording (low confidence) — RecordTourDialog.tsx:127. The setStatus((current) => (current === "ready" ? "idle" : current)) call can never trigger a transition because every caller has its UI control disabled via editingFrozen when status !== "idle". Documented correctly in the inline comment ("Edits are blocked while a take is held, so this only fires from 'idle'"). Harmless defensive code, but worth either removing the unreachable branch or asserting the invariant in dev.

  • options.mimeType silently ignored for Blob inputs in saveBinaryFileBrowser (low confidence) — tauri-io.ts:971–974. When a Blob is passed through, its own .type is used and options.mimeType has no effect. Correct in practice (the MediaRecorder blob already carries "video/webm"), but a future caller could be confused. A one-line comment on the branch is sufficient.

Security / Performance / CLAUDE.md

Nothing to flag. The two-step save flow, the savingRef double-click guard, the AbortController wiring, the Tauri-path lazy byte conversion (avoiding a full-size copy on cancel), and the new i18n keys all look correct. Because i18next.d.ts derives types from typeof en, the renamed keys (secondsUnit → secondsLong, start → startingView) would have been compile errors if any t() call was left over — and the build is reported green.

Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
- Disable the panel close button while a finished take is held ("ready"), using
  editingFrozen instead of busy, so closing can't silently strand a recording
  in memory or drop the user back into the save step on reopen. Save or discard
  first.
- Document that saveBinaryFileWithFallback uses a Blob's own .type as-is and
  only applies options.mimeType when wrapping a Uint8Array, so the contract is
  explicit for future callers.
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/lib/tauri-io.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the three changed files: RecordTourDialog.tsx, en.json, and tauri-io.ts. The overall approach is clean — the captureView extraction, the invalidateRecording helper, the savingRef double-click guard, and the lazy Blob-to-bytes conversion on the Tauri write path are all well-reasoned. No security issues found.

Bugs

Finding Confidence
setSegmentSeconds now calls invalidateRecording(), which clears savedName. After a successful save the "Saved map-tour.webm" confirmation disappears on any duration blur — including the no-op blur that fires when the user clicks into the field without changing the value. The old code did not clear savedName from setSegmentSeconds. (Inline comment at line 224.) Medium

Quality

Finding Confidence
handleSave has a bare catch {} that swallows the original error without logging it. handleRecord logs and inspects its caught errors; handleSave should too. (Inline comment at line 326.) Low
saveBinaryFileBrowser now accepts a Blob directly, using its own .type instead of options.mimeType. If a future caller passes a Blob with an empty .type, the type filter in the File System Access picker is silently ignored. The current caller always passes a typed MediaRecorder Blob so this is safe today. (Inline comment in tauri-io.ts.) Low / informational

Security

Nothing found. File name sanitisation via browserSafeFileName (strips path separators) runs before the blob URL is created on the browser path, and the Tauri path defers to the native save dialog.

Performance

Nothing found. The Blob-to-Uint8Array conversion on the Tauri path is correctly deferred until after the save dialog is confirmed, avoiding a full-size copy on cancelled attempts.

CLAUDE.md

All new user-facing strings are in en.json (the source of truth), t() is used throughout, no direct mutations of MapLibre state, and no modifications to node_modules.

- Stop clearing the "Saved …" / outcome banner on plain keyframe edits. The
  per-edit invalidateRecording() was dead for its real purpose (editing is
  frozen while a take is held, so it never had a pending blob to drop) and only
  caused a regression: clicking into a duration field wiped the save
  confirmation. Replaced it with a narrower clearResultMessages() called only
  from addCurrentView (its original pre-PR behavior); recapture/remove/move/
  duration no longer touch the banner.
- Add a finally safety net in handleRecord so an exception escaping the catch
  (e.g. t() throwing) can't leave the dialog stuck on "recording" with a
  null abort controller; it falls back to "idle".
- Log the raw error in handleSave's catch (console.warn) so a save failure is
  debuggable behind the user-facing message.
- Note that arrayBuffer() rejects on a detached Blob and the rejection
  propagates to the caller's catch.
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/lib/tauri-io.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overall the PR is well-structured: the new `"ready"` state machine is clean, the double-click guard on `handleSave` is correct, and the `savingRef`/`editingFrozen` interplay is sound. Three findings below.


Bugs

Finding Confidence
recaptureKeyframe leaves a stale "Saved …" banner. After a successful save, recapturing a keyframe changes the tour's camera data without calling clearResultMessages(). The "Saved my-tour.webm" banner stays visible, implying the modified tour was already saved when it wasn't. addCurrentView correctly clears the message for the same reason; recapture should too. See inline comment. Medium

Quality

Finding Confidence
Transient pluralization mismatch on the transition unit label. The "second/seconds" text is pluralized from keyframe.durationMs / 1000 (the committed store value) while the <Input> shows unsynced text state. While a user is mid-edit (e.g. typed "1" but hasn't blurred yet from a "2s" field), the label reads "1 seconds". This is the same deliberate trade-off used by the FPS field; noted as a nit with a suggestion inline. Low
Inaccurate "detached Blob" comment in saveBinaryFileWithFallback. Blob has no detached state in the W3C spec — only ArrayBuffer can be transferred/detached. The comment should describe the actual failure modes (OOM, unavailable backing store). See inline comment. Low

Security, Performance, CLAUDE.md

Nothing to flag. The browserSafeFileName path-strip covers the user-supplied fileName, the Tauri write path is gated by the OS save dialog, and the Blob-held-in-state memory pattern is reasonable for a recording workflow. All other locale files already fall back to English for recordTour strings, so the new i18n keys are consistent with existing behaviour.

giswqs added 2 commits June 25, 2026 16:47
- Clear the "Saved …" outcome banner when recapturing a keyframe, matching
  addCurrentView: both change a keyframe's camera, so the saved video no longer
  matches the tour and the success banner would otherwise mislead. Plain
  reorder/duration tweaks still leave the banner alone.
- Pluralize the transition unit against the value being typed (falling back to
  the committed value), so the label tracks the input and reads "1 second"
  rather than "1 seconds" mid-edit before blur commits.
- Correct an inaccurate comment: Blobs have no "detached" state (that's
  ArrayBuffer); arrayBuffer() can still reject for other reasons (OOM,
  unavailable backing store), which propagates to the caller's catch.
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx`:
- Around line 119-122: The saved banner handling in RecordTourDialog should also
clear when a keyframe is removed, since that changes the tour content just like
adding or recapturing. Update the logic around the saved banner state used by
removeKeyframe() so savedName is reset there too, and make sure the same
banner-clearing behavior stays consistent with the existing edit handling for
camera-changing actions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 722bd410-73c8-4a72-b265-067d014cee42

📥 Commits

Reviewing files that changed from the base of the PR and between d53b2fa and c80c971.

📒 Files selected for processing (1)
  • apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
// control) and while a finished take is held in the "ready" state, so
// closing can't silently strand a recording in memory or drop the
// user back into the save step on reopen. Save or discard first.
disabled={editingFrozen}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The close button is disabled in both busy and ready states, but users in the "ready" state have no visible or accessible hint explaining why the ✕ is greyed out. Someone who finishes recording and instinctively clicks ✕ will find it unresponsive with no feedback — the code comment explains the intent, but that isn't surfaced in the UI.

A title attribute conditioned on the "ready" state would help (also useful for aria-description/tooltip on the desktop side):

Suggested change
disabled={editingFrozen}
disabled={editingFrozen}
=== "ready" ? t("recordTour.saveOrDiscardFirst") : undefined}

Requires adding "saveOrDiscardFirst" to en.json (e.g. "Save or discard the recording first."). The title won't surface on touch, but it covers mouse and keyboard users and sets a baseline for a future tooltip.

{t("recordTour.secondsUnit")}
{/* Pluralize against the value being typed (falling back to the
committed one) so the unit tracks the input: "1 second", not
"1 seconds", even mid-edit before blur commits. */}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plural form ("second" vs. "seconds") is driven by keyframe.durationMs / 1000 — the committed store value — not by the text local state shown in the <Input> above. When the user types an out-of-range value (e.g. 0, which is below MIN_SEGMENT_SECONDS = 0.5), onDurationSeconds is skipped, so durationMs stays at the old value. The visible number in the input and the pluralization word then diverge until blur reconciles them.

For the common valid-typing path this is invisible, so it's low priority. If you want to close the gap, the simplest fix is to pluralize against the in-progress numeric value and fall back to the committed one when out of range:

Suggested change
"1 seconds", even mid-edit before blur commits. */}
{t("recordTour.secondsLong", {
count: (() => {
const v = Number(text);
return Number.isFinite(v) && v >= MIN_SEGMENT_SECONDS && v <= MAX_SEGMENT_SECONDS
? v
: keyframe.durationMs / 1000;
})(),
})}

This is cosmetic; the committed value is always what actually gets recorded.

Comment on lines +1299 to +1302
// caller's catch.
const bytes =
content instanceof Blob
? new Uint8Array(await content.arrayBuffer())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the Tauri path, content.arrayBuffer() materialises a full Uint8Array copy of the blob. For a long recording this means both the Blob (still referenced by pendingBlob state in the dialog) and a fresh Uint8Array of the same size live in memory simultaneously until writeFile returns and the Uint8Array goes out of scope.

The code comment already acknowledges that the conversion is deferred until after the dialog is confirmed — that's the right trade-off. Just flagging that on a constrained device and a multi-minute recording the peak is roughly 2× the video size. If Tauri ever exposes a streaming write or direct-Blob write, this would be worth revisiting. No action needed now.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Three files changed: RecordTourDialog.tsx (the main UX work), en.json (new i18n strings), and tauri-io.ts (Blob passthrough). I read all three plus the surrounding saveBinaryFileBrowser / saveBinaryFileWithFallback context and traced the full state machine.

Bugs

Nothing found. The status-machine transitions (idle → recording → ready → saving → idle/ready) are correct under React 18 batching, including the finally-block functional-update safety net in handleRecord (which correctly sees the queued state from the catch block's setStatus("idle"), not the pre-batch "recording" value). The savingRef.current guard against double-handleSave invocation is sound: it is set synchronously before any await, so it blocks rapid double-click and key-repeat regardless of render timing.

Security

No issues found. The fileName input is run through fileName.trim().replace(/\.webm$/i, "") before becoming defaultName; browserSafeFileName then strips path separators (splits on /\\, takes the last segment), so path-traversal attempts like ../../evil are neutralised before reaching either the Tauri save() dialog or showSaveFilePicker. The save() dialog is user-confirmed, so the actual write path is always user-chosen, not derived from the input.

Performance

  • Tauri path: 2× peak memory for large recordings (low, informational). new Uint8Array(await content.arrayBuffer()) creates a full copy of the video blob after the dialog is confirmed. On constrained hardware with a long recording the peak RSS is ~2× the file size. Correctly deferred until post-confirmation as the code comment notes; flagged inline for future consideration if Tauri exposes a streaming write path.

Quality

  • Close button disabled in "ready" state with no visible or accessible explanation (medium confidence). A user who finishes recording and reflexively clicks ✕ gets a silently unresponsive button. Inline suggestion: add a conditional title attribute. Flagged inline.

  • secondsLong pluralization word can diverge from the visible input (low confidence, cosmetic). The "second"/"seconds" word is driven by keyframe.durationMs / 1000 (the committed store value), not by the text local state. When the user types an out-of-range value the two diverge until blur. Inline suggestion provided; no-action is also reasonable since the committed value is what actually gets recorded.

CLAUDE.md

  • i18n: New English strings (secondsLong_one/other, startingView, transition, recapture, saveError, saveVideo, discard, recordingReady, fileNameLabel) are in en.json only. Non-English locales never had a recordTour section, so this follows the existing pattern; i18next falls back to English. The two removed keys (start, secondsUnit) were also English-only. No issue.
  • Store / MapLibre conventions: recaptureKeyframe mutates only local keyframes state, never the Zustand store or MapLibre directly. Consistent with the data-flow described in CLAUDE.md.
  • No direct commits to main, Tauri CSP, or CSS override conventions are affected.

inputMode="decimal"
aria-label={t("recordTour.segmentSeconds")}
className="h-7 w-14"
className="h-7 w-16"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility: conflicting <label> and aria-label give sighted and screen-reader users different label text.

The wrapping <label> element implicitly associates with this <Input>, so clicking the word "Transition" will focus the field (good). But the aria-label="Seconds to move here" attribute takes precedence over the implicit <label> association for screen readers, so assistive technology announces "Seconds to move here" rather than "Transition". The two labels convey different information.

Options:

  1. Drop the aria-label and let the screen reader compute the label from the <label> content ("Transition [value] seconds"). The <label> already wraps the input so the association is automatic.
  2. Keep aria-label but use a <div> instead of <label>, and add a standalone <label htmlFor="…"> that mirrors the aria-label text so visual and accessible names match.
Suggested change
className="h-7 w-16"

(Leave the aria-label if you want the richer description for AT users, but then switch the outer <label className=…> to a <div className=…> so the implicit association no longer conflicts.)

aria-label={t("recordTour.recapture")}
title={t("recordTour.recapture")}
disabled={disabled}
onClick={onRecapture}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "Update to current map view" can be read in the wrong direction.

The intended meaning is "overwrite this keyframe's camera with the current map view", but the phrasing "Update to current map view" could be read as "fly the map to this keyframe's view" — which is what the fly-to button already does.

Suggested change
onClick={onRecapture}

Suggest changing the en.json value to something unambiguous, e.g. "Update this keyframe to current map view" or "Replace with current map view". Low-stakes, but tooltips are often the only affordance users have for icon-only buttons.

const blob =
content instanceof Blob
? content
: new Blob([toArrayBuffer(content)], { type: options.mimeType });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor footgun: options.mimeType is silently ignored when a Blob with an empty (or wrong) type is passed.

The comment correctly documents this, but the contract is easy to violate. The current caller (handleSave) always passes a Blob whose type was set by MediaRecorder (e.g. "video/webm;codecs=vp9"), so it works today.

A lightweight guard would make the guarantee explicit:

Suggested change
: new Blob([toArrayBuffer(content)], { type: options.mimeType });
const blob =
content instanceof Blob
? content
: new Blob([toArrayBuffer(content)], { type: options.mimeType });

No code change needed, but worth a follow-up if saveBinaryFileWithFallback is ever called with a typeless Blob — the result would be a download or FSA write with no content-type, which some browsers handle poorly. Consider adding a console.warn assertion in dev mode or updating the SaveBinaryFileOptions type to document which field applies in which case.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed RecordTourDialog.tsx (the state machine, blob lifecycle, double-click guard, keyboard handling, and the two-step record→save flow), tauri-io.ts (the Uint8Array | Blob overload and Tauri write deferral), and en.json. Read surrounding source including tour-recorder.ts and i18next.d.ts for context.


Bugs

Nothing blocking found. The state machine transitions are correct:

  • The finally safety-net uses a functional updater (setStatus((current) => current === 'recording' ? 'idle' : current)) that correctly sees the preceding setStatus('ready') as queued state under React 18 batching, so the "ready" status is never accidentally reset to "idle".
  • savingRef.current is set synchronously before the first await, and the component re-renders to the "saving" UI before the user can interact again, so the double-click guard is sound.
  • pendingBlob is captured in the closure at call time, so a race between clicking Save and clicking Discard within the same render cycle cannot produce a double-save or a lost blob.

Security

No issues. Filename sanitisation (.trim().replace(/\.webm$/i, '')) prevents the double-extension case; path traversal is moot because the save path is always chosen by the OS native dialog or the FSA picker, never interpolated directly. Rendered strings (savedName, error) go through JSX, not dangerouslySetInnerHTML.


Performance

No issues. pendingBlob sits in React state by reference — React never deep-copies state values, so storing a large Blob there is zero-copy. It is set exactly twice per recording (once on capture, once on save/discard), so no render churn.

The lazy-materialise-bytes strategy in the Tauri path (convert BlobUint8Array only after the save dialog is confirmed) is a good optimisation: a cancelled dialog never does a full-size arrayBuffer() copy.


Quality

# Finding Confidence
Q1 <label> + aria-label dual-labeling in the transition row (line 730): the wrapping <label> provides the visible "Transition" label, but aria-label="Seconds to move here" on the <Input> overrides it for screen readers. Sighted and AT users get different label text — either drop aria-label (let the <label> wrap do the work) or switch the outer element to a <div>. Medium
Q2 Recapture button label ambiguity (line 676): "Update to current map view" can be read as "fly the map to this view" (the reverse of the actual operation). "Replace with current map view" or "Update this keyframe to current map view" is unambiguous. Low–medium
Q3 options.mimeType silently ignored for Blob callers in saveBinaryFileBrowser (line 976 of tauri-io.ts): the comment documents this, but a typeless Blob from a future caller would get no content-type on the download. Current caller is safe because recordTour always sets the codec-specific MIME on its Blob. Low

CLAUDE.md

All new user-facing strings use t() and are present in en.json. Other locales are intentionally partial (per i18next.d.ts: "other locales may be partial and fall back to it at runtime") — no action needed. The store is not mutated directly from the UI; status changes flow through setStatus and React state. Build and pre-commit pass per the PR description.

Make the "Saved …" banner behavior fully consistent: every edit that actually
changes the tour now clears it, while no-op interactions leave it alone.

- removeKeyframe clears the banner (dropping a stop changes the recording),
  per CodeRabbit's note.
- move clears only on an in-range reorder (guards the inert end-of-list click).
- setSegmentSeconds clears only when the duration actually changes, so merely
  focusing and blurring a field (which re-commits the same value) no longer
  wipes the banner — preserving the earlier no-op-blur fix.
@giswqs giswqs dismissed stale reviews from coderabbitai[bot] and coderabbitai[bot] June 25, 2026 21:19

All inline threads resolved and the one actionable item (clear the saved-recording banner on keyframe removal) is addressed in 41c9a52.

@giswqs giswqs merged commit 44d7fa1 into main Jun 25, 2026
16 of 18 checks passed
@giswqs giswqs deleted the feat/issue-874-record-tour-ux branch June 25, 2026 21:20
Comment on lines +217 to +228
const move = (index: number, delta: number) => {
const target = index + delta;
// Guard the no-op (already at an end) so an inert reorder click can't clear
// the banner; the buttons are disabled there anyway, this is belt-and-braces.
if (target < 0 || target >= keyframes.length) return;
clearResultMessages();
setKeyframes((current) => {
const next = [...current];
const target = index + delta;
if (target < 0 || target >= next.length) return current;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium confidence — inner bounds guard removed from functional updater

The original move had the out-of-range check inside the setKeyframes updater, operating on next (the latest committed state):

if (target < 0 || target >= next.length) return current;

That's now gone. The outer check on keyframes.length works correctly for every synchronous click handler today — the closure snapshot is always fresh in React's single-threaded model — but if current.length ever diverges from the closure's keyframes.length (concurrent state batching, future refactoring that queues multiple setKeyframes calls in the same tick, React Strict Mode double-invoking updaters), the unchecked swap silently writes undefined into next[index] or next[target], corrupting the array without a type error.

Both goals (skip clearResultMessages() on no-ops, and a safe updater) can be achieved together by keeping the guard in both places:

Suggested change
const move = (index: number, delta: number) => {
const target = index + delta;
// Guard the no-op (already at an end) so an inert reorder click can't clear
// the banner; the buttons are disabled there anyway, this is belt-and-braces.
if (target < 0 || target >= keyframes.length) return;
clearResultMessages();
setKeyframes((current) => {
const next = [...current];
const target = index + delta;
if (target < 0 || target >= next.length) return current;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};
const move = (index: number, delta: number) => {
const target = index + delta;
// Guard the no-op (already at an end) so an inert reorder click can't clear
// the banner; the buttons are disabled there anyway, this is belt-and-braces.
if (target < 0 || target >= keyframes.length) return;
clearResultMessages();
setKeyframes((current) => {
const next = [...current];
if (target < 0 || target >= next.length) return current;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};

Comment on lines +230 to +239
const setSegmentSeconds = (id: string, seconds: number) => {
const durationMs = Math.round(seconds * 1000);
// Clear the banner only on a real change, so merely focusing and blurring a
// duration field (which re-commits the same value) doesn't wipe it.
const current = keyframes.find((kf) => kf.id === id);
if (current && current.durationMs !== durationMs) clearResultMessages();
setKeyframes((prev) =>
prev.map((kf) => (kf.id === id ? { ...kf, durationMs } : kf)),
);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low confidence — stale-closure comparison for banner suppression

keyframes.find() reads the render-closure snapshot of keyframes, while the actual mutation goes through the functional updater (which receives prev, the latest committed state). For two very rapid onChange events that fire before React re-renders (e.g. held-down arrow key in the number spinner), the snapshot could still show the pre-first-edit durationMs, causing the second identical != comparison to miss the difference and silently skip clearResultMessages().

In practice each onChange fires as a separate browser event, React flushes between them, and the closure is always up-to-date, so this is a theoretical edge. If robustness matters here the comparison could use a useRef that's kept in sync on each committed render, or the banner-clear could be done unconditionally on every duration-input change (the current no-op-blur concern is already handled since onBlur re-commits the same value, not onChange). Not blocking.

@github-actions

Copy link
Copy Markdown
Contributor

Code review placeholder - will replace

@github-actions

Copy link
Copy Markdown
Contributor

test

@github-actions

Copy link
Copy Markdown
Contributor

Code review

@github-actions

Copy link
Copy Markdown
Contributor

Code review

After 8+ rounds of CodeRabbit/Claude iteration the code is in good shape. Three areas worth noting:

Bugs

move — inner bounds guard removed from functional updater (medium confidence, see inline): The outer check on keyframes.length works today because closure snapshots are always fresh in synchronous click handlers. But the original code also had the guard inside the setKeyframes updater (using next.length, the latest committed state), which is the React-canonical way to make updaters self-contained. Removing it means an unchecked swap could write undefined into the array if current.length ever diverges — in concurrent React scenarios, Strict Mode double-invocations, or future refactoring that batches multiple setKeyframes calls. Cost of the fix is one line (see inline suggestion).

Quality

setSegmentSeconds — stale-closure comparison (low confidence, see inline): keyframes.find() reads the render snapshot to decide whether to call clearResultMessages(). If React ever batches two rapid duration edits before flushing, the comparison uses a stale base value and the banner might not clear. Practically safe today since each onChange fires as a separate event, but it is the same class of pattern that motivated moving the guard outside the updater in move.

URL.revokeObjectURL timing in the anchor-download fallback (low confidence, pre-existing, not in diff): saveBinaryFileBrowser revokes the object URL synchronously right after link.click(). For large files the spec technically allows async URL resolution; Chrome and Firefox capture it synchronously so this does not bite in practice. A long setTimeout before revoking is the conventional defensive pattern. Not introduced by this PR, not blocking.

Security

No issues. Filename normalization (trim + .webm regex) is sufficient; the browser sanitizes the result for filesystem safety, and React escapes savedName before rendering it in the banner.

Performance

The Blob-passthrough optimization in saveBinaryFileBrowser / saveBinaryFileWithFallback is correct and well-motivated: the browser path avoids a full arrayBuffer() copy entirely, and the Tauri path defers materialization until after the dialog confirms, so cancel-and-retry never re-copies a large recording.

CLAUDE.md

  • i18next conventions followed; new strings in en.json (source of truth); no other locale has a recordTour section so English fallback is already the baseline.
  • No direct MapLibre mutations; state-driven pattern maintained.
  • No commits directly to main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

## UX Report: Layout and Workflow Optimization for Record Map Tour

1 participant