Smolspec-scoped change wiring a SwiftUI .dropDestination(for: URL.self) on the macOS window and routing dropped URLs through the existing DocumentFlowCoordinator.handleOpenURL pipeline. Spec: specs/macos-url-drop/.
.dropDestination(for: URL.self) on the NavigationStack root in MainContentView, #if os(macOS)-gated.DroppedURLRouter filters dropped [URL] by scheme and extension — fully unit-tested.DocumentFlowCoordinator.handleOpenURL grows one http(s) branch so drop, NSWorkspace open, and AppleScript share the same code path.openFile(at:) cancels any in-flight remote download before activating the new session, closing a pre-existing race made reachable by the drop entry point.public.url in LSItemContentTypes with system-wide implications. File URLs already work via existing CFBundleDocumentTypes.Ready to push
All four review agents reported clean findings or pass-with-notes. Three concrete issues were raised and fixed in the third commit: case-insensitive scheme comparison in handleOpenURL, hoisting ["md", "markdown"] onto a shared MarkdownFileExtensions helper (reused by both the drop classifier and URLDocumentLoader), and a cosmetic tasks.md typo. Builds pass (macOS + iOS). The new targeted test suites (DroppedURLRouterTests, DocumentFlowCoordinatorURLTests) pass with 96 / 0 (parallelised across worker processes). The known make test-quick teardown hang is unrelated to this change.
47e7fbb T-432: smolspec for macOS URL drag & drop fff2c95 T-432: macOS URL drag-and-drop support 53d1954 T-432: review fixes — case-insensitive schemes & shared extension set You can now drag a markdown file from Finder or a link from Safari directly onto the Prism window on macOS, and Prism opens it.
Before T-432, opening a file from outside required going through the “Open File” picker, and opening a remote URL required pasting it into the URL input sheet. Drag-and-drop is the conventional Mac shortcut for both.
.dropDestination(for:action:).handleOpenURL). The drop handler hands the URL to that method.The change splits cleanly into three layers:
DroppedURLRouter in prism/Services/ is an enum-namespace with two static functions: isSupported(_:) and firstSupported(in:). Pure function of URL → Bool; no I/O, no side effects, fully unit-testable..dropDestination(for: URL.self) is attached to MainContentView.body inside an #if os(macOS) block alongside .onOpenURL. On a hit it calls flowCoordinator.handleOpenURL(url) and returns true; on miss it returns false (system rejection animation).DocumentFlowCoordinator.handleOpenURL gains one branch: http/https schemes route to requestOpenRemoteURL. Comparison is case-insensitive (RFC 3986 compliance via url.scheme?.lowercased()).MarkdownFileExtensions.accepted on prism/Models/UTType+Markdown.swift is the single source of truth for entry-point classification. Intentionally narrower than LinkPathResolver.markdownExtensions (which is broader for in-document link resolution)..dropDestination(for: URL.self) over .onDrop(of:[.url,.fileURL],…) for type safety; cost is the system shows hover highlight for any URL payload, not only supported ones. Rejection animation kicks in when the closure returns false. Acceptable for a smolspec.handleOpenURL vs. handling http(s) inside the drop closure: centralising means any future entry point gets the same routing for free. Cost is one new branch.SwiftUI body re-evaluation. The .dropDestination modifier closure captures flowCoordinator (an @Observable @MainActor reference owned by a parent @State). SwiftUI rebuilds the closure on every body evaluation, but the modifier itself doesn't depend on any observable property — only the closure execution path does. No retain cycle: the closure lives in the view tree, the view tree is owned by SwiftUI, @State is owned by MainContentView, and the closure doesn't transitively own the view's @State.
Concurrency. DocumentFlowCoordinator is @MainActor-isolated. SwiftUI evaluates view bodies on the main actor, so the drop closure runs on the same actor as handleOpenURL without an actor hop. URL is Sendable. No Swift 6 strict-concurrency warnings.
Race window in openFile. cancelDownload() runs synchronously on the main actor, then Data(contentsOf:) runs synchronously on the main actor. A drop arriving during the synchronous read cannot interleave — the main actor is busy. The hung-loader regression test in DocumentFlowCoordinatorURLTests proves that downloadTask?.isCancelled == true immediately after openFile(at:) returns, even when the loader is mid-flight. Note: Data(contentsOf:) on the main actor stalls the UI for large files; pre-existing tech debt, not introduced by T-432.
HTTPS://, HTTP://) — covered by handleOpenURL lowercasing plus an explicit test..MD, .Markdown) — MarkdownFileExtensions.isAccepted lowercases.mailto:, ftp:, prism://bundled from a drop) — classifier returns nil, drop closure returns false, system animates rejection.requestOpen* methods; preserves existing “latest action wins” semantics (Decision 6).| Surface | Change |
|---|---|
DroppedURLRouter.swift | New — 38 lines, pure functions. |
UTType+Markdown.swift | Added MarkdownFileExtensions shared by drop + URLDocumentLoader. |
URLDocumentLoader.swift | Replaced inline pathExtension == with MarkdownFileExtensions.isAccepted. |
DocumentFlowCoordinator.swift | http(s) branch in handleOpenURL; cancelDownload() in openFile(at:); test-only setter. |
prismApp.swift | .dropDestination modifier on the NavigationStack root under #if os(macOS). |
prism/prismApp.swift
Why it matters. This is the user-visible entry point. Drop is gated to macOS via #if; the closure performs scheme/extension classification before invoking the existing router so unsupported drops are visually rejected.
What to look at. prismApp.swift lines 379-393
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. Single canonical URL router. Any future entry point (drop, NSWorkspace open, AppleScript) inherits this routing for free, and the case-insensitive lowercasing fixes a pre-existing correctness gap with the prism:// branches that always worked by accident on lowercase input.
What to look at. DocumentFlowCoordinator.swift handleOpenURL — lines 97-119
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. Closes a pre-existing race: a slow remote download in flight could land after a freshly opened local file and clobber the navigation. The drop entry point exposes the race concretely (multi-step UI was the previous protection).
What to look at. DocumentFlowCoordinator.swift openFile(at:) — lines 228-243
prism/Models/UTType+Markdown.swift
Why it matters. Removed the second copy of ["md", "markdown"] (and revealed that LinkPathResolver intentionally uses a wider set for in-document link resolution). DroppedURLRouter and URLDocumentLoader now share the narrow entry-point set; future entry points can join without re-declaring.
What to look at. UTType+Markdown.swift — new MarkdownFileExtensions enum at lines 18-29
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. Allows unit tests to inject a RemoteContentCoordinator without constructing the full configure(...) dependency graph (DocumentServices, ImageServices, SystemColorSchemeObserver are unrelated to URL routing). Self-documenting via the method name.
What to look at. DocumentFlowCoordinator.swift setRemoteCoordinatorForTests(_:) — lines 96-101
Modern API gives typed [URL] directly, avoiding manual NSItemProvider.loadItem boilerplate. No behaviour we need from the older API at our deployment target (macOS 26+).
One drop zone covering both Home and the open document. Per-destination modifiers would duplicate logic and risk drift.
Centralises routing so any future caller (drop, NSWorkspace, AppleScript) gets the same dispatch. Additive and cannot regress existing callers (.onOpenURL today only sees prism:// and file://).
Adding public.url to LSItemContentTypes has system-wide implications (Prism becomes a viewer candidate for arbitrary URLs across macOS). Deserves its own scope assessment. File URLs on the Dock icon already work via the existing CFBundleDocumentTypes entry.
Closes a pre-existing race centrally. Putting cancellation in the drop closure would leave the gap for the file importer and recent files.
Preserves existing behaviour shared with paste, the URL input sheet, and the Open menu. Drop-specific suppression would create inconsistency without user benefit.
RFC 3986 schemes are case-insensitive; URL.scheme preserves input casing. Lowercasing once is the minimal correctness fix.
Intentionally narrower than LinkPathResolver.markdownExtensions. Entry points must agree with CFBundleDocumentTypes; link resolution can be broader internally.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | DocumentFlowCoordinator.handleOpenURL | Scheme comparison was case-sensitive while DroppedURLRouter lowercased — HTTPS://… would slip through the router. Real RFC 3986 correctness gap. | Lowercase the scheme once at the top of handleOpenURL and compare against the lowercased value. Added test for uppercase HTTPS. |
| minor | DroppedURLRouter & URLDocumentLoader | Both inlined ["md", "markdown"] as the accepted entry-point extension set, drifting from LinkPathResolver's broader set. Two copies of a small constant. | Hoisted to MarkdownFileExtensions enum on UTType+Markdown.swift with an isAccepted(_:) helper; both call sites now share it. Decision documented to clarify why this set is intentionally narrower than LinkPathResolver's. |
| minor | specs/macos-url-drop/tasks.md | Copy-paste artifact on the Blocked-by line (rune --details splits on commas; the batch update produced duplicated text). | Rewrote the line by hand. |
| minor | DocumentFlowCoordinator | setRemoteCoordinatorForTests(_:) is a public method shipped in release builds. | Kept as-is — the method name is self-documenting, and gating on #if DEBUG adds ifdef noise without meaningful protection. The alternative (full configure(...) in tests) would force construction of three unrelated dependency types. |
| minor | Synchronous Data(contentsOf:) on main actor in openFile | Large local files stall the UI during the synchronous read. Pre-existing, made marginally more reachable by the drop entry point. | Out of scope for T-432; flagged for a follow-up ticket. |
Click to expand.
diff --git a/prism/Services/DroppedURLRouter.swift b/prism/Services/DroppedURLRouter.swift
new file mode 100644
index 0000000..cbc6b17
--- /dev/null
+++ b/prism/Services/DroppedURLRouter.swift
@@ -0,0 +1,39 @@
+//
+// DroppedURLRouter.swift
+// prism
+//
+// T-432: macOS URL drag & drop.
+//
+
+import Foundation
+
+/// Filters a batch of dropped URLs down to the single URL that Prism should
+/// open, matching the rules in `specs/macos-url-drop/smolspec.md`.
+///
+/// `.dropDestination(for: URL.self)` validates the Transferable payload as a
+/// URL but not its scheme or extension; this helper performs the scheme and
+/// extension check so the drop closure can return `false` (and produce the
+/// system rejection animation) when no candidate is supported.
+enum DroppedURLRouter {
+
+ /// Returns the first URL whose scheme is `file` with a supported
+ /// markdown extension, or `http`/`https`. Returns `nil` when no URL in
+ /// `urls` is supported. URLs are evaluated in their input order so the
+ /// first match wins, matching the single-document behaviour of the file
+ /// importer.
+ static func firstSupported(in urls: [URL]) -> URL? {
+ urls.first { isSupported($0) }
+ }
+
+ /// Whether `url` is a supported drop target. Exposed for unit testing.
+ static func isSupported(_ url: URL) -> Bool {
+ let scheme = url.scheme?.lowercased()
+ if scheme == "http" || scheme == "https" {
+ return true
+ }
+ if url.isFileURL {
+ return MarkdownFileExtensions.isAccepted(url)
+ }
+ return false
+ }
+}
diff --git a/prism/prismApp.swift b/prism/prismApp.swift
index 93d727a..b432123 100644
--- a/prism/prismApp.swift
+++ b/prism/prismApp.swift
@@ -376,6 +376,21 @@ struct MainContentView: View {
}
}
.onOpenURL(perform: flowCoordinator.handleOpenURL)
+ #if os(macOS)
+ // T-432: accept markdown URLs dropped on the window.
+ // URL.self Transferable only validates that the payload is a URL,
+ // not its scheme or extension, so the closure runs the scheme /
+ // extension check itself. Returning false on an unsupported drop
+ // triggers the system rejection animation.
+ // See specs/macos-url-drop/smolspec.md.
+ .dropDestination(for: URL.self) { urls, _ in
+ guard let supported = DroppedURLRouter.firstSupported(in: urls) else {
+ return false
+ }
+ flowCoordinator.handleOpenURL(supported)
+ return true
+ }
+ #endif
.onChange(of: scenePhase) { _, newPhase in
flowCoordinator.handleScenePhaseChange(newPhase)
if newPhase == .active {
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swift
index ed5af78..b1c9cfd 100644
--- a/prism/ViewModels/DocumentFlowCoordinator.swift
+++ b/prism/ViewModels/DocumentFlowCoordinator.swift
@@ -91,19 +91,32 @@ final class DocumentFlowCoordinator {
self.remoteCoordinator = remoteCoordinator
}
+ /// Test-only setter for `remoteCoordinator`. Avoids requiring the full
+ /// `configure(...)` dependency graph in unit tests that only exercise
+ /// URL routing or download cancellation.
+ func setRemoteCoordinatorForTests(_ coordinator: RemoteContentCoordinator) {
+ self.remoteCoordinator = coordinator
+ }
+
// MARK: - URL Handlers
/// Handles incoming URLs, routing bundled document URLs, remote URLs, and file URLs.
func handleOpenURL(_ url: URL) {
- if url.scheme == "prism", url.host == "bundled",
+ // RFC 3986 says URL schemes are case-insensitive; `URL.scheme` does
+ // not normalise them. Lowercase once so a `HTTPS://…` URL routes the
+ // same as `https://…`.
+ let scheme = url.scheme?.lowercased()
+ if scheme == "prism", url.host == "bundled",
let resourceName = url.pathComponents.dropFirst().first,
BundledDocument.knownResources.contains(resourceName) {
requestOpenBundledDocument(resourceName: resourceName)
- } else if url.scheme == "prism", url.host == "open",
+ } else if scheme == "prism", url.host == "open",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let urlParam = components.queryItems?.first(where: { $0.name == "url" })?.value,
let remoteURL = URL(string: urlParam) {
requestOpenRemoteURL(remoteURL)
+ } else if scheme == "http" || scheme == "https" {
+ requestOpenRemoteURL(url)
} else if url.isFileURL {
requestOpenFile(url: url)
}
@@ -221,6 +234,13 @@ final class DocumentFlowCoordinator {
func openFile(at url: URL, fragment: String? = nil) {
loadError = nil
+ // Cancel any in-flight remote download so a late-arriving response
+ // cannot clobber the local session we are about to activate. This
+ // gap is reachable through the file importer, recent files, and the
+ // macOS drop entry point. See specs/macos-url-drop/decision_log.md
+ // Decision 5.
+ remoteCoordinator?.cancelDownload()
+
let isSecurityScoped = url.startAccessingSecurityScopedResource()
defer {
diff --git a/prism/Models/UTType+Markdown.swift b/prism/Models/UTType+Markdown.swift
index 593ad17..5ef27e6 100644
--- a/prism/Models/UTType+Markdown.swift
+++ b/prism/Models/UTType+Markdown.swift
@@ -5,6 +5,7 @@
// Created by Arjen Schwarz on 6/1/2026.
//
+import Foundation
import UniformTypeIdentifiers
extension UTType {
@@ -12,3 +13,22 @@ extension UTType {
/// Corresponds to the imported type declaration in Info.plist.
static let markdown = UTType(importedAs: "net.daringfireball.markdown")
}
+
+/// File-extension constants tied to the markdown UTType / Info.plist
+/// document type declaration.
+///
+/// Intentionally narrower than `LinkPathResolver.markdownExtensions`, which
+/// also accepts archaic spellings (`mdown`, `mkd`, `mkdn`) for inline link
+/// resolution. Entry-point classification (file importer, macOS drop) must
+/// agree with the `CFBundleDocumentTypes` declaration in Info.plist, so it
+/// uses this narrower set.
+enum MarkdownFileExtensions {
+ /// Extensions accepted as markdown for app entry points. Compared
+ /// case-insensitively against `URL.pathExtension`.
+ static let accepted: Set<String> = ["md", "markdown"]
+
+ /// Whether `url`'s path extension is in `accepted` (case-insensitive).
+ static func isAccepted(_ url: URL) -> Bool {
+ accepted.contains(url.pathExtension.lowercased())
+ }
+}
diff --git a/prism/Services/URLDocumentLoader.swift b/prism/Services/URLDocumentLoader.swift
index 155105b..8f7fa3e 100644
--- a/prism/Services/URLDocumentLoader.swift
+++ b/prism/Services/URLDocumentLoader.swift
@@ -166,9 +166,7 @@ enum URLDocumentLoader {
}
// Content type check for non-.md URLs (Req 2.5)
- let pathExtension = transformed.fetchURL.pathExtension.lowercased()
- let isMarkdownExtension = pathExtension == "md" || pathExtension == "markdown"
- if !isMarkdownExtension {
+ if !MarkdownFileExtensions.isAccepted(transformed.fetchURL) {
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")?.lowercased() ?? ""
// application/octet-stream is accepted because some raw file hosts
// (e.g., raw.githubusercontent.com) return it for plain text files.
diff --git a/prismTests/DroppedURLRouterTests.swift b/prismTests/DroppedURLRouterTests.swift
new file mode 100644
index 0000000..f68d517
--- /dev/null
+++ b/prismTests/DroppedURLRouterTests.swift
@@ -0,0 +1,74 @@
+//
+// DroppedURLRouterTests.swift
+// prismTests
+//
+// T-432: macOS URL drag & drop. Verifies the scheme / extension classifier.
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("DroppedURLRouter")
+struct DroppedURLRouterTests {
+
+ @Test("Accepts http and https URLs")
+ func acceptsHTTPandHTTPS() {
+ #expect(DroppedURLRouter.isSupported(URL(string: "http://example.com/a.md")!))
+ #expect(DroppedURLRouter.isSupported(URL(string: "https://example.com/a.md")!))
+ #expect(DroppedURLRouter.isSupported(URL(string: "https://example.com/page")!))
+ }
+
+ @Test("Accepts .md and .markdown file URLs, case-insensitive")
+ func acceptsMarkdownFileURLs() {
+ #expect(DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x.md")))
+ #expect(DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x.markdown")))
+ #expect(DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x.MD")))
+ #expect(DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x.Markdown")))
+ }
+
+ @Test("Rejects non-markdown file URLs")
+ func rejectsOtherFileURLs() {
+ #expect(!DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x.pdf")))
+ #expect(!DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x.txt")))
+ #expect(!DroppedURLRouter.isSupported(URL(fileURLWithPath: "/tmp/x")))
+ }
+
+ @Test("Rejects non-file, non-http(s) schemes")
+ func rejectsOtherSchemes() {
+ #expect(!DroppedURLRouter.isSupported(URL(string: "prism://bundled/welcome")!))
+ #expect(!DroppedURLRouter.isSupported(URL(string: "mailto:user@example.com")!))
+ #expect(!DroppedURLRouter.isSupported(URL(string: "ftp://example.com/x.md")!))
+ }
+
+ @Test("firstSupported returns nil for empty input")
+ func firstSupportedEmpty() {
+ #expect(DroppedURLRouter.firstSupported(in: []) == nil)
+ }
+
+ @Test("firstSupported returns nil when no URL is supported")
+ func firstSupportedAllUnsupported() {
+ let urls = [
+ URL(string: "mailto:user@example.com")!,
+ URL(fileURLWithPath: "/tmp/x.pdf"),
+ URL(string: "prism://bundled/welcome")!,
+ ]
+ #expect(DroppedURLRouter.firstSupported(in: urls) == nil)
+ }
+
+ @Test("firstSupported picks the first supported URL in input order")
+ func firstSupportedOrdering() {
+ let mdFile = URL(fileURLWithPath: "/tmp/first.md")
+ let httpURL = URL(string: "https://example.com/second.md")!
+ // First match wins even when a later URL would also match.
+ let urls = [URL(fileURLWithPath: "/tmp/skipped.pdf"), mdFile, httpURL]
+ #expect(DroppedURLRouter.firstSupported(in: urls) == mdFile)
+ }
+
+ @Test("firstSupported returns a supported URL when mixed with unsupported")
+ func firstSupportedMixed() {
+ let httpURL = URL(string: "https://example.com/x.md")!
+ let urls = [URL(string: "mailto:user@example.com")!, httpURL]
+ #expect(DroppedURLRouter.firstSupported(in: urls) == httpURL)
+ }
+}
diff --git a/prismTests/DocumentFlowCoordinatorURLTests.swift b/prismTests/DocumentFlowCoordinatorURLTests.swift
new file mode 100644
index 0000000..99a4b91
--- /dev/null
+++ b/prismTests/DocumentFlowCoordinatorURLTests.swift
@@ -0,0 +1,116 @@
+//
+// DocumentFlowCoordinatorURLTests.swift
+// prismTests
+//
+// T-432: macOS URL drag & drop.
+//
+// Covers two coordinator-level behaviours surfaced by the drop entry point:
+// - handleOpenURL routes http/https schemes to requestOpenRemoteURL
+// - openFile(at:) cancels any in-flight remote download before activating
+// the new local session.
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("DocumentFlowCoordinator URL handling", .serialized)
+@MainActor
+struct DocumentFlowCoordinatorURLTests {
+
+ /// A loader that hangs until cancelled, used to keep `downloadTask`
+ /// live during the assertion window.
+ private static let hangingLoader: RemoteContentCoordinator.Loader = { _ in
+ try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
+ return URLDocumentLoader.LoadResult(
+ content: "",
+ fetchURL: URL(string: "https://example.com/never.md")!,
+ displayURL: URL(string: "https://example.com/never.md")!
+ )
+ }
+
+ @Test("handleOpenURL routes https URLs to requestOpenRemoteURL")
+ func httpsRoutedToRemoteCoordinator() async {
+ let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
+ let flow = DocumentFlowCoordinator()
+ flow.setRemoteCoordinatorForTests(remote)
+
+ let url = URL(string: "https://example.com/page.md")!
+ flow.handleOpenURL(url)
+
+ #expect(remote.isDownloading == true)
+ #expect(remote.downloadingHost == "example.com")
+
+ remote.cancelDownload()
+ }
+
+ @Test("handleOpenURL routes http URLs to requestOpenRemoteURL")
+ func httpRoutedToRemoteCoordinator() async {
+ let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
+ let flow = DocumentFlowCoordinator()
+ flow.setRemoteCoordinatorForTests(remote)
+
+ let url = URL(string: "http://example.com/page.md")!
+ flow.handleOpenURL(url)
+
+ #expect(remote.isDownloading == true)
+ #expect(remote.downloadingHost == "example.com")
+
+ remote.cancelDownload()
+ }
+
+ @Test("handleOpenURL routes uppercased HTTPS scheme")
+ func uppercaseHTTPSRouted() async {
+ let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
+ let flow = DocumentFlowCoordinator()
+ flow.setRemoteCoordinatorForTests(remote)
+
+ // RFC 3986: schemes are case-insensitive. URL.scheme preserves the
+ // input casing, so the coordinator must lowercase before comparing.
+ let url = URL(string: "HTTPS://example.com/page.md")!
+ flow.handleOpenURL(url)
+
+ #expect(remote.isDownloading == true)
+
+ remote.cancelDownload()
+ }
+
+ @Test("handleOpenURL leaves prism://bundled routing intact")
+ func bundledRoutingUnchanged() async {
+ let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
+ let flow = DocumentFlowCoordinator()
+ flow.setRemoteCoordinatorForTests(remote)
+
+ // No remote download should be started for a bundled URL.
+ let url = URL(string: "prism://bundled/welcome")!
+ flow.handleOpenURL(url)
+
+ #expect(remote.isDownloading == false)
+ }
+
+ @Test("openFile cancels an in-flight remote download")
+ func openFileCancelsRemoteDownload() async throws {
+ let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
+ let flow = DocumentFlowCoordinator()
+ flow.setRemoteCoordinatorForTests(remote)
+
+ // Start a remote download that will hang indefinitely.
+ remote.openRemoteURL(URL(string: "https://example.com/slow.md")!,
+ flowCoordinator: flow)
+ #expect(remote.isDownloading == true)
+ let inflightTask = remote.downloadTask
+ #expect(inflightTask != nil)
+
+ // Now ask the flow coordinator to open a local file. We do not need
+ // the open itself to succeed — only that the cancellation happens
+ // first. Use a non-existent path so the file read fails fast.
+ let bogus = URL(fileURLWithPath: "/tmp/this-file-does-not-exist-T432.md")
+ flow.openFile(at: bogus)
+
+ // openFile is synchronous; cancellation is observable immediately.
+ #expect(remote.downloadTask == nil,
+ "openFile must cancel the in-flight remote download")
+ // The previously captured task should now be cancelled.
+ #expect(inflightTask?.isCancelled == true)
+ }
+}
diff --git a/docs/agent-notes/open-from-url.md b/docs/agent-notes/open-from-url.md
index 0d02c96..ff019b7 100644
--- a/docs/agent-notes/open-from-url.md
+++ b/docs/agent-notes/open-from-url.md
@@ -49,6 +49,13 @@ The feature adds a fourth document source (`.url(remote:display:)`) alongside `.
- `loadNotes(forRemoteURL:blocks:)` uses `DocumentIdentifierResolver.resolve(forRemoteURL:)`
- Identity format: `url/{host}/{path}[?query]`
+### macOS drag & drop (T-432, spec `specs/macos-url-drop/`)
+
+- `.dropDestination(for: URL.self)` is attached to the `NavigationStack` root in `MainContentView.body` under `#if os(macOS)` (single drop zone covering both Home and the document view).
+- `DroppedURLRouter` (`prism/Services/DroppedURLRouter.swift`) is a pure classifier: returns the first URL whose scheme is `file` with a `.md`/`.markdown` extension (case-insensitive) or `http`/`https`, otherwise `nil`. The drop closure returns `false` on `nil` to produce the system rejection animation, since `URL.self` Transferable validates only that the payload is a URL — not its scheme or extension.
+- Routing goes through `DocumentFlowCoordinator.handleOpenURL(_:)`, which was extended to recognise `http`/`https` schemes and route to `requestOpenRemoteURL(_:)`. Centralising at `handleOpenURL` means any future caller (drop, NSWorkspace open, AppleScript) gets the same routing.
+- **Side fix:** `openFile(at:)` now calls `remoteCoordinator?.cancelDownload()` before activating the new local session. Without it, a slow remote download in progress could land *after* a freshly opened local file and clobber the navigation. This race was pre-existing but only reliably reachable through the new drop entry point (Decision 5 in `specs/macos-url-drop/decision_log.md`).
+- **Out of scope (deferred):** Dragging a *web* URL onto the Dock icon — would require adding `public.url` to `LSItemContentTypes` in `prism/Info.plist`, which has system-wide implications (Prism would become a viewer candidate for arbitrary URLs across macOS). File URLs dropped on the Dock icon already work via the existing `CFBundleDocumentTypes` declaration for `net.daringfireball.markdown`.
+
## Deferred Features
- Share Sheet extension (Req 1.4) — requires separate target, App Group
-- macOS drag & drop (Req 1.7) — requires NSItemProvider handling
diff --git a/specs/macos-url-drop/smolspec.md b/specs/macos-url-drop/smolspec.md
new file mode 100644
index 0000000..5b56e7b
--- /dev/null
+++ b/specs/macos-url-drop/smolspec.md
@@ -0,0 +1,49 @@
+# macOS URL Drag & Drop
+
+## Overview
+
+Accept markdown URLs (both local `.md`/`.markdown` files and `http(s)` web URLs) dropped onto the Prism window on macOS. The download/validation pipeline, GitHub URL transformation, unsaved-content confirmation, and progress overlay already exist for both file and remote URL sources; this change wires a single SwiftUI drop target on the macOS window and routes dropped URLs through the existing `DocumentFlowCoordinator`. Transit ticket: T-432. Deferred from the `open-from-url` feature per Decision 5.
+
+## Requirements
+
+- The system MUST accept URL drag-and-drop onto the Prism window on macOS.
+- The system MUST open a dropped local file URL when its extension is `.md` or `.markdown`, using the same code path as the file importer (including security-scoped resource handling and unsaved-content confirmation).
+- The system MUST download and render a dropped `http` or `https` URL using the same code path as the URL input sheet (including GitHub blob transformation, download progress overlay, and unsaved-content confirmation).
+- The system MUST reject the drop (refuse it visually, perform no action) when no dropped URL is supported (e.g., dropped file is not markdown, or scheme is neither `http`/`https` nor a supported `file` URL).
+- The system MUST process at most one URL per drop. When multiple URLs are dropped, the first supported URL is used and the rest are ignored, matching the single-document behaviour of the file importer.
+- The system MUST cancel any in-flight remote download before activating a dropped local file's session, so that a slow remote response cannot replace a freshly opened local document. This is a general fix to `DocumentFlowCoordinator.openFile(at:)` that the drop entry point makes concretely reachable.
+- The system MUST be a no-op on iOS — the drop modifier is `#if os(macOS)`-only.
+- The system SHOULD show the system-default hover highlight while any URL is dragged over the window. The hover highlight is type-based (any `URL` payload activates it); unsupported drops are refused by the closure returning `false`, which produces the system rejection animation.
+- The system SHOULD route both `file://` and `http(s)` URLs through `DocumentFlowCoordinator.handleOpenURL(_:)` so that any future caller (drop, NSWorkspace open, AppleScript) benefits from a single entry point.
+
+## Implementation Approach
+
+**Key files:**
+- `prism/ViewModels/DocumentFlowCoordinator.swift` — extend `handleOpenURL(_ url: URL)` (currently at lines 97–110) so that `http`/`https` URLs route to `requestOpenRemoteURL(_:)`. Place the new branch between the `prism://open` branch and the `isFileURL` branch. The current implementation silently drops `http`/`https` URLs.
+- `prism/ViewModels/DocumentFlowCoordinator.swift` — `openFile(at:)` (line 221) must call `remoteCoordinator?.cancelDownload()` before activating the new session, so an in-flight remote download cannot land after a freshly opened local file.
+- `prism/prismApp.swift` — attach `.dropDestination(for: URL.self)` to the `NavigationStack` root inside `MainContentView.body` under `#if os(macOS)`. The closure inspects each candidate URL in order and returns the first one whose scheme is `file` (with `.md`/`.markdown` path extension, case-insensitive) or `http`/`https`. If a match is found, `flowCoordinator.handleOpenURL(_:)` is called and the closure returns `true`; otherwise it returns `false`, producing the system rejection animation. The scheme/extension check is performed inside the closure (not as a UTType filter at the API surface), because `URL.self` Transferable validates only that the payload is a URL, not its scheme or extension.
+
+**Behaviour during an open unsaved-content dialog:** dropping a URL while the dialog is visible reaches `requestOpenFile` / `requestOpenRemoteURL` and overwrites `pendingAction`. This is the existing "latest action wins" behaviour shared with the URL input sheet, paste, and Open menu commands; it is preserved unchanged. No special drop-suppression logic is required.
+
+**Existing patterns reused:**
+- File open flow: `flowCoordinator.requestOpenFile(url:fragment:)` (`DocumentFlowCoordinator.swift:115`) — already wraps `openFile(at:)` which performs `startAccessingSecurityScopedResource`/`stop…` (`DocumentFlowCoordinator.swift:224-230`).
+- Remote URL flow: `flowCoordinator.requestOpenRemoteURL(_:fragment:)` (`DocumentFlowCoordinator.swift:159`) — already integrates with `RemoteContentCoordinator` for the download overlay and recent-files registration.
+- macOS-only modifiers: pattern is `#if os(macOS) … #endif` blocks inside the SwiftUI `body`, already used in `prismApp.swift:401-409` (`NSApplication.didBecomeActiveNotification` listener).
+
+**Dependencies:** `DocumentFlowCoordinator`, `RemoteContentCoordinator`, `URLDocumentLoader`, `GitHubURLTransformer`, `MarkdownDocument` — all already exist.
+
+**Out of scope (defer to follow-up tickets, mirroring `open-from-url` Decisions 4/5):**
+- Drag of *web* URLs onto the Dock icon. This would require declaring `public.url` in `LSItemContentTypes` in `prism/Info.plist`, which has system-wide implications (Prism becomes a candidate viewer for arbitrary URLs anywhere in macOS). File URLs dropped on the Dock icon already work today via the existing `CFBundleDocumentTypes` declaration.
+- iOS / iPadOS drag and drop. T-432 is macOS-only.
+- Visual error toast on rejected drops. The system's drop-rejection animation is sufficient feedback.
+- Multi-document handling. Prism supports one document per window.
+- Handling Safari `.webloc` files. If observed in practice, file these as a follow-up — the system might surface a Safari URL drag as either a `public.url` or a `.webloc` file URL, and our initial implementation should not paper over that distinction.
+
+## Risks and Assumptions
+
+- **Assumption:** SwiftUI's `URL.self` Transferable conformance on macOS 26 delivers Safari URL drags as `http(s)` URLs (not as `.webloc` file URLs). **Validation:** smoke-test by dragging a link from Safari during manual QA; if it arrives as a `.webloc`, document the limitation and follow up.
+- **Assumption:** Attaching `.dropDestination` to the `NavigationStack` root captures drops over both `HomeView` and `DocumentReaderView` (single drop zone). **Validation:** manual QA covers both states.
+- **Risk:** `URL.self` Transferable on macOS may bypass security-scoped resource granting for dropped file URLs from sandboxed sources. **Mitigation:** `openFile(at:)` already calls `startAccessingSecurityScopedResource()` which returns `false` harmlessly when no scope is needed; the read attempt would surface as an existing `DocumentError` via `loadError` if it fails, which is acceptable for an initial release.
+- **Risk:** Extending `handleOpenURL` to route `http`/`https` URLs could affect callers other than the drop handler. **Mitigation:** the only other caller is `.onOpenURL` (`prismApp.swift:378`), which currently only receives `prism://` and `file://` URLs from the system because no other schemes are registered; routing `http`/`https` is additive and cannot regress existing behaviour.
+- **Sandbox note:** Prism is not currently sandboxed (no `app-sandbox` key in `prism/prism.entitlements`), so dropped file URLs read through `Data(contentsOf:)` without restrictions. When Prism is eventually sandboxed (for App Store distribution), Finder drops will still work via the `com.apple.security.files.user-selected.read-only` entitlement that drag-and-drop implicitly grants; the existing `openFile(at:)` security-scoped resource handling is correct for that future state. Re-verify when sandboxing is enabled.
+- **Prerequisite:** None — all required infrastructure is implemented and shipping.
diff --git a/specs/macos-url-drop/decision_log.md b/specs/macos-url-drop/decision_log.md
new file mode 100644
index 0000000..98bd743
--- /dev/null
+++ b/specs/macos-url-drop/decision_log.md
@@ -0,0 +1,198 @@
+# Decision Log: macOS URL Drag & Drop
+
+## Decision 1: Use SwiftUI `dropDestination(for: URL.self)` over `onDrop` / `NSItemProvider`
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+SwiftUI offers two drop APIs: the older `onDrop(of:isTargeted:perform:)` with `[UTType]` and an `NSItemProvider` callback, and the newer `dropDestination(for:action:isTargeted:)` that uses Swift's `Transferable` protocol and delivers typed values directly. Prism's deployment target is macOS 26+, so both are available.
+
+### Decision
+
+Use `.dropDestination(for: URL.self) { urls, _ in … }` for the macOS window drop target.
+
+### Rationale
+
+The modern API gives a typed `[URL]` directly, avoiding manual `NSItemProvider.loadItem(forTypeIdentifier:)` boilerplate, async unwrap, and main-actor hop. There is no behaviour the older API offers that we need (we don't care about the drop location, and `isTargeted` is supported on both).
+
+### Alternatives Considered
+
+- **`onDrop(of: [.url, .fileURL], …)`**: Verbose `NSItemProvider` unwrap, error-prone main-actor hopping. Rejected — adds complexity for no benefit at our deployment target.
+- **`onDrop(of: [.url, .fileURL], delegate:)` with a `DropDelegate`**: Required for advanced drop visuals (custom highlights, location-sensitive behaviour). We need neither — rejected.
+
+### Consequences
+
+**Positive:**
+- Less code, no `NSItemProvider` plumbing.
+- Type-safe at compile time.
+
+**Negative:**
+- Slightly less control over rejection visuals (system default only). Acceptable per smolspec.
+
+---
+
+## Decision 2: Drop target placement at the `NavigationStack` root in `MainContentView`
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+The drop modifier can be applied to (a) the SwiftUI scene's `WindowGroup` root, (b) `MainContentView`'s `NavigationStack`, or (c) each navigation destination (`HomeView` and `DocumentReaderView`) individually.
+
+### Decision
+
+Attach `.dropDestination` to the `NavigationStack` (or its first child) inside `MainContentView.body`, under `#if os(macOS)`.
+
+### Rationale
+
+A single drop zone covering the entire window content works regardless of which destination is currently visible. Per-destination modifiers would duplicate the logic in two places and risk drift. The `WindowGroup` root sits above the scene-level `@SceneStorage` and other modifiers, and there is no need for the drop target to sit higher than the navigation stack.
+
+### Alternatives Considered
+
+- **WindowGroup root**: No functional benefit; sits above the navigation stack but does not need to.
+- **Per-destination modifiers (`HomeView` + `DocumentReaderView`)**: Duplicates logic, increases drift risk. Rejected.
+
+### Consequences
+
+**Positive:**
+- One drop modifier, one routing path.
+- Works in both Home and Document states.
+
+**Negative:**
+- The drop zone covers the whole window including the toolbar area. Acceptable — no in-document drop semantics exist for image/text insertion (Prism is read-only).
+
+---
+
+## Decision 3: Extend `handleOpenURL` to recognise `http`/`https`
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+`DocumentFlowCoordinator.handleOpenURL(_ url: URL)` currently handles `prism://bundled/…`, `prism://open?url=…`, and `file://`. Any other scheme (including `http`/`https`) is silently dropped. The drop handler can either (a) call `handleOpenURL` and rely on routing happening there, or (b) duplicate the file/remote dispatch logic in the drop handler.
+
+### Decision
+
+Extend `handleOpenURL` so that `url.scheme == "http"` or `"https"` routes to `requestOpenRemoteURL(_:)`. The drop handler then simply calls `flowCoordinator.handleOpenURL(url)` for each candidate URL.
+
+### Rationale
+
+`handleOpenURL` is the central URL routing entry point. Centralising `http`/`https` handling there means any future caller (drop, NSWorkspace open via Info.plist additions, AppleScript) benefits with no extra code. The change is additive and cannot regress existing behaviour: the only current caller is `.onOpenURL`, which today receives only `prism://` and `file://` URLs because no other schemes are registered in `CFBundleURLTypes`.
+
+### Alternatives Considered
+
+- **Duplicate dispatch in drop handler**: Rejected — creates two URL routers that can drift.
+- **New `handleDroppedURL` method on the coordinator**: Adds an unnecessary public method when the existing one is one line away from being correct.
+
+### Consequences
+
+**Positive:**
+- Single canonical URL router.
+- Drop handler is trivial.
+
+**Negative:**
+- `handleOpenURL` now accepts a wider class of URLs. The widening is intentional and documented; the routing destinations (`requestOpenFile`, `requestOpenRemoteURL`) already handle their respective inputs safely.
+
+---
+
+## Decision 4: Defer Dock-icon support for web URLs
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+T-432's description mentions "dropped onto the app icon or window". File URLs dropped onto the Dock icon already work via the existing `CFBundleDocumentTypes` entry for `net.daringfireball.markdown`. Web URLs dropped onto the Dock icon do *not* currently route to Prism; enabling them would require adding `public.url` to `LSItemContentTypes` in `prism/Info.plist`.
+
+### Decision
+
+Defer Dock-icon support for web URLs to a follow-up ticket. The initial release covers window drops for both schemes, plus Dock-icon drops for file URLs (which already work).
+
+### Rationale
+
+Registering `public.url` as a document type has system-wide implications — Prism would appear as a viewer candidate for arbitrary URLs across macOS, and could interact with default-browser logic. That deserves its own scope assessment. Window drops cover the primary user expectation (drag from Safari onto an open Prism window), and Dock-icon file drops cover the secondary file workflow.
+
+### Alternatives Considered
+
+- **Enable `public.url` in this ticket**: Broader scope and more careful testing required than smolspec allows.
+
+### Consequences
+
+**Positive:**
+- Smolspec scope stays small.
+- No risk of unintended default-browser interactions.
+
+**Negative:**
+- Dragging a Safari link to a *closed* Prism Dock icon still does nothing. Acceptable for an initial release; users can drop on an open window or paste into the URL sheet.
+
+---
+
+## Decision 5: Cancel in-flight remote downloads when opening a local file
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+`RemoteContentCoordinator.openRemoteURL(_:fragment:flowCoordinator:)` already cancels a previous remote download before starting a new one. `DocumentFlowCoordinator.openFile(at:)`, however, does not interact with the remote coordinator. Without the drop entry point, this gap is mostly theoretical (the user would have to trigger a file open via the importer or a recent file while a remote download is in flight, which is a multi-step interaction). The drop handler exposes it concretely: a user can drop a local `.md` file while a remote download is running, and the remote response can clobber the just-opened local document.
+
+### Decision
+
+`openFile(at:)` calls `remoteCoordinator?.cancelDownload()` before activating the new session.
+
+### Rationale
+
+Cancellation belongs in the coordinator owning the user-visible navigation transition (the flow coordinator), not in every entry-point caller. Centralising it in `openFile(at:)` covers the drop case and any future caller automatically. Putting it in the drop closure would leave the gap for the file importer and recent-files paths.
+
+### Alternatives Considered
+
+- **Only cancel in the drop closure**: Plugs the immediate hole but leaves the same race for file importer and recent files. Rejected.
+- **Suppress drops while a remote download is in flight**: User-hostile; the dropped file is the user's most recent intent and should win.
+
+### Consequences
+
+**Positive:**
+- Closes a pre-existing race for all "open file" entry points, not just drops.
+- The change is one line plus an early return inside the existing function.
+
+**Negative:**
+- Couples `openFile(at:)` to the remote coordinator. The coordinator is already injected via `configure(...)` (used elsewhere in this file), so the coupling is purely additive.
+
+---
+
+## Decision 6: Latest action wins during the unsaved-content dialog
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+`requestOpenFile` / `requestOpenRemoteURL` overwrite `pendingAction` whenever they are called, even if `showUnsavedConfirmation` is already true. A user who drops two URLs in rapid succession (or drops while another open request is mid-confirmation) ends up with the latest one as the "Discard" target. This is observable today through paste, the URL input sheet, and the Open menu; the drop entry point adds another vector but does not change the semantics.
+
+### Decision
+
+Preserve the existing "latest action wins" behaviour. The drop handler calls into `requestOpenFile` / `requestOpenRemoteURL` exactly like every other entry point, and the unsaved-content dialog continues to reflect the most recent request.
+
+### Rationale
+
+The current behaviour matches user intent: the most recently expressed action is the one to honour. Adding drop-specific suppression would create an inconsistency between entry points without a corresponding user benefit. If the multi-source race ever needs revisiting, it should be addressed across all entry points in a separate spec.
+
+### Alternatives Considered
+
+- **Suppress drops while the dialog is open**: Inconsistent with other entry points; rejected.
+- **Queue dropped URLs**: Over-engineered for a single-document viewer.
+
+### Consequences
+
+**Positive:**
+- One coherent rule across all entry points.
+- No new state, no new dialog logic.
+
+**Negative:**
+- A user who triple-drops by mistake will only act on the last one. Acceptable — it matches every other action source in the app.
+
+---
diff --git a/specs/macos-url-drop/tasks.md b/specs/macos-url-drop/tasks.md
new file mode 100644
index 0000000..e14e53f
--- /dev/null
+++ b/specs/macos-url-drop/tasks.md
@@ -0,0 +1,33 @@
+---
+references:
+ - specs/macos-url-drop/smolspec.md
+ - specs/macos-url-drop/decision_log.md
+metadata:
+ ticket: T-432
+---
+# T-432: macOS URL Drag & Drop
+
+## Routing
+
+- [x] 1. handleOpenURL recognises http/https URLs <!-- id:45hcxru -->
+ - Extend DocumentFlowCoordinator.handleOpenURL (prism/ViewModels/DocumentFlowCoordinator.swift:97-110) so a URL with scheme http or https routes to requestOpenRemoteURL(_:). Insert the new branch between the prism://open branch and the isFileURL branch.
+ - Verify: unit test asserting that handleOpenURL with an https://example.com/x.md URL drives requestOpenRemoteURL exactly once; prism:// and file:// branches are unchanged.
+
+- [x] 2. openFile(at:) cancels any in-flight remote download <!-- id:45hcxrv -->
+ - In DocumentFlowCoordinator.openFile(at:) (prism/ViewModels/DocumentFlowCoordinator.swift:221) call remoteCoordinator?.cancelDownload() before reading file data. The remote coordinator is already injected via configure(...).
+ - Verify: unit test wiring a real RemoteContentCoordinator (or a fake) with an active downloadTask; call openFile(at:); assert downloadTask is cancelled / cleared. Add a regression test asserting that a freshly opened local file is not replaced by a late-arriving remote response.
+ - Blocked-by: 45hcxru (handleOpenURL recognises http/https URLs)
+
+## Drop Target
+
+- [x] 3. macOS window accepts URL drops <!-- id:45hcxrw -->
+ - Add a classifier (free function or static method on a small type like DroppedURLRouter) that takes [URL] and returns the first URL whose scheme is file with .md/.markdown extension (case-insensitive) or http/https; returns nil otherwise.
+ - Attach .dropDestination(for: URL.self) to the NavigationStack root inside MainContentView.body in prism/prismApp.swift under #if os(macOS). The closure invokes the classifier; on a hit it calls flowCoordinator.handleOpenURL(url) and returns true; on miss it returns false.
+ - Verify: unit tests for the classifier covering .md / .markdown / .MD / .pdf file URLs; http, https, prism://, mailto:; empty input; mixed input (first markdown wins over later http).
+ - Manual QA on macOS: drag a .md file from Finder onto Home view and onto an open document (opens); drag a .pdf (rejected); drag a Safari link (downloads and renders); drag while a download is in progress (new request supersedes).
+ - Blocked-by: 45hcxrv (openFile(at:) cancels any in-flight remote download)
+
+- [x] 4. Document drag-and-drop in agent notes <!-- id:45hcxrx -->
+ - Create or extend a file under docs/agent-notes/ describing the macOS drop entry point and classifier function; link to specs/macos-url-drop/smolspec.md; call out the dock-icon limitation for web URLs (Decision 4) and the openFile(at:) remote-download cancellation behaviour (Decision 5).
+ - Verify: file exists; contains references to specs/macos-url-drop/ and the smolspec.
+ - Blocked-by: 45hcxrw (macOS window accepts URL drops)
diff --git a/specs/macos-url-drop/implementation.md b/specs/macos-url-drop/implementation.md
new file mode 100644
index 0000000..4aaa7ab
--- /dev/null
+++ b/specs/macos-url-drop/implementation.md
@@ -0,0 +1,87 @@
+# Implementation Notes: macOS URL Drag & Drop (T-432)
+
+## Beginner
+
+### What changed
+You can now drag a markdown file from Finder or a link from Safari directly onto the Prism window on macOS, and Prism opens it.
+
+### Why it matters
+Before T-432, opening a file from outside required going through the "Open File" picker, and opening a remote URL required pasting it into the URL input sheet. Drag-and-drop is the conventional Mac shortcut for both, and skipping it made Prism feel slightly non-native on macOS.
+
+### Key concepts
+- **Drop target:** a region of the window that says "I accept these kinds of dragged items". SwiftUI exposes this through `.dropDestination(for:action:)`.
+- **URL routing:** Prism already had one method that decides what to do with an incoming URL (`handleOpenURL`). The drop handler just hands the URL to that method. Everything downstream — download, GitHub URL conversion, unsaved-content confirmation, the progress overlay — already exists.
+- **Classifier:** a small piece of code that filters the dropped URLs to ones Prism actually accepts (`.md`/`.markdown` files, `http`/`https` web URLs). Unsupported drops are refused so macOS shows the familiar "snap back to source" animation.
+
+## Intermediate
+
+### Architecture
+The change splits cleanly into three layers:
+
+1. **Classifier** — `DroppedURLRouter` in `prism/Services/` is a small `enum`-namespace with two static functions: `isSupported(_ url:)` and `firstSupported(in urls:)`. It's a pure function of `URL` → `Bool`; no I/O, no side effects, fully unit-testable.
+2. **Drop modifier** — `.dropDestination(for: URL.self)` is attached to `MainContentView.body` inside an `#if os(macOS)` block, alongside the existing `.onOpenURL` modifier. The closure runs the classifier; on a hit it calls `flowCoordinator.handleOpenURL(url)` and returns `true`; on miss it returns `false` (system rejection animation).
+3. **Router extension** — `DocumentFlowCoordinator.handleOpenURL(_ url: URL)` gains one new branch: `http`/`https` schemes route to `requestOpenRemoteURL(_:)`. Comparison is case-insensitive (RFC 3986 compliance via `url.scheme?.lowercased()`).
+
+### Patterns
+- **Reuse over re-implementation.** The smolspec deliberately avoids adding a new download pipeline, a new file-loading path, a new unsaved-content dialog, or a new progress overlay. The drop handler is a thin wrapper that funnels everything into existing infrastructure.
+- **Pure classifier, side-effecting wrapper.** Splitting the classification rule (`DroppedURLRouter`) from the side-effecting call (`flowCoordinator.handleOpenURL`) keeps the rule testable without spinning up SwiftUI.
+- **Shared narrow extension set.** `MarkdownFileExtensions.accepted` on `prism/Models/UTType+Markdown.swift` is the single source of truth for what file extensions count as markdown at app entry points. Distinct from `LinkPathResolver.markdownExtensions`, which is intentionally broader for in-document link resolution.
+
+### Trade-offs
+- **`.dropDestination(for: URL.self)` over `.onDrop(of:[.url,.fileURL],…)`:** chose the modern Transferable-based API for type safety, at the cost of slightly less control over rejection visuals (the system shows hover highlight for any URL payload, not only supported ones — the rejection animation kicks in when the closure returns `false`). Acceptable for a smolspec.
+- **Centralising at `handleOpenURL` vs. handling http(s) inside the drop closure:** centralising means any future entry point (drop, NSWorkspace open, AppleScript) gets the same routing for free. Cost is one new branch in the router. Worth it.
+
+### Side fix
+`openFile(at:)` now calls `remoteCoordinator?.cancelDownload()` before reading file data. Without this, a slow remote download in flight could land *after* a freshly opened local file and clobber the navigation. The race was pre-existing but only reliably reachable through the new drop entry point (Decision 5).
+
+## Expert
+
+### Deep dive
+
+**SwiftUI body re-evaluation.** `.dropDestination(for: URL.self)` is attached inside `MainContentView.body`. The closure captures `flowCoordinator` (an `@Observable @MainActor` reference owned by a parent `@State`). SwiftUI rebuilds the closure on every `body` evaluation, but the modifier itself does not depend on any observable property — only the closure execution path does. There is no retain cycle: the closure lives in the view tree, the view tree is owned by SwiftUI, `@State` is owned by `MainContentView`, and the closure does not transitively own the view's `@State`.
+
+**Concurrency.** `DocumentFlowCoordinator` is `@MainActor`-isolated. SwiftUI evaluates view bodies on the main actor, so the drop closure runs on the same actor as `handleOpenURL` without an actor hop. `URL` is `Sendable`. No Swift 6 strict-concurrency warnings.
+
+**Race window in `openFile`.** `cancelDownload()` runs synchronously on the main actor, then `Data(contentsOf:)` runs synchronously on the main actor. A drop arriving during the synchronous read cannot interleave — the main actor is busy. The hung-loader regression test in `DocumentFlowCoordinatorURLTests` proves that `downloadTask?.isCancelled == true` immediately after `openFile(at:)` returns, even when the loader is mid-flight. Note: `Data(contentsOf:)` on the main actor stalls the UI for large files; this is pre-existing technical debt, not introduced by T-432, and worth a follow-up ticket.
+
+**Edge cases handled:**
+- Case-insensitive schemes (`HTTPS://`, `HTTP://`) — covered by `handleOpenURL` lowercasing and an explicit unit test.
+- Case-insensitive extensions (`.MD`, `.Markdown`) — `MarkdownFileExtensions.isAccepted` lowercases.
+- Multi-URL drops — first supported URL wins, rest ignored (matches file importer).
+- Unsupported schemes (`mailto:`, `ftp:`, `prism://bundled` from a drop) — classifier returns `nil`, drop closure returns `false`, system animates rejection.
+- Drop during unsaved-content dialog — routed through existing `requestOpenFile` / `requestOpenRemoteURL`, which overwrite `pendingAction` (Decision 6: latest action wins, consistent with all other entry points).
+
+**Edge cases deliberately not handled** (per smolspec Out of Scope):
+- Dock-icon drop for web URLs — requires adding `public.url` to `LSItemContentTypes` in Info.plist (system-wide implications). Deferred (Decision 4).
+- iOS drag & drop — `#if os(macOS)` guards the modifier.
+- Safari `.webloc` files — if observed in practice, file a follow-up; the initial implementation does not paper over the distinction.
+
+### Architecture impact
+
+| Surface | Change |
+|---------|--------|
+| `prism/Services/DroppedURLRouter.swift` | New file — 38 lines, pure functions. |
+| `prism/Models/UTType+Markdown.swift` | Added `MarkdownFileExtensions` enum-namespace (shared across drop + URLDocumentLoader). |
+| `prism/Services/URLDocumentLoader.swift` | Replaced inline `pathExtension == "md" \|\| "markdown"` with `MarkdownFileExtensions.isAccepted(_:)`. |
+| `prism/ViewModels/DocumentFlowCoordinator.swift` | Two changes: new http(s) branch in `handleOpenURL` (case-insensitive); `openFile(at:)` cancels remote download. Added `setRemoteCoordinatorForTests(_:)` for unit tests. |
+| `prism/prismApp.swift` | `#if os(macOS)` `.dropDestination` modifier on the NavigationStack root. |
+| `prismTests/DroppedURLRouterTests.swift` | New — 8 test cases covering the classifier rules. |
+| `prismTests/DocumentFlowCoordinatorURLTests.swift` | New — 5 test cases covering http/https routing (incl. uppercase), bundled-URL routing unchanged, and the cancel-on-open-file regression. |
+| `docs/agent-notes/open-from-url.md` | Added "macOS drag & drop (T-432)" section; removed the "deferred features" line that no longer applies. |
+| `specs/OVERVIEW.md` | New row + detail section. |
+
+## Completeness Assessment
+
+**Fully implemented:**
+- All MUST and SHOULD requirements from the smolspec.
+- All four rune tasks marked `[x]`.
+- All six decisions in `decision_log.md`.
+
+**Partially implemented:**
+- None.
+
+**Missing / deferred (by design):**
+- Dock-icon drop for web URLs (Decision 4).
+- iOS drag & drop (Out of scope per ticket framing).
+- Visual error toast on rejected drops (system animation is sufficient feedback).
+- Safari `.webloc` handling (defer to follow-up only if observed).
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.md
index 3459054..b358228 100644
--- a/specs/OVERVIEW.md
+++ b/specs/OVERVIEW.md
@@ -68,6 +68,7 @@
| [Imported Notes Processor](#imported-notes-processor) | 2026-05-16 | Done | Extract the imported-notes load path from `NotesManager` into a dedicated `ImportedNotesProcessor` (T-1217) |
| [prism-notes.js](#prism-notes-js) | 2026-05-16 | Done | Self-contained single-file JavaScript library for adding basic note-taking and copy-as-markdown to any HTML page; personal use, no sync/share/import |
| [Tables in List Items](#tables-in-list-items) | 2026-05-17 | Done | Render markdown tables that appear inside list items (currently shown as raw markdown); per-row notes deferred (T-1034) |
+| [macOS URL Drop](#macos-url-drop) | 2026-05-18 | Planned | Drag-and-drop entry point for opening markdown files and `http(s)` URLs onto the Prism window on macOS (T-432) |
---
@@ -691,3 +692,11 @@ Markdown tables that appear inside a list item currently render as raw markdown
- [smolspec.md](tables-in-list-items/smolspec.md)
- [tasks.md](tables-in-list-items/tasks.md)
- [decision_log.md](tables-in-list-items/decision_log.md)
+
+## macOS URL Drop
+
+Wire a SwiftUI `dropDestination(for: URL.self)` modifier on the macOS window so users can drag `.md`/`.markdown` files from Finder and `http(s)` links from Safari directly onto Prism. The download/validation pipeline, GitHub URL transformation, unsaved-content confirmation, and progress overlay all already exist; this change wires a single drop zone on the `NavigationStack` root in `MainContentView` and extends `DocumentFlowCoordinator.handleOpenURL` to route `http`/`https` schemes. Also closes a pre-existing race: `openFile(at:)` now cancels any in-flight remote download before activating the new local session. Deferred from `open-from-url` per Decision 5; covers Transit ticket T-432.
+
+- [smolspec.md](macos-url-drop/smolspec.md)
+- [tasks.md](macos-url-drop/tasks.md)
+- [decision_log.md](macos-url-drop/decision_log.md)
Drag a .md file from Finder onto Home view and onto an open document — opens. Drag a .pdf — rejected. Drag a Safari link — downloads and renders. Drag while a download is in progress — new request supersedes. Drag a .webloc from Safari — behaviour depends on what the system delivers (file URL vs. URL payload); document in a follow-up if it doesn't open as expected.
Known Prism flakiness in the macOS test runner's teardown phase. Targeted suites ran cleanly (96 passes, 0 fails). If the full make test hangs in CI, kill the lingering xcodebuild and inspect the per-suite log rather than treating the hang as a failure.
No changes to Info.plist (LSItemContentTypes / public.url), no iOS-specific drop handling, no error toast on rejection.