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
New phase chained after UPDATE-SELFTEST (so a full SHOTDECK_PICKER_SELFTEST
chain now prints all five PASS lines) and also runnable standalone via
REDLINE_SELFTEST_PHASE=onedrive, since the harness has no other per-phase
selector.
Points TransportSettings at a NEW "Redline-selftest-<Dubai timestamp>" folder
under the real /Users/.../OneDrive-MMDGROUP sync root (never a fake home tree
— that proves the transport against the actual OneDrive file provider), drives
a seeded session through the OneDrive branch of send(anchor: nil), asserts the
PDF landed, the session archived, and the status starts with "Saved to
OneDrive", then confirms the watcher does NOT report the fresh unmarked PDF
as returned. It then adds a real PDFKit ink annotation to that PDF in place —
what the iPad does — saves it, and confirms the watcher now reports it as
commented. Prints "ONEDRIVE-SELFTEST PASS path=<folder>". Never deletes
anything under OneDrive; the created folder and PDF are left in place.
UserDefaults.standard's transport/oneDriveFolder keys are snapshotted and
restored around the phase, the same pattern runRegionPersistPhase already
uses for CaptureRegion — there is no separate defaults-suite threading
through AppModel/send(), so this is the only way to drive the real send()
path without leaving the real app pointed at the selftest folder afterward.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
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
Adds SendTransport/TransportSettings (UserDefaults-backed, mirrors FolderSettings)
and OneDriveLocator, which finds a OneDrive-* sync root under
~/Library/CloudStorage and resolves the Redline send/watch folder inside it
(MMD-named roots preferred). TransportSettings.effectiveFolders() is the one
function that combines the transport choice with FolderSettings/OneDriveLocator.
ReturnWatcher gains recordUncommented (default true, today's AirDrop behaviour):
when false, a document with zero human marks is neither recorded into the
ledger nor returned by scanNow. This is needed because in OneDrive mode the
outbox and watch folder are the same folder, so a freshly written, unmarked
PDF must not be treated as a return — only a later, actually marked-up save
of the same file should be.
Adds ShotdeckError.oneDriveFolderUnavailable(path:) for when the OneDrive
folder is missing or unwritable at send time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
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>
Root cause: RegionPickerView lacked acceptsFirstMouse — as an LSUIElement accessory app
Shotdeck is never active when the hotkey fires, so the user's first click on the overlay
was refused and the drag never started. Also plumbs the monitored event's location through
the controller (hardware-cursor reads made the chain untestable). Adds PickerSelfTest
(SHOTDECK_PICKER_SELFTEST): posts synthetic mouse events through the app's own queue,
asserts the exact CaptureRegion, saves a mid-drag overlay bitmap. Coordinator ran it:
PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0); overlay bitmap shows dim+punch+chip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>