From fff39f8d762ae69bc4052df395ab9519ea9e17b3 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 13:16:55 +0200 Subject: [PATCH] polish(desktop): non-destructive Change Instance + recovery enter/exit logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX bug found during smoke testing: clicking Change Instance immediately deleted the saved instance URL and showed an empty picker, with no way back if the user changed their mind. Fix: - Don't clearInstanceUrl() in recovery action 'change-instance' — picker is now non-destructive - Picker pre-fills the input with the current saved URL when present - Cancel button (shown only when a saved URL exists) returns to current instance via idempotent setInstanceUrl re-save - Header copy switches to 'Switch instance' / 'Cancel to stay' framing when a saved URL is present - URL only overwrites on explicit Connect to a different instance Also: add console.log enter/exit lines in enterRecoveryMode and the clear-recovery-state action handlers, so smoke-test scripts can grep stderr for recovery activity without UI introspection. Spec + docs/systems/desktop.md updated. --- docs/systems/desktop.md | 28 +++++++++- .../desktop/resources/instance-picker.html | 53 +++++++++++++++++++ packages/desktop/src/recovery.ts | 8 ++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/docs/systems/desktop.md b/docs/systems/desktop.md index 1c2b430c..8d1b0888 100644 --- a/docs/systems/desktop.md +++ b/docs/systems/desktop.md @@ -105,7 +105,17 @@ When no instance URL is configured, the app loads `resources/instance-picker.htm After navigation (both to an instance URL and back to the picker), the main process forces Electron to re-evaluate drag regions by momentarily resizing the window (+1px then back). -The tray menu and macOS app menu both include a "Change Instance" option that clears the saved URL and reloads the picker. +### Non-destructive "Change Instance" navigation + +The tray menu, macOS app menu, and recovery surface all include a "Change Instance" option. This navigation is **non-destructive**: the saved instance URL is preserved when navigating to the picker. The picker's `init()` function reads the current saved URL via `getInstanceUrl()` IPC and, if one exists: + +- Pre-fills the URL input with the current value. +- Shows a Cancel button (hidden by default; only shown when a saved URL exists). +- Switches the header copy from "Welcome to Backspace / Connect to your instance" to "Switch instance / Connect to a different Backspace instance, or cancel to stay." + +**Cancel button behavior:** Clicking Cancel re-saves the existing URL via `setInstanceUrl` (idempotent) and navigates back to it. The saved URL is only overwritten when the user explicitly clicks Connect on a *different* URL. This means the user can always back out of an accidental "Change Instance" click. + +**Loading state:** `setLoading(true)` — invoked when Connect is clicked — disables both the Connect button and the Cancel button to prevent a race between the `setInstanceUrl` calls. --- @@ -185,7 +195,7 @@ All four assets are produced by `scripts/gen-icons.mjs` from `assets/brand/{mark |------|--------| | Show Backspace | `window.show()` + `focus()` | | Hide | `window.hide()` | -| Change Instance | Clear saved URL, load picker, show + focus | +| Change Instance | Load picker (non-destructive — saved URL preserved), show + focus | | Quit | Set `isQuitting = true`, `app.quit()` | Tray click toggles window visibility (show/hide). @@ -326,12 +336,26 @@ Page reads initial state via `getRecoveryState()` IPC and subscribes to `recover | Open Releases Page | `updateState === 'error'` | always when visible | | Quit Backspace | always | always | +**Change Instance from recovery is non-destructive.** The saved URL is not cleared when navigating to the picker; see the Instance Picker section above for the full behavior (pre-filled input, Cancel button, header copy update). + Hint text is computed as a function of `(reason.code, updateState)` — see code in `recovery.html`. The `renderer-stalled` text intentionally avoids claiming an update is the cause (slow Pi/cold cache could also trigger it). `lastCheckResult` provides transient inline feedback ("You're up to date" / "Update check failed") with 5s auto-decay. Without this, the user has no signal that a Check for Updates click ran when the result is no-update. Cmd/Ctrl+R is wired as a keyboard shortcut for Reload. +### Observability Logging + +`recovery.ts` emits structured `console.log` lines on entry and exit so smoke-test scripts can grep stderr without UI introspection: + +| Event | Log line | +|-------|----------| +| Recovery entered | `[recovery] entered: ` | +| Exited via Reload | `[recovery] exited (reload)` | +| Exited via Change Instance | `[recovery] exited (change-instance)` | + +The enter log fires after the state update but before the re-entry guard, so repeated entry (reason update with no re-navigation) also logs — useful for diagnostics. + ### Loop Prevention / Contained Failure If `recovery.html` itself fails to load (corrupt resources, packaging bug), `did-fail-load` re-fires inside the recovery context. The `isInRecoveryMode` guard prevents infinite reload loops — `state.reason` updates for display purposes but no second `loadFile()` is issued. User-visible outcome is a blank window with tray-only escape (Quit). This is **contained failure**, not graceful failure: a corrupt `recovery.html` means a corrupt build that requires a fresh install. diff --git a/packages/desktop/resources/instance-picker.html b/packages/desktop/resources/instance-picker.html index 73ec2a80..b427dae0 100644 --- a/packages/desktop/resources/instance-picker.html +++ b/packages/desktop/resources/instance-picker.html @@ -136,6 +136,29 @@ } /* ── Button — accent-primary with white text (matches login) ── */ + .cancel-btn { + width: 100%; + margin-top: 0.5rem; + padding: 0.625rem; + background: transparent; + color: #a0a0aa; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 4px; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .cancel-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.04); + color: #efefef; + } + .cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .connect-btn { width: 100%; padding: 0.625rem; @@ -264,6 +287,7 @@ /> +