13 Commits
Author SHA1 Message Date
Claude Fable 5 905a019823 fix(app-model): separate status channels — update messages no longer hide capture summary
Root cause: updateStatusMessage was an alias to statusLine, so update feedback appeared
both in the header (where capture summary belongs) and footer (intended). Both MenuBarView
header and footer rendered the same value, creating duplication and information loss.

Fix: introduce updateStatus property separate from statusLine. Route UpdateChecker.statusMessage
into setUpdateStatus(), not setStatus(). Header now shows capture summary uninterrupted;
footer-only shows update feedback. Both channels now independent.

- Add public updateStatus property to AppModel
- Add setUpdateStatus() mutator
- Change updateStatusMessage property to return updateStatus instead of statusLine
- Route onChecked callback to setUpdateStatus, not setStatus
- Update PanelSnapshot helper to use setUpdateStatus
- Add test asserting channel independence

Panel-12 and panel-13 now render correctly: capture summary in header, update status only
in footer; no duplication or information loss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 11:05:14 +04:00
kua-agent eb1af5bf18 Merge pull request 'Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631)' (#23) from feat/redline-onedrive-transport-20260905 into main
Merge pull request #23: Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631)
2026-09-05 06:17:29 +00:00
kua-agentandClaude Fable 5.1 064e410e30 feat: surface check/revert/version in the menu and badge the icon
AppModel exposes appVersion, previousVersion, isCheckingForUpdates and
updateStatusMessage (an alias for the existing statusLine plumbing — one
status channel, not a new one), plus checkForUpdates() and
revertToPreviousVersion() wired to the hardened UpdateChecker.

Menu gains, in order: "Update to X" (unchanged, staged-only), "Check for
updates" (labelled "Checking…" and disabled mid-check), "Revert to <version>"
(only when a rollback copy exists), then the existing rows unchanged, then a
non-interactive footer "Redline <version>" with the status line under it —
same caption/secondary styles already used elsewhere in the file, no new
tokens.

Menu-bar icon gets a small badge while an update is staged: uses the SF
Symbol's own ".badge" variant when one exists for the current icon, otherwise
overlays a small dot on the plain symbol. Reads live model state, so the
badge disappears on its own once the offer clears.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:00:17 +04:00
kua-agentandClaude Fable 5.1 04d1e73532 fix: store pendingReconcileTask handle; skip real Carbon hotkey binding on self-test runs
AppModel gains pendingReconcileTask (the most recent watcher-reconcile Task
spawned by chooseTransport/chooseOneDriveFolder), which send() now awaits —
see the SendController.swift commit in this series. chooseTransport/
chooseOneDriveFolder store their Task's handle into it instead of firing an
untracked `Task { }`.

bootstrap() now also skips binding the real, process-wide Carbon global
capture hotkey on the same env-var-flagged self-test/headless runs that
already skip the update-check schedule (PickerSelfTest's phases,
PanelSnapshot, and the new ShotdeckTests launch-wiring regression test).
Real Carbon hotkey registration is not safe to exercise in an automated test
process — it can collide with ShotdeckCoreTests' own
HotkeyCenterCarbonTests running in the same test binary — and bootstrap()'s
hotkey step had never actually been exercised by any self-test before (none
of them call bootstrap() directly) until the new real-wiring test in this
series does. A real user launch never sets these env vars, so production
behavior is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:55:16 +04:00
kua-agentandClaude Fable 5.1 ef0d8712f7 fix: BLOCKER — launch model uses transport-aware paths; bootstrap reconciles watcher folder unconditionally
AppDelegate.makeLaunchModel() built `paths` via the AirDrop-only
FolderSettings.resolvedAppSupportPaths(), so ReturnWatcher's internal
watchFolder (seeded from paths.watchFolder in its own init) was the AirDrop
folder even when OneDrive was the persisted transport, and bootstrap() never
reconciled it before starting. Net effect: on every relaunch with OneDrive
selected, PDFs went to OneDrive but FSEvents kept watching the stale AirDrop
folder for the whole session — marked-up returns were never detected.

Fix: makeLaunchModel() now calls TransportSettings.resolvedAppSupportPaths()
(single source of truth for the transport-folder mapping); made internal
(not private) with an optional appSupportRoot override so
PickerSelfTest's relaunch-simulation sub-step can call the exact same
function against a temp root instead of the real Application Support folder.
bootstrap() now unconditionally calls watcher.updateWatchFolder(watchFolderURL)
before watcher.start() (belt-and-suspenders reconciliation, even though the
paths fix alone already makes this a no-op in the normal case), and sets
recordUncommented before start as it already did.

Regression tests proving this land in the same PR (ReturnWatcherTests.swift):
one characterizing the old bug's exact construction still missing a marked
OneDrive-mode return, one proving the fixed launch-construction path detects
it end to end. The ONEDRIVE-SELFTEST phase also gains a relaunch sub-step
using this same makeLaunchModel() function.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:27:42 +04:00
kua-agentandClaude Fable 5.1 209921f084 feature: model-owned resolvedOneDriveFolder + OneDrive Settings/menu panel snapshots
SettingsView's OneDrive row called OneDriveLocator.resolveOneDriveFolder()
directly with the real UserDefaults.standard and the real home directory,
which made that row impossible to drive from a fake/isolated environment.
Moved that resolution into AppModel as a tracked resolvedOneDriveFolder
property (nil means "no OneDrive folder found"), refreshed at init,
bootstrap, chooseTransport, chooseOneDriveFolder, and inside send()'s live
folder check. SettingsView and chooseOneDriveFolder's picker-start path now
read model.resolvedOneDriveFolder instead of calling OneDriveLocator
directly — state flows through the model like everything else in this app.

PanelSnapshot (SHOTDECK_SNAPSHOT_DIR) adds three panels on a SEPARATE
isolated model so the transport switch never bleeds into the six existing
AirDrop-mode panels:
- panel-07-settings-onedrive.png: transport=oneDrive with a resolved folder,
  built by pointing OneDriveLocator.defaultRedlineFolder at a fake home tree
  (Library/CloudStorage/OneDrive-MMDGROUP under this snapshot's own temp
  root) so the displayed path is shaped like the real default without ever
  touching the real home.
- panel-08-settings-onedrive-missing.png: a fake home with no
  Library/CloudStorage at all, resolved through a throwaway UserDefaults
  suite (never .standard) so the "no OneDrive folder found" state and its
  still-usable Choose... button are exercised for real.
- panel-09-captures-present-onedrive.png: 3 captures + transport=oneDrive,
  confirming the menu row reads "Send to OneDrive".

Extracted the 3-swatch capture seeding (panel 04) into addSampleCaptures(to:)
so panel 09 reuses it instead of duplicating the loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:10:59 +04:00
kua-agentandClaude Fable 5.1 6449c72b3b feature: OneDrive folder as a second send transport
send(anchor:) now branches on TransportSettings.transport(). OneDrive mode
skips AirDrop entirely: it verifies the resolved OneDrive folder exists right
before composing (never trusts stale state), archives the session immediately
after the PDF lands, and sets "Saved to OneDrive — N page(s). Open it in Files
on your iPad." AirDrop's existing behaviour, including handleDidFailToShareItems,
is untouched and only reached from the .airDrop branch.

AppModel seeds outbox/watch from TransportSettings.effectiveFolders() instead
of FolderSettings.resolve() directly, tracks the live `transport`, and
bootstrap() creates the OneDrive folder and sets the watcher's
recordUncommented flag (true only for AirDrop) before the watcher starts.

Settings gets a "Send via" segmented picker above Folders. AirDrop shows the
existing watch/output rows; OneDrive shows a single read-only OneDrive folder
row (Choose... reuses the existing directory picker) plus one caption
explaining the same-folder round trip, or a "No OneDrive folder found" prompt
when nothing resolves (Choose... stays usable). Switching transport re-points
the watcher's folder and recordUncommended live; AirDrop's own folder
overrides are stored separately and are untouched by a OneDrive-and-back
round trip.

Menu's "Send..." row reads "Send to OneDrive" when that transport is active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
2026-09-05 09:01:13 +04:00
kua-agent 36071aed84 Merge remote-tracking branch 'origin/feat/shotdeck-20260830' into fix/send-truth-20260902
# Conflicts:
#	Sources/Shotdeck/PickerSelfTest.swift
2026-09-02 09:17:09 +04:00
kua-agent 5e61cd735c fix: archive only after AirDrop completes; PDF named by send time; Reveal last PDF 2026-09-02 09:12:08 +04:00
kua-agent c01653e9aa feature: built-in auto-update (appcast + sha256 + staged install), version 0.2.0 2026-09-02 09:08:42 +04:00
kua-agent fa3e7b0a4a feature: user-selectable capture hotkey with recorder in Settings 2026-09-01 22:33:13 +04:00
kua-agentandClaude Fable 5 517a4e4fdc fix: load persisted capture region at launch; REGION-PERSIST selftest phase
Ben-reported: picker opened on every activation. AppModel.init set region = nil and never
read UserDefaults back; saving worked, every launch forgot it. init now loads via
loadPersistedRegion() (decode + isStillValid). Selftest phase 2 writes a known region,
reloads through the same path, asserts the rect, restores the user's stored value.
Coordinator ran it: PICKER-SELFTEST PASS + REGION-PERSIST PASS, 90/90 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:20:40 +04:00
kua-agent fdac2f2f47 WP-4a: menu-bar shell, AppModel, session strip per SPEC-A2b + integration contracts 2026-08-31 15:59:37 +04:00