diff --git a/TODO.md b/TODO.md
index 8b3790f..d7afec3 100644
--- a/TODO.md
+++ b/TODO.md
@@ -13,3 +13,14 @@
# M6 — Fan-out
# M7 — Inbox triage
+
+# M8 — macOS client
+
+- macOS client feasibility build
+ See `docs/macos.md`. Throwaway AppKit + WKWebView shell with a UI/navigation delegate and the Notification polyfill, pointed at a development byre. Compare with Safari: identity and both SSE streams, terminal Ctrl keys, typed vs Go-menu ⌘K/P/L/S/ with a dialog open, screenshot paste and Finder drop, code Copy, every `_blank` link type, notification permission and click, blur on app switch and window close, an hour hidden then a question, sleep/wake, quit with a draft. Record OS/SDK and signing results.
+- macOS client shell
+ `mac/` Xcode project: one window hosting a persistent WKWebView on the configured origin, connection sheet with 401/403/TLS/unreachable errors and Change Server…, `_blank` and off-origin links to NSWorkspace, menus with no File menu and no key equivalents for page shortcuts, hide-on-close, quit via pagehide, reload-once crash recovery.
+- macOS client notifications
+ Document-start `Notification` polyfill backed by UNUserNotificationCenter via a main-frame, origin-checked message handler; `tag` as request id, click shows the window then calls the page's onclick; permission re-read on activation.
+- macOS client parity review
+ Side by side with Safari on the same server, size and appearance, using disposable tasks: every tab, switcher, Inbox choice types, merge/discard dialogs, light/dark, narrow windows, IME. Document build, setup, storage and limits in `docs/operations.md`.
diff --git a/docs/macos.md b/docs/macos.md
new file mode 100644
index 0000000..66578ff
--- /dev/null
+++ b/docs/macos.md
@@ -0,0 +1,245 @@
+# byre for macOS
+
+This is a design. Nothing in it is implemented: there is no macOS client, no native bridge and no new API. The work to build it is under "M8 — macOS client" in `TODO.md`.
+
+## Decision
+
+The app is a small AppKit shell around one `WKWebView` that loads the configured byre server's own origin, the same Tailscale Serve HTTPS URL a browser uses. The workspace is the unmodified `web/` client the server already serves: the same `index.html`, `style.css`, `dist/` modules, fonts, and vendored Mermaid and xterm, lazy-loaded exactly as in a browser. The shell adds only what a browser tab gets from the browser and a `WKWebView` does not: a window, menus, a server address, link routing, and a delivery path for the Web Notifications API.
+
+**Version one changes nothing under `web/` and nothing on the server.** Every gap between Safari and `WKWebView` is closed on the native side, through a `WKUIDelegate` or `WKNavigationDelegate` method or a document-start `WKUserScript`. That is what makes "strictly copies the web client" checkable: the app and Safari run byte-identical client code against the same server, so any visible difference is a shell bug. A gap that can only be closed in `web/` is a decision for the owner, because it ends that property.
+
+The page is loaded over HTTPS from the server. It is not bundled under `file://`, not copied into the app, not proxied through a local server and not given a custom origin. Same-origin REST, SSE, images and the terminal WebSocket therefore work unchanged, with no CORS, no second copy of the API schema, no second Markdown parser and no skew between app and server assets.
+
+Swift owns only the configured origin, the window frame, notification authorization and the short-lived calls the polyfill makes. It never subscribes to SSE, renders a transcript, reads model lists or sends task mutations. The web store in `web/src/state.ts` is the only application state.
+
+### Rejected: a native re-rendering
+
+A SwiftUI or AppKit rewrite would reimplement the event reducer in `web/src/state.ts`, the transcript renderer and its tool grouping, the custom Markdown in `markdown.ts`, the inbox, task colours, the terminal protocol and the pane focus rules, and would drift from the web client on every change. A file-by-file parity table doesn't prevent that drift. It would also force differences the brief doesn't ask for: SwiftUI limits text selection to one view, SwiftTerm's defaults are not xterm's, and font metrics and equal-width layout would need proving rather than coming free. A native re-rendering stays possible later, with this design's acceptance list as its contract.
+
+## What "strictly copies" means
+
+Inside the window, the app is the web client: two equal panes, the same tabs, labels, palette, font, dialogs, keyboard handling and empty states, because it is the same code. The identity banner is the only thing above the panes. Nothing is replaced with native controls, and no minimum width beyond what the web layout itself needs is imposed. Restrictions are copied along with capabilities: read-only Files, no file-mention picker, no closed-task browser, previews open outside.
+
+"Same" means the same behaviour under the same inputs and viewport as the web client in Safari on the same Mac. It does not promise identical window chrome or identical rendering across browser engines.
+
+That includes the web client's browser-flavoured wording. Settings says "This browser shows notifications while a byre page is open." (`web/src/settings.ts`, `renderDevice`). Version one keeps it, because changing it means changing `web/`. The two choices, for a user who has granted notifications:
+
+| Choice | Settings → device line in the app | Cost |
+| --- | --- | --- |
+| Keep (version one) | `This browser shows notifications while a byre page is open.` | None; slightly odd wording in an app. |
+| Reword | `This device shows notifications while byre is open.` | A `web/` change that alters the browser too. |
+
+The only things outside the web content are the platform frame, listed in full:
+
+| Difference from the browser | Boundary |
+| --- | --- |
+| macOS window frame and title bar | The web viewport below it is unchanged; no byre controls in the title bar. |
+| Application menu bar | System menu fonts stay system fonts. |
+| Connection sheet (first launch, Change Server…) | An app has no address bar. No new tab or left-pane section. |
+| Native panels the content asks for | The macOS notification permission prompt. |
+| Notification Center banners | Same events, preferences and text as the browser. |
+| External links open in the default browser | The app stays on the configured origin; hash routes stay in the page. |
+| App-private website storage | Drafts and visit order from a browser are not imported; the storage format is unchanged. |
+| Load-error and crash overlays | Shown instead of the page, never as a third pane. |
+
+## Window
+
+One window, one server. No native window tabs (`NSWindow.allowsAutomaticWindowTabbing = false`), no second window, no toolbar, no sidebar, no inspector.
+
+A standard titled window with the web view below the title bar, so the traffic lights never overlap the Lead tab or change its padding. Resizing changes the web viewport directly; the page is never scaled to a fixed canvas. The initial size fills most of the screen and the frame is saved with `NSWindow.setFrameAutosaveName`. A minimum width is set only after checking at what width the web client's own layout stops working in Safari; the shell never changes the 50/50 split, because it doesn't render it.
+
+The window title is the page's `
` (`web/index.html`), bound through `WKWebView.title`. Appearance follows the system; the `prefers-color-scheme` palettes in `web/style.css` do the rest.
+
+Closing the window hides it. The `WKWebView` and its connections stay alive until Quit, so notifications and drafts behave as they would in a browser tab left open in the background. Clicking the Dock icon shows the same window again; there is no New Window command. Closing or quitting never stops server agents or terminal shells. There is no launch-at-login, background helper or Dock badge.
+
+## Connection
+
+**First launch** shows a sheet with one field, Server URL, and Connect. Accepted input is an `https` origin with an optional port and no path, query, fragment or credentials, because the client's routes are origin-root (`/api/...`, `#/task/...`). Invalid input is explained inline. Debug builds alone also accept `http://127.0.0.1:` for a development byre. The origin is always entered, never guessed from the local machine.
+
+**Check.** Connect fetches `GET /api/admin/status` through the web view's own networking, so the request carries exactly what a Safari request from this Mac would. Tailscale Serve adds the identity headers on the server side, and `internal/server/server.go` rejects requests without them. The app never adds identity headers, keys or passwords, and never shows a login screen. Each outcome maps to sheet text and actions:
+
+| Response | Sheet text | Actions |
+| --- | --- | --- |
+| 200 | (sheet closes, workspace loads) | — |
+| TLS failure | The server's certificate isn't trusted. | Edit, Retry |
+| DNS or connect failure | Can't reach the server. Is Tailscale connected? | Edit, Retry |
+| 401 (no Tailscale identity) | The server didn't receive a Tailscale identity. Use the server's `ts.net` HTTPS address. | Edit |
+| 403 (not via Tailscale Serve) | The server refused the request because it didn't arrive through Tailscale Serve. | Edit |
+
+Certificate validation is never relaxed.
+
+**Change Server…** (app menu) reopens the sheet. Navigating away from the origin fires `pagehide`, which already flushes drafts (`web/src/drafts.ts`) and reports pane focus false (`web/src/panes.ts`). The shell additionally drops the old page's pending notification calls and stops delivering for it before the new origin loads. It never erases the old origin's saved data.
+
+**Startup** with a saved server loads it directly. If the load fails, the same table drives an in-window error with Retry and Change Server…. The web client's own fatal page ("byre failed to start: …") covers failures after the page loads.
+
+Initial state, feed history, request generations, event replay and reconnect timing stay in `app.ts`, `state.ts` and `api.ts`, including the WebKit load-event workaround in `api.ts`. There are still two SSE streams, global and lead, and only the global one drives the connection dot. There is no offline editing queue and no automatic replay of a request whose outcome is unknown.
+
+## Gaps between Safari and `WKWebView`
+
+This table is the core of the design. Each row is a web-platform feature the client uses, where it is used, what a bare `WKWebView` does, and the shell's fix.
+
+| Feature used by `web/` | Where | Bare `WKWebView` | Shell |
+| --- | --- | --- | --- |
+| `target="_blank"` links (previews, Markdown links, images, review screenshots, exposed URLs, inbox URLs) | `preview.ts`, `markdown.ts`, `task.ts`, `reviews.ts`, `inbox.ts` | Nothing happens: without a `WKUIDelegate` there is no new window | `webView(_:createWebViewWith:for:windowFeatures:)` returns nil and routes the URL: same-origin attachment and image URLs and all other `http` and `https` URLs go to `NSWorkspace.shared.open` (`markdown.ts` opens both schemes in `_blank`); other schemes are dropped. The `https`-only rule applies to the configured server, not to external links |
+| Top-level navigation off-origin (a plain link that isn't `_blank`) | any | Navigates the workspace away | `decidePolicyFor navigationAction`: same origin allowed; while the shell is in its teardown state (Quit or Change Server…), the `about:blank` load the shell itself started is also allowed; anything else cancelled and opened with `NSWorkspace` |
+| `#/task/[/]`, `#/project/` hash routing | `app.ts` `route`, `state.ts` | Works | Nothing |
+| `