Design a macOS client app

This commit is contained in:
Ian Gulliver
2026-10-16 14:55:17 -07:00
parent 9d87139b72
commit 19336a501a
3 changed files with 257 additions and 0 deletions
+245
View File
@@ -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 `<title>` (`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:<port>` 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/<id>[/<tab>]`, `#/project/<id>` hash routing | `app.ts` `route`, `state.ts` | Works | Nothing |
| `<dialog>.showModal()` (merge, discard, alerts, help) | `dom.ts` | Works | Nothing. The client uses no `alert`, `confirm` or `prompt`, so no JS panel delegate methods are needed |
| `navigator.clipboard.writeText` (code-block Copy) | `dom.ts` | Works for user-initiated calls on a secure origin | Verify in the feasibility build |
| Image paste and drop into composers | `task.ts` paste and drop handlers | Works | Verify `clipboardData.files` for screenshots from the macOS clipboard and for Finder drops. The client has no file picker, so no open-panel delegate is needed |
| `EventSource` (global and lead streams) | `api.ts` `subscribe` | Works | Nothing; see Lifecycle for throttling |
| Terminal `WebSocket` with Origin check | `terminal.ts` | Works; Origin is the server's own origin | Nothing |
| Downloads | None: the client has no `download` links; attachments open in `_blank` | — | Nothing. Attachments open in the default browser, which handles any download |
| `Notification` constructor, `Notification.permission`, `requestPermission()`, `onclick` | `notifications.ts`, `settings.ts` | Not available to a plain `WKWebView` app | Polyfill below |
| `window.focus()` in a notification click | `notifications.ts` `show` | No-op | The polyfill brings the window forward before calling `onclick` |
| `navigator.serviceWorker` unregister loop | `app.ts` `start` | Works or absent; either path is harmless | Nothing |
| Keyboard shortcuts on ⌘ or Ctrl | `switcher.ts` `bindShortcuts` | Delivered to the page, unless a menu key equivalent swallows them | Menu design below |
| Text selection across transcript messages, IME composition | `task.ts`, `inbox.ts` | Works | Verify |
### Notification polyfill
A `WKUserScript` injected at document start, main frame only, into the configured origin defines `window.Notification` with the surface `web/` uses and nothing more. `web/src/notifications.ts` keeps deciding which events notify, using the user's preferences, and formats title and body exactly as it does in a browser.
- `Notification.permission`: read synchronously from a value the shell injects in the same user script, from `UNUserNotificationCenter.getNotificationSettings`, mapped `authorized → "granted"`, `denied → "denied"`, `notDetermined → "default"`. The shell updates the value with `evaluateJavaScript` when the app becomes active, so a change made in System Settings shows the next time Settings renders.
- `Notification.requestPermission()`: posts to a `WKScriptMessageHandlerWithReply` named `byreNotify`, which calls `requestAuthorization(options: [.alert, .sound])` and replies with the mapped string. Permission is only ever requested from the Settings action, as in the browser.
- `new Notification(title, { body, tag })`: posts `{title, body, tag}` to the handler, which delivers a `UNNotificationRequest` with `identifier = tag`. Posting the same tag again replaces the banner, matching the browser's `tag` behaviour.
- `onclick` and `close()`: the polyfill keeps a map from `tag` to instance. `userNotificationCenter(_:didReceive:)` shows and focuses the window, then calls `evaluateJavaScript` to invoke that instance's `onclick`. The client's handler runs `focusTask(taskId)` itself, so the click lands exactly where it does in a browser. `close()` removes the delivered notification.
The handler accepts messages only when `message.frameInfo.isMainFrame` is true and `message.frameInfo.securityOrigin` matches the configured origin, and only those operations, with string fields bounded in length (the client already truncates bodies; see `notifications.ts`). It exposes nothing else of the Mac: no filesystem, process or task-mutation access beyond the page's existing API. An isolated `WKContentWorld` may hold the host plumbing, but isolation alone doesn't authenticate a message; the frame and origin checks do.
Worked example, from `notifyInbox` for a question on a task titled "Fix login redirect":
```text
web/ calls: new Notification("byre · Fix login redirect", { body: "Which branch should the fix target?", tag: "<inbox item id>" })
polyfill: postMessage byreNotify {op: "show", title: "byre · Fix login redirect", body: "Which branch should the fix target?", tag: "<inbox item id>"}
shell: UNNotificationRequest(identifier: "<inbox item id>", content.title/body as given)
user clicks: window shown → Notification instance for "<inbox item id>" .onclick() → focusTask(task id)
```
The browser fires notifications only for events on a live stream: the global stream starts at the bootstrap `last_event_seq` (`app.ts` `main`) and resumes from the last seen seq after a reconnect, so history never floods. The app inherits this unchanged and needs no dedupe store of its own.
A banner still in Notification Center after the app quits and relaunches has no live instance to call. Clicking it shows the window and does nothing else. It never navigates a different server using a task id from another origin. A browser and the app open on the same server both notify, as two browsers would. Nothing is delivered while the app isn't running.
If a future WebKit exposes its own `Notification` to `WKWebView`, the shell prefers it only after checking that its permission and click behaviour match the above.
## Menus and keyboard
The web client binds ⌘K, ⌘P, ⌘L, ⌘S and ⌘/ (each also with Ctrl), ignored while a `<dialog>` is open (`switcher.ts` `bindShortcuts`); Escape closes the open dialog (`app.ts`); Enter and Shift-Enter with IME protection submit in composers (`task.ts`, `inbox.ts`). The terminal stops propagation of Ctrl combinations so Ctrl-K, Ctrl-L and Ctrl-P reach the shell (`terminal.ts`).
Native menu key equivalents are matched before the web view sees a keystroke. So the menu must **not** claim those keys, or it would bypass the page's dialog guard and the terminal's Ctrl passthrough. Each byre command is listed in a **Go** menu with its shortcut shown, implemented by dispatching the same `keydown` the page already handles:
```js
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }))
```
The menu item's shortcut is display-only (an attributed title, no `keyEquivalent`). Typed shortcuts go straight to the page and behave exactly as in Safari; clicked menu items run the identical handler with its identical guards.
Worked example, the user is in the merge dialog and presses ⌘K:
| | Safari | App |
| --- | --- | --- |
| Typed ⌘K | `bindShortcuts` sees `dialog[open]`, does nothing | Same code, same result (no menu key equivalent intercepts it) |
| Go ▸ Switch Task clicked | — | Synthetic ⌘K keydown → same guard → nothing. The item is not disabled; matching the page's own no-op is the copy |
Menus:
- **byre**: About, Change Server…, Hide, Hide Others, Quit. No Settings… item and no ⌘,: the web client has no Settings shortcut, and its Settings tab has no route to reach from outside (`panes.ts` holds the left tab in memory).
- **Edit**: Undo, Redo, Cut, Copy, Paste, Select All, with standard key equivalents. They reach the focused text field or the terminal's xterm helper textarea as in Safari. No Find: Safari's find-in-page isn't part of the client.
- **Go**: Switch Task (⌘K), Project (⌘P), Lead (⌘L), Merge (⌘S), Keyboard Help (⌘/), Back and Forward through the hash-route history (⌘[ and ⌘] as real key equivalents, since the page binds neither).
- **View**: Reload (⌘R, a real equivalent; the page doesn't bind it), Actual Size, Zoom In and Zoom Out via `pageZoom`, Enter Full Screen.
- **Window**: Minimize, Zoom, Bring All to Front.
- **Help**: Keyboard Help (same as Go ▸ Keyboard Help).
No File menu: there's nothing to open, save or print, and the standard template's Save (⌘S) and Print (⌘P) would steal Merge and Project.
## Lifecycle
| Event | Page sees | Existing web behaviour that handles it | Shell adds |
| --- | --- | --- | --- |
| App deactivates or window loses key | `blur` | `panes.ts` reports pane focus false; drafts flush on textarea blur | Nothing |
| App reactivates | `focus` | `panes.ts` re-reports from the real `document.activeElement` | Refreshes the injected notification permission |
| Window closed (hidden) | `blur`, `visibilitychange` → hidden | Same as above | Keeps the web view in the hidden window so it isn't torn down |
| Window reopened | `visibilitychange` → visible | `api.ts` `onVisible` runs the stale check and reconnects if the stream is quiet | Nothing |
| Sleep and wake | Stream drops; `online` and `visibilitychange` on wake | `api.ts` reconnects from the last seq | Nothing |
| Quit | `pagehide` | Drafts flush; pane focus false with `keepalive` | Tears the page down before exiting (below), because closing the window only hides it and never fires `pagehide` |
| Web content process crash | — | — | `webViewWebContentProcessDidTerminate` reloads the same URL once. A second crash shows an overlay with Reload and Open in Browser instead of reloading in a loop. Drafts survive up to their last flush, as with a crashed tab; pasted image bytes and the last unflushed keystrokes don't |
| Server restart | Stream error | `api.ts` retries | Nothing |
**Quit sequence.** `applicationShouldTerminate` returns `.terminateLater`, the shell enters its teardown state, and it navigates the web view to `about:blank`. The navigation policy allows that one load only in that state, and only when the shell started it; a page-initiated `about:blank` is still cancelled. Leaving the page runs the existing `pagehide` handlers in `web/src/drafts.ts` and `web/src/panes.ts`: drafts flush to `localStorage` and the pane-focus request goes out with `keepalive`. When `webView(_:didFinish:)` reports `about:blank` loaded, those handlers have run, and the shell calls `reply(toApplicationShouldTerminate: true)`. If the navigation hasn't finished within a short bound, the shell terminates anyway, so a hung page never blocks Quit. Change Server… uses the same teardown: once `about:blank` has loaded, the shell saves the new origin as the configured one, leaves the teardown state and loads the new origin, which the policy then allows as same-origin.
Risks the browser doesn't share, measured in the feasibility build rather than assumed:
- **Timer throttling while hidden.** WebKit throttles timers in hidden pages, and App Nap throttles the process. The stale check in `api.ts` (`check`, threshold `staleAfterMs`) can then run late, so a silently dead stream may go unnoticed longer while the window is closed. Events on a live `EventSource` still arrive, so notifications keep working. Safari behaves the same way for a background tab. If the app proves worse than Safari, `ProcessInfo.beginActivity(options: .userInitiatedAllowingIdleSystemSleep)` while connected is the candidate fix.
- **Pane-focus reporting while hidden.** It matches the browser only if `blur` fires when the app deactivates and when the window is occluded. The right pane must never be reported focused while another app is active.
- **Shutdown reporting is best effort.** Even with the quit sequence, the keepalive pane-focus request may not reach the server before the process exits, and nothing is sent on a crash or sleep. The server's existing stale-focus handling covers this, as it does for a browser.
A browser and the app used side by side share focus and read markers through the server, exactly like two browser tabs, including the existing race where one user has a single focus pair across clients. Fixing that race is a server design of its own.
## Local state
| State | Lives in |
| --- | --- |
| Tasks, sessions, inbox, reviews, snapshots, workflows, focus (task, tab, tab order), read markers, notification categories | Server, as for the browser |
| Drafts, visit order | `localStorage` of the configured origin in the app's default persistent `WKWebsiteDataStore`, with the web client's keys and formats unchanged. Separate from Safari's, and per origin, so a different hostname or port is a different draft namespace |
| Left tab, file position, disclosure and scroll state | Page memory, lost on reload, as in the browser |
| Server URL, window frame | `UserDefaults` |
| Notification authorization | macOS (System Settings ▸ Notifications ▸ byre) |
The app never clears website data and never clears unsent text because a request failed.
## Repository layout
- `mac/Byre.xcodeproj` and `mac/Byre/`: the AppKit target, with the window, connection sheet, navigation and UI delegates, menus, lifecycle handling, the notification handler and the polyfill script as a resource.
- The app icon comes from the favicon artwork (`web/favicon-tile.svg`), with the conversion reproducible.
- Swift and the system frameworks only; no third-party dependencies, no Swift API generator, no copied fonts or palette, no Markdown port, no SwiftTerm.
All byre feature logic stays in `web/`. A change to `web/` reaches the app on its next page load, with the same update lifecycle as an open browser tab. AGENTS.md's checks (`go vet ./...`, `go generate ./web`) stay as they are, with an Xcode build added for changes under `mac/`. No tests are added. Installing the app and deploying the byre service are separate actions.
Packaging, signing, notarization, the minimum macOS version and update delivery are decided at implementation. Notification authorization needs a stable bundle identifier and signing, and whether development signing is enough is a feasibility-build result. Nothing depends on the App Store, APNs or a background helper.
## Feasibility build
Before anything else, a throwaway shell with just the web view, the UI and navigation delegates and the notification polyfill, pointed at a development byre (never the production service), checked against Safari on the same Mac and server:
1. Load, Tailscale identity, both SSE streams, terminal attach and typing, including Ctrl-C and Ctrl-K into the shell.
2. Typed ⌘K, ⌘P, ⌘L, ⌘S and ⌘/ with and without a dialog open, and the matching Go menu items.
3. Screenshot paste (⌘⇧⌃4 then ⌘V) and Finder drag into a composer; IME composition and Enter.
4. Code-block Copy; text selection across transcript messages.
5. Every `_blank` link type: preview, Markdown link, transcript image, review screenshot, inbox URL, exposed-port link.
6. Notification permission flow, a question notification, a turn summary notification, and clicking each.
7. `blur` on app switch and on window close (watch `PUT /api/me/pane-focus` in the server log).
8. The window closed for an hour with the Mac awake, then a question arrives: does the banner appear, and how late?
9. Sleep and wake. Quit with a nonblank draft and a focused right pane, with the window open and again with it closed: check that the `about:blank` teardown runs, the pane-focus request reaches the server log, and the draft is back after relaunch.
10. An `http://` link in a transcript opens in the default browser.
11. Kill the web content process; check the single reload and the overlay on a second kill.
Record the macOS and SDK versions and the signing used. Any failure is fixed in the shell. A failure that can only be fixed in `web/` is reported to the owner as a decision.
## Acceptance
Manual, side by side with Safari at the same window size and appearance, same server, using disposable tasks for anything that mutates (never merging or discarding real work). Pane focus is shared through the server, so reset it between comparisons so one client doesn't change the other's result.
The comparison follows the web client's own surfaces:
- Lead and Status, Settings (notification preferences, lead notes, deletion confirmation).
- The switcher: filtering, `project-prefix: title` create, workflow second step.
- Project: nested tasks, TODO Start and Discuss.
- Transcript: grouped tools, thinking, subagents, held turns, pending sends, streaming, compaction, large histories and sticky scrolling.
- Composer: model rules, attachment limits and upload failure, interrupt, drafts across Quit and relaunch.
- Changes: Since main, This turn, pinned snapshots.
- Files, Terminal (Ctrl keys, shell exit, New shell), Reviews (Fix, Ignore), Workflow.
- Inbox: every choice type, recommendation markers, image grids, Other, 409 on a concurrent answer.
- Merge and discard dialogs, forks, the Git identity banner.
- Light and dark, narrow and large windows, Unicode.
- Invalid origin, 401 and 403, server restart, content-process recovery, two-client focus.
Because the code is identical, each check is "looks and behaves the same", and any difference is a shell bug. Keep a short pass/fail record, with screenshots for visible differences.
## Not in version one
- A Settings… menu item and ⌘, (needs a Settings shortcut or route in `web/`).
- Device-neutral notification wording in Settings (a `web/` change).
- A `byre:` URL scheme or universal links.
- Notifications after Quit (needs a server push path, which byre doesn't have).
- Several servers or several windows.
- Automatic updates and offline mode.
- Any native re-rendering of the workspace, which would take the Acceptance list as its contract.
+1
View File
@@ -13,6 +13,7 @@
- `internal/tailscale`: `tailscale serve` for task previews (see "Previews").
- `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows").
- `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `favicon.svg` (a lead dot in the accent colour with spokes to five task-coloured dots; its colours are saturated versions of the `web/style.css` accent and task hues, not copies of the palette, since the palette's pastels blur together at 16px; they switch with the light/dark scheme), `favicon.png` (the same icon in its dark colours on an opaque dark `--surface` square at 180×180, rendered from `web/favicon-tile.svg`, which is not embedded; opaque because Orion puts a transparent icon on a white tile; linked as a second icon and as the `apple-touch-icon`, which Orion prefers over the SVG for its tab icon; Orion's icon store (`~/Library/Application Support/Orion/Defaults/website_icons`, kept apart from its cache) keys icons by hostname without the port, so another server on the same hostname can supply byre's tab icon; `favicon-tile.svg` repeats the shapes and dark colours of `favicon.svg`, so change both together, then regenerate the PNG with `rsvg-convert --width 180 --height 180 --output web/favicon.png web/favicon-tile.svg`), `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid, see "Inbox"; xterm.js, see "Terminals"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`.
- There is no macOS client yet; `docs/macos.md` is its design.
## Data