# Native macOS backend Zig owns the core, the ptys, every effect and the worker threads. Swift owns `NSApplication`, the window, input translation, and drawing. They meet at a hand-written C ABI, `src/macos/pardes.h`, built as a static library that the app links; the Swift half is ordinary AppKit that pumps events in and reads one packed cell buffer out. There is exactly one core per process, so the ABI has no handles — the state is a file-scoped singleton and every function names it implicitly, exactly like the browser backend. The research this design came out of is written down separately in `docs/ghostty-macos-notes.md` — how ghostty wires Zig to Swift, what its build plumbing actually requires, and which parts of it are distribution machinery rather than integration. Read that for the alternatives; this file is what was built and why. Future layout/compositing ownership and the regression baseline are discussed in [Shared rendering contract](rendering-parity-design.md). That proposal is design groundwork; the native drawing implementation remains in place. ## Why not ghostty's split Ghostty was read carefully before this was written, and this backend deliberately inverts its division of labour. Ghostty hands Zig a bare `NSView*` through a tagged platform union (`ghostty_platform_macos_s.nsview`), and Zig then creates a layer, assigns it as the view's layer, sets `wantsLayer`, and installs a display callback — `src/renderer/Metal.zig` in that tree. Swift never renders a glyph; it supplies a rectangle and gets out of the way. Pardes goes the other way because its frame is *already* a cell grid. `pardes.Surface` is `cols × rows` of `Cell`, and CoreText is the native way to draw one: attributed runs, the system font stack, and Apple's own subpixel and color-emoji handling, for free. The alternative is a hand-rolled glyph atlas, and the SDL backend is the measurement of what that costs — a large part of `src/gui/gui.zig`'s 6,348 lines is a 2048² FreeType-hinted R8 atlas (`atlas_w` and `atlas_h`, `src/gui/gui.zig:140`), GPU pipelines, transfer buffers and shaders. Writing a second one, blind, buys nothing the grid needs. Blind is the operative word: this backend was scaffolded on a Linux machine. A Metal renderer written there would have been untestable code — compiled at best, never once run — whereas CoreText drawing is a small amount of Swift that only exists on the machine that can run it. The ceiling is accepted and named. Per-cell CoreText drawing is slower than an atlas, and if it fails to hold a full-screen redraw at the target rate the escalation is ghostty's model verbatim: Zig owns a `CAMetalLayer` installed into the view and drives its own frame clock, and the ABI grows a `platform` pointer field carrying the `NSView*` — one field, because the rest of the seam does not change. Nothing here is designed to make that harder. That does not rule out a *postprocess*. CoreText is still the renderer and the cell ABI is unchanged, but shader effects draw that same frame into a retained bitmap and feed it through Core Image kernels on one Metal context. There is no second glyph atlas, pane renderer, or view pointer in the ABI. ## Why the ABI mirrors src/web.zig The browser and a Cocoa app are the same host, and the browser proved the shape first. In both, someone else owns the clock and the event loop; events arrive through flat functions that take scalars and borrowed byte ranges; one call renders, and the result is one contiguous array of packed cells the host walks linearly. `pardes_cell_s` is byte-for-byte `WebCell`: seven UTF-8 bytes of grapheme plus a guaranteed zero, tagged `fg`/`bg` words (`0x01000000` default, `0x02000000 | index` for the palette, otherwise `0x00RRGGBB`), an attribute bitfield with the underline style in the high bits, and a `flags` bit meaning "the core never painted this cell" so a host can draw background only and skip the glyph. Two hosts spelling the same encoding is not duplication worth removing; it is the encoding being right. The one real difference is IO. The browser has no ptys, so `src/web.zig` forwards every effect out to JavaScript as a numbered code plus a byte payload, and JavaScript performs it. macOS has `forkpty`, `read` and `write` right there, so almost no effect crosses the boundary at all: `pardes_tick` runs `while (core.nextEffect()) |e| core.perform(e)` (`src/macos.zig:1154`) and each effect lands on this host's own `Host.VTable`, of whose twenty-one methods fourteen are filled in (`src/macos.zig:1761`). The machine-local half of those methods is no longer this file's. `forkShell`, `writeFileBytes` and `writeFd` live in `src/host_io.zig` and are shared with the tty shell, the SDL shell and the detached daemon; `src/macos.zig` calls them (`:1807`, `:1823`, `:1848`, `:1863`) and its own copies, together with the four `extern "c"` declarations they needed (`forkpty`, `execv`, `chdir`, `_exit`), are gone. Two `extern "c"` declarations remain here: `setenv`, and `pardes_host_watch_file`, which FileWatcher.swift satisfies (`src/macos.zig:40`, `:45`). The bug all four hosts' private `forkShell` copies had in common is the argument for the shared file existing: none set `FD_CLOEXEC` on the pty master, so a program in one pane could read and write another pane's terminal and closing a master did not reliably hang its shell up. That is why the runtime struct is three callbacks rather than a second vtable — and why two of the three are the clipboard: the pasteboard is AppKit's, in both directions, and is the one piece of IO the Zig side cannot reach for itself. ## The seam **Frame.** `pardes_frame` renders and returns the cell count; `pardes_frame_cells` hands back a pointer valid until the next `pardes_frame`, with `pardes_frame_cols`/`_rows` giving its shape. The cursor rides alongside as `pardes_cursor_x`/`_y`, each `-1` when it is hidden, plus `pardes_cursor_bar` asking for a thin insert caret instead of a block. `Surface.images` crosses separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel attachments" below. `pardes_scene` is the matching full-window snapshot: a bit for CRT, plus the 60 Hz time/frame that animates it. It is returned by value, so the renderer never holds a pointer into live core state. A zero flags word is also the fast-path contract: draw the CoreText frame directly. **Input.** `pardes_key` takes a codepoint and the host's already-composed text, because composition is AppKit's job and the core only ever wants finished characters. Keys that carry no text are the four ASCII controls (enter, escape, tab, backspace) and a private-use block starting at `0xF0001` for the arrows and navigation keys — private-use precisely so a functional key can never be confused with a real codepoint arriving as text. `pardes_mouse` speaks acme's vocabulary: three buttons, where 1 selects, 2 executes and 3 looks, with wheel directions as ordinary buttons rather than a separate axis. Ctrl is the only modifier the core consults (a left press with ctrl is goto-definition), but the full mask is passed anyway to keep the signature identical to the web one. `pardes_scroll` carries fractional row and column deltas from a trackpad; the Zig side accumulates each axis separately and synthesizes whole `wheel_up`/`wheel_down`/`wheel_left`/`wheel_right` presses, because the core scrolls on button events and its `touch_scroll` event only records the residual for the debug overlay. `src/gui/gui.zig` (`takeScrollTicks`) and `src/web/app.mjs` both do exactly this already. A real mouse notch skips the smoothing and goes through `pardes_mouse`. `pardes_rotate` is the same shape for a two-finger twist, spent as `n`/`N` — see the trackpad section. `pardes_command` runs one builtin command line through the core's own `command` event, which is the channel a nested pardes speaks; here it is what a menu item is made of and what opens a path from argv or the Dock (`Look `). `pardes_take_haptic` reports the Look or Exec the core just performed and clears it. `pardes_resize` also carries one cell in *physical* pixels, which only the native PDF placement path reads — pass the backing-store size, not points. **Runtime callbacks.** `pardes_runtime_s` is a `userdata` and three functions, copied by value during init so the struct need not outlive the call. `wakeup` means "your state moved, please pump me". `set_clipboard` hands over text to put on the pasteboard, borrowed for the duration of the call; it fires inside a tick for `SPC y` / `SPC Y` and for the tag's own `y` chord, and for nothing else — an ordinary `y` or `d` writes the core's register and never reaches here, which is what stopped deleting a character from clobbering the desktop's clipboard. The other three clipboard words — `SPC p`, `SPC P`, `SPC R` — go the OTHER way and raise `read_clipboard` instead. `read_clipboard` is that direction reversed: "give me the pasteboard", with no payload either way, because the host answers by calling `pardes_paste` — and NSPasteboard being synchronous, that usually happens inside the callback itself, before it returns. Both are main thread, inside `pardes_tick`. There is no `open_url` callback because the core already opens links itself through `/usr/bin/open` (`src/look.zig`), and no file callbacks because it owns the filesystem side too. **Lifecycle.** `pardes_init` returns 0 or an opaque nonzero code, `pardes_deinit` tears down, `pardes_tick` drains pty output plus the effect queue and returns whether anything changed, `pardes_should_quit` reports the Exit builtin or the last pane closing, and `pardes_animating` says `pardes_animation_tick` wants a ~60 Hz call until it settles — the one thing in an otherwise event-driven frontend that redraws on a clock. A persistent scene effect deliberately keeps that clock armed until its builtin turns it off. Ordinary input and pty pumps never count as elapsed animation frames. The ordering contract is the part a header cannot enforce. **Init with the real grid size.** The core defers each integrated shell's greeting until it has seen a resize: `sync()` in `src/pardes.zig` only emits the opening `ls` once `resize_count > 0` and parsed OSC 133 B says the prompt has handed the cursor to input. An unintegrated shell has no reliable readiness signal, so it skips the cosmetic greeting; an explicit command still releases after its successful fork. Setting `Options.cols`/`rows` alone never bumps that counter, so init must turn its arguments into an actual resize event the way `src/web.zig` does immediately after construction — and the size must be true, because the first `forkpty` takes its winsize from the core's current grid and a shell booted at a placeholder draws its first prompt at the wrong width. **Call `pardes_tick` after every input function.** The input calls only advance the state machine; the writes to the pty, the spawns, the saves all happen in the drain, so an input without a following tick is an input that visibly did nothing. **`wakeup` is the only any-thread entry point in the whole ABI.** Everything else is main-thread only, and the host's `wakeup` must do nothing but hop — one `DispatchQueue.main.async` that calls `pardes_tick` and marks the view dirty. ## The trackpad is the third button acme wants three mouse buttons — 1 selects, 2 executes, 3 looks — and the machine this runs on has a glass rectangle. So the rectangle is taught to speak the vocabulary, cheapest gesture to commonest verb: | gesture | button | verb | | --- | --- | --- | | one finger | 1 | select | | two fingers | 2 | Exec | | three fingers | 2 | Exec | | a deep press | 3 | Look | | two fingers twisted | — | `n` / `N` | **The finger count decides, not the button stream.** This is the part that only real hardware could teach, and it is worth spelling out because the obvious implementation is wrong. macOS's secondary click is "click or tap with **two or more** fingers", so with that setting on — the default — a *three*-finger click is delivered as `rightMouseDown` exactly like a two-finger one. A view that trusts the stream cannot tell them apart and quietly does Look for both, which is the one thing a multi-finger click here must NOT do. The trace that caught it, from a real trackpad: ``` pardes: rightMouseDown: resting=2 pardes: rightMouseDown: resting=3 ``` So all three button streams funnel into one `beginClick`, which resolves the button from the fingers first and falls back to the stream only when there are no fingers to count — which is exactly the real-mouse case, where right is Look and the middle button is Exec. Two and three fingers landing on the same verb is therefore not a wasted gesture, it is the hardware refusing to distinguish them under the default setting. Look is the deep press instead: on a trackpad it is the one gesture the system does not overload, and it wants "Force Click and haptic feedback" on in System Settings — which nothing in this process can read, so a Mac with that off (or a trackpad with no force sensor) reaches Look through a real mouse's right button and the `Look` builtin, keyboard Enter included. The count comes from `event.touches(matching: .touching, in: nil)`, with `nil` rather than the view because that argument filters on touch/view association and an association that fails does not raise, it returns zero fingers — a two-finger Exec silently degrading into a select. Belt and braces: the view also keeps a running `restingFingers` from the four `touchesXxx` callbacks, because the touch set hanging off a *mouse* event is an accident of how the click was produced and can come back empty. The mouse event's own set wins when it has anything in it. Whatever it resolves to is then **latched** for the drag and the release: the core tracks a drag keyed by button, and answering a press of 3 with a release of 1 strands it holding a sweep nothing will ever end. A deep press arrives as `pressureChange` reaching stage 2, and only the transition counts — AppKit repeats stage 2 for as long as the finger stays down. By then a press has already gone out, so it is *released* before the right one is sent. That ordering is not tidiness: a right press arriving while the core holds a left select-drag is acme's 1-3 chord, which is **Paste**. The release costs a cursor move at the click point, which is what clicking there would have done anyway; a multi-finger press released this way fires the Exec its fingers already asked for. Which press gets upgraded is deliberately not restricted to the left one, and that too came from the trace: on a Force Touch trackpad the deep press usually rides a click that already went out on the *right* stream, so gating on a latched left button meant the conversion never fired at all — the log showed `pressure: stage=2 latched=nil` and nothing else. Any in-flight click upgrades; already-Look is the only case with nothing to do. The view also needs `NSPressureConfiguration(pressureBehavior: .primaryDeepClick)` or stage 2 is the system's business and never arrives. Twisting two fingers is a dial, and a dial over a list of look-able places is `n`. A notch moves the SELECTION one place along and opens nothing; Enter opens the one the dial stopped at, which is what makes the dial worth spinning at all — a stepper that opened every place it passed could not be. `pardes_rotate` takes raw degrees and libpardes quantizes them, one step per 10°, keeping the remainder — the same accumulate-and-spend shape as `pardes_scroll`, in Zig for the same reason: it is then unit-tested on a machine with no trackpad. AppKit reports counterclockwise as positive and the forward step is clockwise, so the sign inverts here and nowhere else. The banked remainder is deliberate hysteresis; a gesture beginning passes 0 to clear it, so the first degree of a new twist cannot inherit a nearly-complete notch from the last one. Measured against a real twist: 95 events, mean 3.3° each — 20° was more than a wrist gives without thinking about it and made the dial feel stuck, where 10° is still a deliberate turn and 36 steps to a revolution. ### Momentum `pardes_rotate_end` says the fingers came off, and how fast they were moving when they did decides everything. AppKit gives rotation no momentum phase of its own — `momentumPhase` belongs to scroll — so the release speed is measured here, off the monotonic clock, as a smoothed degrees-per-second over the event stream. The curve is the point. Momentum is scaled off the EXCESS over a 70°/s floor rather than being a flat amount the moment the floor is crossed: ```zig excess = min(|speed| - rotation_fling_floor, rotation_fling_max) ``` A plain threshold would hand out two free notches the instant it was crossed, and the same gesture a hair quicker jumping twice as far is how a control stops feeling like a control. There is a second floor under that one: an excess below `rotation_fling_stop` (18) coasts at zero, so 70–88°/s is a dead band and the smallest coast that happens at all is 18°/s — under a fifth of one notch, which is why a slow deliberate turn still spends no notches at all. The harder it is thrown the further it goes — bounded, by the cap, at about eleven matches for the hardest flick a trackpad can report. Two things stop a fling that was never thrown. A release more than 90 ms after the last motion event is a hand that *stopped* and then lifted, which is the most deliberate twist there is and the one a stale velocity sample would fling hardest. And a finger back down (`pardes_rotate(0)`) catches a coast in progress, the way a hand catches a dial. The coast itself is spent by `pardes_animation_tick`, one fixed 1/60 step per scheduled frame with a 0.94 decay, and it makes `pardes_animating` true for as long as it lasts — so it rides the same 16 ms re-pump a theme transition does and needs no clock of its own. Fixed rather than measured on purpose: one fling then spends the same travel every time, which is what lets `rotate.snap` assert it instead of asserting the machine's timer jitter. Both halves are goldens. `rotate.snap` turns the dial at 4° per 100 ms (40°/s, under the floor) and asserts the screen is byte-identical across the release, then at 12° per 5 ms and asserts it is not. The list it walks is twenty-four places rather than three for a reason worth keeping, and `n`/`N` becoming a RING only sharpened it: a short ring comes round, so a hard flick can stop exactly where a single notch would have and the two screens agree — a golden that passes before momentum exists. Twenty-four is longer than the cap can travel (about eleven), so the fling has nowhere to hide. ## A drop is a click plus Look Files dragged onto the grid open beside the pane they were dropped on. Finder and the Dock already reached the app through `application(_:open:)`, but that path cannot say *where* — it opens next to whichever pane happened to have focus. A drop knows where the hand was, and in acme that is the whole difference, because `Look` places the document relative to the pane it runs in. So the definition is exactly two things the hand could have done itself: the pointer's cell gets a left press and release, focusing that pane the way a click there would, and then the ordinary `Look` builtin runs in it. Drop on a tag and you clicked a tag. **No drop concept was added to the core**, and there is no case here to special-case. `performDragOperation` decodes the pasteboard and the location and then calls `drop(_:at:)`, one call below the event, for the reason every gesture in this file is split that way: `NSDraggingInfo` is a protocol with a dozen members and no public conformer, so a test that had to build one would be testing its own stub. `test/macos-snapshots/drop.snap` drives that entry point and asserts the placement rather than the opening — the second file is dropped *inside the pane the first one opened* and has to land beside it, which is only true if the click went where the pointer was. ## The titlebar follows the focused pane `pardes_active_path` and `pardes_active_dirty` are read once per pump into `representedURL`, `title`, and `isDocumentEdited` — the proxy icon you can drag and Cmd-click for the path, the filename, and the dot in the close button. There is no document architecture behind this and deliberately so: no `NSDocument`, no save panel, no "do you want to save" on close. (One piece of document chrome does exist, and it is the harmless one: File ▸ Open… runs a real `NSOpenPanel` and hands what you pick to Look.) Save is a builtin, the pane's tag already says so, and this is the same two facts spelled where a Mac user looks for them. A terminal or an output buffer is not a document, so focus landing on one clears the icon and puts the title back to `pardes` rather than leaving a stale file there. PDFs and images do get an icon: they are real paths, and a proxy icon is about the file, not about who may edit it. The dirty half needed one core watermark. `File` counted `revision` but never recorded which edit was last *written*, so no shell could derive "unsaved" — `File.saved_revision` is that watermark, advanced by `Save` at the moment the write is asked for and by a successful external-file reload whose bytes already came from disk. The core shows the same answer on each pane's grip button, while this host also puts it in the native close button. Save is marked at ask-time rather than on completion because `save_file` carries none back, which makes both indicators exactly as honest as the Save request. ## The menu bar, and the chords the core never sees A Mac app without a menu bar is a Mac app that is wrong, and most of what a menu bar wants to say pardes already has a word for. So the menu is mostly a second spelling of builtins — `AppDelegate` calls `run("New")`, `run("Save")`, `run("Help")`, `run("Tutor")` — and the items with no builtin behind them are left out rather than stubbed. The workspace tag row — the topmost tagline, the one carrying `Newcol Joincol Find Grep …` — is not drawn on this shell. `-Dworkspace-tag` (default off for `-Dplatform=macos`, on everywhere else) hands the row to the menu bar: the core stops reserving the row (`Pardes.topBarHeight`), the grid starts at the column tags, and the row's commands live in a **Builtins** menu instead. The items are the tag's own words, spelled exactly as a tag Exec would type them, and nothing in that menu is editable — it is the tag's buttons, not the tag: | menu | item | chord | what it does | |---|---|---|---| | pardes | About pardes | — | AppKit's panel | | | Hide pardes / Hide Others / Show All | ⌘H / ⌥⌘H / — | AppKit | | | Quit pardes | ⌘Q | AppKit | | File | Open… | ⌘O | a real `NSOpenPanel`, then Look on what you picked | | | New | ⌘N | the `New` builtin | | | Save | ⌘S | the `Save` builtin | | | Close Window | ⌘W | AppKit; the app terminates after the last one | | Builtins | Newcol / Joincol / Find / Grep / Changelog / Dump / NextColor / Debug / Kill | — | the workspace tag's own builtins, `run(word)` | | Edit | Paste | ⌘V | the view's own paste path, not a second one | | View | Zoom In / Zoom Out / Actual Size | ⌘= / ⌘- / ⌘0 | point size, host-side | | Window | Minimize / Zoom | ⌘M / — | AppKit | | Help | pardes Help | ⌘? | the `Help` builtin | | | Tutorial | — | the `Tutor` builtin | Edit holds Paste and nothing else. Copy, Cut, Undo and Select All are deliberately absent: pardes's own words for those are `SPC y`, the 1-2 chord, `u` and `%`, and a menu item that ran a different thing under the same name would be worse than no item. The consequence is worth stating plainly, because it is invisible from the core's side: those chords are now **the menu's**, and the core can never see them. Nor can it see any other Command chord — the ABI's modifier mask carries ctrl, alt and shift and has no super bit, so `PardesView` swallows Cmd rather than sending a key the core would misread as unmodified. Cmd is the host's layer here; everything pardes binds lives on the other four. ## When a gesture looks like it did nothing All three verbs are cheap to mistake for broken, because acme's verbs are about the *word under the pointer* and most words resolve to nothing: - **Look** (a deep press) on a filename opens it; on a word that names no file and matches nothing else on screen, it searches, finds where it already is, and the screen does not move. The pulse still fires — the gesture worked. - **Exec** (two or three fingers) on a builtin name runs it. On ordinary prose it types that word at a shell, which needs a terminal pane to type into. - **`n`/`N`** (twist) moves the SELECTION to the next look-able place and opens nothing; Enter opens what it landed on. It walks a ring across panes — the ones a Look came from first, then the output buffers none has — so a twist with no results buffer anywhere still steps the pane in front of you. A screen with no filename on it is what has nothing to step. Setting `PARDES_LOG` at all — the gate tests presence, not value, so even `PARDES_LOG=0` counts — prints every decoded gesture to stderr: the fingers counted, the stream it came in on, the button it resolved to, the pressure stage, the rotation degrees. It exists because which events a trackpad produces is decided by hardware plus four System Settings switches this process cannot read, and because guessing at that from a screenshot cost an afternoon. ## Haptics Every Exec and every Look taps the trackpad. The core arms a one-slot pulse in the ONE dispatcher — `lookAt` and `execute`, the two functions a middle click, a right click, Enter, Tab, a tag chord and the `Look`/`Exec` builtins all funnel into — and the host takes it once per pump with `pardes_take_haptic`. Exec gets `.generic`, the definite tap of something done; Look gets `.alignment`, the lighter detent AppKit uses when a dragged guide snaps. A pulse, not a queue: five Execs inside one keystroke are one thing the hand did. A twist taps nothing now that `n`/`N` only move the selection, and that is the honest report: the detent belongs to the Enter that opens, and that is where it fires. Three details are load-bearing. `execute` arms only at `exec_depth == 0`, because `Exec ls` re-enters as `ls` and one Tab is one gesture however many words it unwraps to. `init` and `initFromDump` take the pulse and drop it, so a config file that opens a file with `Look` does not buzz at boot. And the field is `HapticSlot`, `void` on every platform but this one, the way `PdfSlot` is `void` without MuPDF — no other shell reads it, so no other shell carries it. There is no capability check. `NSHapticFeedbackManager` is a silent no-op without a Force Touch trackpad and when the user has feedback switched off, so a check here would only be a second place to be wrong — and it would be wrong the moment an external trackpad is plugged in mid-session. ## Shell directories and nested Look `host_io.shellCwd` reads a shell's working directory using `proc_pidinfo(PROC_PIDVNODEPATHINFO)`; Linux uses `/proc//cwd`. The macOS host refreshes directories for panes that produced output, so idle frames do not poll every shell. `test/macos-snapshots/cwd.snap` covers the tag after `cd` and after an idle tick. Nested launches use the same inherited 9P address and pane serial as other native hosts. They do not inspect ancestor processes or executable names. See [the control filesystem](fs.md) for paths and transports. Two more things this host cannot inherit from its launcher, because a `.app` has none: - **The terminal's identity.** `host_io.ChildEnv` writes `TERM`, `COLORTERM` and `TERM_PROGRAM` over whatever the environment carried and the child is `execve`'d with that array — built before the fork, because a `setenv` between fork and exec can deadlock on the heap a pty reader thread was holding. Every pane is emulated by the bundled VT, so the value describes pardes and never the terminal pardes was started from; `-Dplatform=gui` and the tty shell take the same path, where the difference is only that their launcher usually happened to set something. The names come from `config.child_term` / `child_colorterm` / `child_term_program`, and `TERM` is deliberately `xterm-256color` rather than a name with no installed terminfo entry — `clear`, colour and cursor addressing all resolve through that lookup. - **Whether a program has the terminal.** `host_io.ttyTaken` is what `Pardes.takesCommandLine` asks before deciding that Escape is the `Last` builtin rather than a keystroke for the child, and what `Exec` asks before believing a pane is at its prompt. Linux descends `/proc//task// children`; darwin has neither that nor a children list, so it enumerates the tty's foreground process group with `proc_listpids(PROC_PGRP_ONLY)` and compares each member's `proc_pidpath` against the shell's own. A nested interactive shell is therefore still a prompt, and `bash -c 'sleep 30'` is still taken — the wrapper wears the shell's binary, but `sleep` shares its group. The occupancy suite in `src/host_io.zig` runs on both platforms. ## Fonts and zoom The face is the shell's business and the size is the window's, so the two are reached differently on purpose. `Font ` and the `FontSel` picker are ordinary core builtins, enabled by `pardes.font_picker` — the frontends that draw their own text, which is now the SDL shell and this one. `src/fonts.zig` moved out of `gui/` for that reason. It walks the platform's font directories and reads four small sfnt tables per file to decide whether every glyph has the same advance; no fontconfig and no CoreText, so both shells agree about which faces exist and disagree only about how to rasterize one. macOS needed two things from that walk. Its directories are `/System/Library/Fonts`, that plus `Supplemental`, `/Library/Fonts` and `~/Library/Fonts`; and a third of what is in them — Menlo and Courier included — is a `.ttc` collection rather than a plain face. A collection is a `ttcf` header in front of several sfnt directories, and the table offsets inside one are absolute from the start of the file, so reading face 0 is a matter of finding where its directory begins and changing nothing else. The answer crosses the ABI as a PATH, not a family name: the core already found the file, and asking CoreText to resolve a name would be a second lookup that can disagree. `pardes_font_take` hands it over once, the same take-and-clear shape as the haptic, and the view loads it with `CTFontManagerCreateFontDescriptorsFromURL`, picks the untraited cut out of a collection, derives bold and italic from it, and re-measures. A file it cannot wear crosses back through `pardes_font_reject` and leaves the old metrics exactly as they were. Success crosses through `pardes_font_ack` with the effective PostScript name and point size. Requested and effective values are therefore distinct, queryable facts rather than a request being mistaken for what is on screen. Cmd+=, Cmd- and Cmd+0 change the point size, rebuild `Metrics`, and report the new cell through the same resize path a window drag uses. They also observe the effective face/point tuple through `pardes_font_observe`, so the single `Config` report follows a host-owned zoom without resolving a pending face request. The initial system face is observed the same way. Cmd+= rather than Cmd++ because AppKit matches the character and `=` is what is under the finger. Both are machine-checked in `test/macos-snapshots/font.snap`, which needs two different kinds of assertion because a snapshot is the core's cell buffer and the core has no font: `font Menlo-Regular` asks the view what it is actually wearing, and the snapshots catch the grid moving when the cell changes size. ### Compact tags and context rows `TaglineSize ` scales the tag face, pitch and glyph band. The tag's background still fills a whole body-grid row, as in the SDL renderer. Painting only the compact band leaves dark strips between tags. Glyph offsets come from `pardes_tagline_band_offset`; all measurements cross the ABI in physical pixels. The chrome overlay runs after the compact layers. It draws the topbar rule, the column rule and the pane's tag/body boundary using the theme's border colour. The pane rule moves above a bottom-positioned tag. Tag-layer fields 11 and 12 preserve that placement and colour in frozen transition frames. Tree-sitter context rows use the compact height and pitch, with full-width row backgrounds. Their one-physical-pixel separators use the same border colour as SDL, after discontinuous declarations and after the final context row. The remaining body starts immediately after the compact rows. ### The cell is snapped to device pixels, not to points The grid has to land on whole *device* pixels: the background pass runs with antialiasing off (touching fills would otherwise seam at every shared edge), so a fractional column boundary makes the rounding wobble by a pixel from column to column, and a screen made of tag bars and selections stripes visibly. That used to be spelled as whole *points*, which on a Retina display asks for twice what it needs — half a point already *is* a whole pixel at 2x. The difference is not academic. Monaco advances 8.4014pt at 14, so ceiling to 9 spaced every column **7.1% wider than the face was drawn for**: loose, washed-out text that reads as bad rendering rather than as bad spacing. `Metrics` now rounds onto `backingScaleFactor`, giving 8.5 — +1.2%. Width rounds to nearest (a monospace glyph is drawn to fit its own advance, so the half-pixel either way is slack); height rounds up, because losing a pixel off a descender is clipping. The ascent is snapped too, so the rules hung off the baseline are whole-pixel fills rather than one-pixel bars smeared across two. The snap is display-dependent, so `viewDidChangeBackingProperties` re-measures: a scale change moves no bounds and therefore fires no resize. What is *not* done, because macOS does not do it: hinting. Apple renders outlines faithfully and lets stems fall where they fall, which is why Mac text is softer than a hinted Linux or Windows grid, and why `setShouldSmoothFonts` is pinned off — smoothing dilates glyphs (measured: +25% lit pixels, +31% ink mass) and needs to know the colour behind the glyph, which over a transparent theme it cannot. ## Pixel attachments: PDFs and images `Surface.images` used to be dropped on the floor here, which is why a PDF pane showed *nothing at all*: with `native_images` false the core assumes a terminal that cannot draw pixels and degrades a document to counted page turns, and this host never set it. It does now — this shell draws pixels, which is a fact rather than a question (the tty backend has to ask the terminal about kitty-graphics support; the SDL one just says yes, as we do). The transport is `pardes_image_s`, walked with `pardes_frame_images` / `pardes_frame_image_list` after each `pardes_frame`, and it is deliberately flat: no callbacks, no handles to register or release. Each entry is a rasterized page or image plus two rectangles — `src` (the crop of the raster) and `dst` (where it lands), both already clipped to the viewport by the core, which is what lets a host draw a continuous-scroll page without inventing an overflow clip. Geometry is in **physical pixels**, the space `pardes_resize`'s `cell_w`/`cell_h` put the core in; only `cell_x`/`cell_y` are in cells. `serial`, `page` and `revision` together are the cache key, and the point of it is what does *not* move them: panning, zooming to fit and scrolling all reuse the same raster, so `PardesView` decodes a page once and scrolling costs nothing but a `CGContext.draw`. The bytes the core lends are only valid until the next `pardes_frame`, so the `CGImage` owns a copy — which is exactly why the key has to be good enough that the copy happens when MuPDF re-rasterizes and never on an ordinary wheel event. Attachments a frame does not place are evicted, or a session that scrolled a long document would hold every page it ever showed. Two details the picture depends on. The pane BODY is still the clip even though the geometry is pre-clipped — a page one pixel too tall would otherwise sit on a tagline. And `isFlipped` gives a y-down CTM while `CGImage` draws +y up, so each attachment is flipped about its own destination rect rather than about the view, which keeps the arithmetic in the grid's coordinates. Turning this on also changes what an IMAGE pane is here: it was the PETSCII glyph-art fallback, the same one a terminal without kitty graphics gets, and it is now the real pixels. ## Live file reload `FileWatcher.swift` gives each watched pane a vnode source on the file and one on its parent directory. The file catches in-place writes; the parent is essential because editors commonly save by renaming a fresh inode over the path. After the 45 ms debounce the file source is rearmed on the current inode, then the event returns to Zig with the pane's watch generation. Replacing or closing a pane advances that generation, so a callback already queued for the old occupant cannot reload the new one. The callback only wakes the ordinary main-thread pump. `pardes_tick` checks the exact owned path, filtering unrelated changes in the same directory, duplicate vnode events, and Pardes's own saves. Text panes compare and adopt a bounded byte snapshot by content hash. PDFs can be much larger than that bound, so they compare inode/size/time metadata and let MuPDF reopen the pathname directly. That identity is committed only when equal stats bracket a successful transactional reopen; a mismatch receives one self-scheduled retry, so it does not depend on a second vnode edge and cannot spin forever on a malformed stable file. Reading settings survive and derived page data is regenerated. A malformed PDF therefore leaves the last good document usable and remains retryable after the next real write. ## Themes, live Two bugs lived here, and they were the same bug. `ChromeTheme` fades between themes over ten 16 ms steps, advanced by a `.tick` event. The tty and SDL loops call `core.update(.tick)` on their own clocks; the native host schedules the same clock explicitly through `pardes_animation_tick`. `pardes_tick` only drains work: if every input pump also advanced the transition, a burst of key or pty events could collapse a ten-frame fade into one display frame. The scheduled callback advances once, then pumps effects and redraws, and re-arms itself only while `pardes_animating` remains true. `pardes_theme_bg` is the other half. The window background behind the titlebar and behind a live resize was a hand-agreed `#121212` in two files; it is now read from the core, and it carries the theme's *own* background rather than the chrome's, because document backgrounds switch the instant the theme does while chrome fades. `window.appearance` follows its luminance, so wearing `acme` no longer leaves a dark titlebar over a cream grid. ## Scene effects `Crt` is one full-window postprocess. Its canonical macOS source is `shaders/crt.ci.metal`, which the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs it through a `CIContext` created from the system Metal device. The source is also archived by the Zig build; `EffectCode` links to it without a checkout. The ordinary CoreText/attachment/cursor pass is one function. With any scene bit or panel track it targets a retained, backing-scale bitmap; kernels then sample the complete frame into the flipped view. PDFs, image panes, taglines, rules, and the caret therefore receive the same effect. With every bit off and no panel track the bitmap and Core Image context are bypassed entirely. CRT works in linear light with restrained bloom, scan/mask, vignette, hum, and noise instead of applying a broad color remap. The scene kernel alone receives a `clampedToExtent` image and the final output is cropped back to the original finite extent. Chroma samples therefore clamp to the edge exactly like SDL's scene sampler instead of acquiring transparent-black seams from Core Image's finite source image. The Zig side owns the clock. `pardes_animation_tick` increments its wrapped 60 Hz frame only while a scene bit is active, and `pardes_scene` derives seconds from that integer. Input bursts cannot accelerate the shader. Pointer input follows the same destination-to-source transform as the last presented scene frame before it is divided by the cell metrics. The view keeps that exact `pardes_scene_s` snapshot and mirrors the Metal barrel arithmetic in `ScenePostprocessor.sourcePoint`; pixels outside the CRT tube have no cell. The resulting displayed-grid cell then reaches the core, whose panel-track mapping resolves it to canonical pane content. ## Panel transitions `pardes_frame_panel_track_list` publishes the core's plain `Track` records without a host-side animation model: pane serial/id, phase, effect, frame, and logical-cell `from`/`to` boxes. The C layout and every field offset are asserted against the Zig extern struct on Linux. The exported list is already stable paint order—moving panes by slot, then opening panes by slot—so Swift only consumes it. CoreText renders the complete frame supplied by the core. Character effects — PanelAscii's byte walks and the glyph motion of PanelEdges, PanelFall, PanelWave, PanelCurtain, PanelScramble and PanelType — are already composed there, identically to the TTY and SDL paths. When tracks exist, the same retained bitmap used by scene effects is fed through two kernels in `shaders/crt.ci.metal`: `pardesPanelClear` removes every final target first, then `pardesPanel` samples each target into its eased presented box. Slide uses ease-out cubic, zoom uses ease-out-back, dissolve uses stable pane/cell noise, while `pardesComposedInCore` effects only clip the already-composed core cells; pixel attachments have no character value and pass through unchanged. Because the input is the finished bitmap rather than a glyph-only batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image panes move and dissolve together. The scene CRT runs once after the panel composition. With no scene bit and no panel track the retained bitmap, Core Image context, and Metal passes are bypassed. `EffectCode Panel*` links to this Metal file and its Swift owner in the virtual filesystem. ### Transparent themes A theme with `bg = null` — the curated `dark`, and every vendored `*_transparent` — declares no background of its own. In a terminal that means "wear whatever the terminal is wearing"; a window has nothing to wear, so `pardes_theme_bg` answers `PARDES_COLOR_DEFAULT` and the host goes see-through: `window.isOpaque = false`, a clear background colour, and an `NSVisualEffectView` (`.underWindowBackground`, `.behindWindow`, `.active`) behind the grid when `WindowBlur` is enabled. `PardesView` stops painting the ground at all — it *clears*, because AppKit does not blank a non-opaque view — and any cell whose background is still the default resolves to `bgClear` and is skipped by the run loop. Reversed cells are not: a reverse puts the text colour in the background, and text is a real colour that paints. The blur is a **sibling** of the grid inside a plain container, never its parent. Hiding a superview hides its subviews, so a nested backdrop drew a blank window for every opaque theme the moment it was hidden. `WindowOpacity <0..100>` sets one background coverage throughout the frame. Background fills replace existing coverage, so overlapping ground, cell, tag and context layers cannot increase the requested opacity. The window itself stays clear below 100%; a second tinted window backdrop would compound it. Text and cursor ink stay opaque. PDFs render onto a transparent MuPDF pixmap, which preserves the coverage of text, paths, photos, and highlights. The host paints paper at `WindowOpacity`, then composites PDF content at its original opacity. Blank paper therefore reveals the blurred backdrop while lettering stays readable even at `WindowOpacity 0`. Paper is white with `PdfTint` disabled and uses the theme background otherwise. Explicit PDF background rectangles and scanned page images remain content: this does not guess paper from pixel brightness or remove white objects from a document. Scrolling and transitions retain both the content raster and its paper color. Ordinary image attachments still follow the SDL image pipeline: remove the destination by source coverage, then add the image at the requested opacity. Theme colours and attachment rasters are interpreted as sRGB. The PDF paper separation is currently macOS-specific; other hosts keep the opaque raster path. `WindowBlur <0..100>` controls the strength of that native backdrop independently of `WindowOpacity`. It is a macOS-only builtin, also accepted in the startup config and reported by `Config`. It defaults to 0 (off); 100 shows the full AppKit material and intermediate values blend the material with the unblurred backdrop using the effect view's alpha. An opaque themed background hides the effect; lowering `WindowOpacity` makes the remembered strength visible again. For example: ```text WindowOpacity 71 WindowBlur 60 ``` This is material strength, not a Gaussian radius in pixels. AppKit's public `NSVisualEffectView` API chooses its own blur and tint for the material. It samples behind the window; text and cursors are drawn in a separate sibling above the effect and remain sharp. macOS accessibility settings such as Reduce Transparency can override the material's appearance. `WindowBlur 0` also turns off the formerly implicit blur for background-less themes; set it to 100 to restore that appearance. The compositor must be checked in a live window: offscreen grid captures cannot verify behind-window blur. `test/macos-snapshots/rendering-parity.snap` checks background colour and alpha, regular and bottom tags, compact context separators, and PDF fit, tint and scrolling. Its native-metrics mode uses backing pixels like the shipping app; the older grid-only tests keep their display-independent point metrics. ## Threading One core, touched only from the main thread, plus one pty reader task per pane and one socket listener for the nested-pardes protocol. A reader blocks in `read(2)` and pushes into ONE process-wide `Inbox` — a bounded ring of tagged messages, not a buffer per pane — then calls `wakeup`; the next tick drains the ring wholesale and feeds the bytes in as `output` events. The ring is lossy under sustained backpressure: an `output` message can be dropped, and `eof` and `command` will evict a queued `output` to get in, because losing a byte of scrollback is survivable and losing the end of a pane is not. Sixteen panes is the ceiling (`MAX_PANES`), and the readers run on a default-sized `std.Io.Threaded` pool, so the thread count is the pool's rather than one per pane. Ghostty again is the contrast: it runs a renderer thread *and* an IO thread per surface, each with its own mailbox and wakeup, because it owns the frame clock and must render independently of input. Pardes does not own the clock here — the host does, through `wakeup` and dirty rects — so there is no third thread to synchronize and no mailbox protocol to get wrong. ## Building One command builds everything this shell has. The two extra steps below exist only for handing a bundle to somebody else. ```sh zig build -Dplatform=macos ``` produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h` beside it — and on a Darwin host a signed `zig-out/pardes.app` as well, which the next section is about. `zig-out` and not `~/.local`, and that now needs saying. A bare `zig build` redirects the install prefix to `$HOME/.local` and prints that it has, but only behind the guard `also_gui and prefixIsUntouched(b)`: `also_gui` is `requested_platform == null`, and `prefixIsUntouched` refuses when a `DESTDIR`, a `--prefix` or a `--prefix-*dir` has already chosen somewhere. `-Dplatform=macos` names a shell, so neither condition holds and every path in this document is relative to `zig-out` unless you pass `--prefix` yourself. The `/dev` directory the benchmark binaries install into likewise never appears in this backend; nothing here is a dev binary. `-Dplatform=macos` is one of the two platforms that override the repo's default target; `esp32p4` is the other, and pins its own riscv32-freestanding query. The tty, SDL and web shells default to the Steam Deck (x86_64 linux-gnu, glibc pinned to 2.38), and that default is not survivable here: swiftc links this archive, so a Steam Deck build hands ld64 ELF objects inside a GNU archive and the app link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac the default becomes the host arch at `macos_min_version`, which is the same triple the app's swiftc link is given, so the two halves of the app cannot disagree about how old a macOS they support — the arch is spelled rather than left null so the CPU model resolves to generic, which is ghostty's `Config.genericMacOSTarget` workaround. On any other host it stays plain native, which is what keeps the Linux dev loop below runnable. That archive is also *fat*. `b.addLibrary` emits only this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and ghostty-vt's simdutf/highway stay in archives of their own that zig would normally hand to a linker it drives itself. swiftc drives this one and is given a single file, so `fatArchive` in `build.zig` walks `getCompileDependencies` and folds every static archive into one with Apple's `libtool`. This is ghostty's `CombineArchivesStep` minus the non-Darwin half, and it inherits ghostty's two hard-won details: each input is copied and run through `ranlib` first, because ld64 otherwise refuses zig's layout outright (`64-bit mach-o member 'compiler_rt.o' not 8-byte aligned`) and libtool silently *drops* members from it — a 15 MB input came back as 13 MB with half the objects missing, which links almost far enough to look like a source problem. ```sh zig build -Dplatform=macos # libpardes.a + pardes.h, and on Darwin a signed pardes.app too zig build macos-app -Dplatform=macos # the signed bundle on its own zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over ``` The first line is not "the library only", and that is the part worth stating: `b.getInstallStep().dependOn(&sign.step)` puts the bundle on the DEFAULT install step, so on a Darwin host an ordinary `zig build -Dplatform=macos` assembles `zig-out/pardes.app` and ad-hoc-signs it. build.zig says why in as many words — the app is "part of an ORDINARY build rather than a verb to remember". Only the dmg is opt-in, because it is for handing over rather than for running. What is gated is that whole branch, and on the TARGET as well as the host: `builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()` is what decides whether `fatArchive` runs, and without a Mach-O archive there is nothing for swiftc to link. Off either, `macos-app` and `macos-dmg` resolve to an explicit `addFail` — *"pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native"* — rather than a bundle that could not have been signed, and the default install stops at the library. The library and the header build anywhere, which is what the Linux dev loop below uses. The bundle is assembled by `build.zig` itself, not by a script it shells out to. An `.app` is a directory with a plist, a binary, a shader and an icon in it, and each of those is one step whose inputs the build graph knows — so the app rebuilds when a Swift source or the archive moves and is left alone when nothing does. There is no Xcode project: a hand-written `pbxproj` would be a second build system to keep in step for what four `addInstallFileWithDir` calls already do (`install_app_bin`, `install_plist`, `install_scene_kernel`, `install_icon`). - **The plist.** `plutil -replace LSMinimumSystemVersion` reads the committed `src/macos/Info.plist` and writes a stamped copy into the cache. The source file is never mutated, which is what the old in-place `PlistBuddy` call did. - **The icon.** The mark is **Glenda**, the Plan 9 rabbit — pardes is an acme, and acme is Plan 9's. `src/macos/icon.swift` is compiled alone (it is top-level code: one file, one module) and run with the bundle's `Resources` as its output directory. She is *drawn*, not traced: four overlapping ellipses filled as one path under nonzero winding for the silhouette, three more punched back out in the ground colour for the eyes and nose. A silhouette rather than an outline because the mark has to survive being twelve pixels across, where an outlined drawing is a grey smudge with a lighter grey inside it — the ears are the whole recognition, and they are the shapes that reach furthest from the mass. Generated rather than committed, so the palette stays in step with the one `PardesView` draws with (ground `defaultBG`, Glenda `defaultFG`, the strip above her the tag bar, the block cursor at the end of it `ansi16[11]`), and there is no binary blob in the tree to disagree with the app it ships in. - **The scene kernel.** `shaders/crt.ci.metal` is installed verbatim as `Contents/Resources/crt.ci.metal` and compiled at runtime with `CIKernel.kernels(withMetalString:)`. The same file is also an anonymous module import named `effect-source-crt.ci.metal`, added by `build.zig`'s per-shell module wiring under `if (shell == .macos)`, which is what lets `EffectCode` link to the exact source the app executes. Its three entry points are `extern "C" [[stitchable]]`: the runtime compiler looks for stitchable functions and rejects the WHOLE source with "cannot find a valid stitchable Metal function in the source" when there are none, which costs the view its postprocessor and turns every effect silently off. The `draw-effect` command in the e2e suite exists to catch exactly that. - **The link.** One swiftc invocation, with the optimize mode following `-Doptimize` — `-Onone` for Debug, `-Osize` for ReleaseSmall, `-O` otherwise — so both halves of the app are built the same way: ```sh swiftc <-O|-Osize|-Onone> -target -apple-macos13.0 \ -import-objc-header src/macos/pardes.h \ -o /pardes \ src/macos/Sources/{main,AppDelegate,PardesView,ScenePostprocessor,FileWatcher}.swift \ /libpardes.a -lc++ \ -framework AppKit -framework CoreText -framework CoreGraphics \ -framework CoreImage -framework Metal ``` The arch is derived from the build target, which defaults to the host — `arm64` on Apple silicon, `x86_64` on an Intel Mac. What is genuinely missing is a UNIVERSAL binary: there is no `lipo` step anywhere, so a bundle built on one arch runs on that arch. The header goes in through `-import-objc-header` rather than a module map, because the header is read straight out of the source tree and there is nothing to stage; a module map is what an *xcframework* needs, and there isn't one. `-lc++` is there because ghostty-vt pulls in simdutf and highway, which are C++ — the Zig side bundles `compiler_rt` and `ubsan_rt` into the archive (`bundle_compiler_rt` in `build.zig`), so the C++ runtime is the only thing left for this link to supply. `-target` is not optional: without it swiftc uses the host triple, `LC_BUILD_VERSION` records whatever macOS built the thing, and dyld refuses to launch it on anything older — the plist's `LSMinimumSystemVersion` is a claim, not the enforcement. The deployment version is spelled once, as `macos_min_version` in `build.zig`, and reaches the `-target`, the plist and the library's own target from there. ## Distribution `codesign` runs last, over the finished directory — a signature taken before the icon lands is a signature the icon then breaks — and it is part of the ordinary build, because an unsigned arm64 bundle does not launch at all. The default identity is ad-hoc (`-`), which needs no keychain and is enough for the machine that built it. For a bundle that leaves this machine: ```sh zig build macos-dmg -Dplatform=macos -Doptimize=ReleaseFast \ -Dmacos-identity="Developer ID Application: Your Name (TEAMID)" ``` A real identity also gets `--options runtime` and `--timestamp`, which are notarization's requirements rather than a signature's (`--timestamp` on an ad-hoc signature is an error, which is why it is conditional). `macos-dmg` wraps the signed bundle in a compressed read-only UDZO image — the format every Mac already knows how to open, and the signature survives the copy out of it. Notarization itself is one command away and deliberately not wired in, because it needs credentials and the network: `xcrun notarytool submit zig-out/pardes.dmg --keychain-profile --wait`, then `xcrun stapler staple`. Still not here: an xcframework and `lipo`. Ghostty has both (`src/build/GhosttyXCFramework.zig`), and they exist for a universal binary and for letting something other than this app link the core. This ships arm64. ## Testing Three layers, and each one exists because the layer above it cannot reach where it goes. **The Linux dev loop.** The Zig half of this backend is plain POSIX, and most of it is no longer even this backend's: `forkShell`, `writeFileBytes` and `writeFd` are `src/host_io.zig`'s, byte-identical on both systems, and what is left that differs is `ioctl(TIOCSWINSZ)` (absent from `std.c.T` on darwin), `/usr/bin/open` against `/usr/bin/xdg-open` (`src/look.zig:33-37`), and libproc against `/proc`. So `-Dplatform=macos` compiles on a Linux host, and its tests run there: ```sh zig build unit-test -Dplatform=macos ``` That covers the ABI's Zig side, the effect drain, and the two quantizers the trackpad depends on — `takeScrollTicks` and `takeRotationNotches` are pure functions precisely so that "how many notches is a 180° twist" is answerable on a machine with no trackpad in it. The header is kept honest with ghostty's trick. `build.zig` runs `translate-C` over `src/macos/pardes.h` into the unit-test build, and `src/macos.zig` asserts every constant and every struct layout against the Zig side — the color tags, the attribute bits, the key codepoints, the mouse, modifier and haptic ordinals, `@sizeOf(pardes_cell_s)` and each field offset. A hand-written header is a second source of truth, and the only defensible way to keep one is a test that fails the moment the two disagree. A second test compares every exported function's arity and scalar widths against the header's declaration. It is not a type equality — `translate-C` spells pointers `[*c]` and mints its own struct types, so nothing would ever match exactly — but arity and width are what actually break. It earned its place immediately: `pardes_scroll` grew a cell coordinate after the Swift view had already been written against the one-argument form, and nothing but a human reading both files would have caught it. It earned it a second time when the same function grew a horizontal axis. **The offscreen AppKit suite.** Everything above stops at the ABI. This one drives the real `PardesView` in a real (borderless, offscreen, activation- prohibited) `NSWindow`, over a real core with real ptys: ```sh zig build macos-e2e -Dplatform=macos # run it zig build macos-e2e -Dplatform=macos -- --update # regenerate the goldens zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap ``` With no paths, the harness runs `test/macos-snapshots`. Paths after `--` select scripts or directories instead. The executable stays in the build cache. `test/macos_e2e.swift` links the same Swift sources the app does, minus `main.swift`, into a second binary — test scaffolding does not ship inside the product. Scripts are `test/macos-snapshots/*.snap` and speak the tty suite's vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`, `command`, `mouse`, `click`, `wheel`, `resize`, `draw`) plus what only exists here: `fingers `, `force `, `rotate [gap_ms]`, `rotate_end`, `drop `, `scroll `, `haptic `, `nsclick` (the AppKit-event path, as opposed to `click`'s direct entry-point call), `font `, `font-size `, `zoom`, `tracks ...`, `draw-effect`, and `clipboard`. The boot script uses `tracks` followed by `draw-effect` to assert the moving/opening ABI order and that the runtime Metal owner actually accepted the panel frame (a raw fallback fails); the font script checks every initial/adopted/zoomed effective point size without changing its grid goldens. The dial's two extras are what make momentum testable at all: the optional gap is a real sleep before the event, so a script can say how FAST the dial is being turned, and `rotate_end` is the release the fling is measured from. Output is byte-identical in shape to `test/snapshot.zig`'s, so a grid captured through CoreText and one captured through a pty can be read side by side. This is the layer that can assert the trackpad features, and the reason it can is that `NSTouch`, pressure stages and rotation have **no public constructors** — a test can never synthesize the events. So the view is built with the decision one call below the event: every override decodes and then calls `press`/`release`/`click`/`rotate`/`typeKey`, and `Trackpad.button(fingers:)` is pure policy with no `NSEvent` in it. The scripts drive those, which is everything except the two lines that read the properties off the event. `haptic` reads `pardes_take_haptic` back, which is how a pulse is asserted on a machine that cannot feel one; `draw` renders the view with `cacheDisplay` and fails if every pixel comes out identical, which is what keeps `draw(_:)` honest — `snap` reads the core's cell buffer and would be perfectly happy with a `draw` that returned on its first line. The harness also owes the core a PRESENTATION, and that is not cosmetic. The window is borderless and never ordered front, so AppKit runs no display cycle for it and `draw(_:)` — the only caller of `pardes_frame_presented` — would never run outside the `draw` command. The core holds pointer gestures inert while a layout mutation has not reached a backend (`panel_presentation_pending`, read in `presentedPointer`), which for the app is one frame and for an unpresenting harness is the rest of the script: the first pane a script opens would silently kill every later click, drag and Look. So `readFrame` presents what it just rendered, into a bitmap nobody reads — the app's `AppDelegate.pump` marks the view and AppKit draws it, and this is the same debt paid the same way. The Linux loop proves the new C layout, flag encoding, clock wrap, embedded kernel source, header syntax, and static library. Compiling Swift, runtime Metal kernel compilation, and comparing processed pixels remain `macos-e2e` work on a Darwin host; Linux has neither AppKit nor Apple's Metal runtime. Goldens are hermetic: a fake `$HOME` with a pinned `PS1`, `Shell bash` in the config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and `TMPDIR` inside the per-script world, so that any temporary document a script opens has a reproducible directory — its six mkstemp characters are masked on capture. `New` itself no longer makes one: since `c3d0b84` it opens the in-memory `+New` scratch buffer and `Save` asks for a path. **The app itself.** `zig build -Dplatform=macos && open zig-out/pardes.app`. Some things only a hand can test: which System Settings checkbox is on, what a deep press feels like, whether the haptic lands with the click or after it. ## Performance Measured on an M2, one window at 190x56 (1710x984 points), timing `draw(_:)` and its phases over 60 frames of a shell pouring out four thousand lines. | | Debug core | ReleaseFast core | |---|---|---| | `pardes_frame` | 4119 us | 413 us | | background pass | 160 us | 187 us | | glyph pass | 226 us | 264 us | | **whole `draw`** | **4516 us** | **879 us** | The finding is the first row, and it is not about drawing at all. `swiftc` was hardcoded to `-O` while the Zig core followed `-Doptimize`, so the ordinary build shipped an optimized shell wrapped around a Debug core — and that reads as "the mac backend is slow" rather than "you built Debug". The mode now travels from `-Doptimize` into the swiftc link, both halves are compiled the same way, and a Debug bundle says so on the way out. Build one you intend to *use* with `-Doptimize=ReleaseFast`. What is left is honest: 0.88 ms against a 16 ms frame, and the Swift half is 0.45 ms of it. Nothing here is a CoreText problem yet. The two things that would be worth doing before reaching for Metal, if a bigger window ever makes this matter, are both in `pardes_frame` rather than in the view — it re-renders every cell of the grid on every frame, and `draw(_:)` ignores its `dirtyRect` for exactly that reason. Those figures are the direct path with all scene bits off. An enabled scene adds an offscreen CoreGraphics frame, one Core Image/Metal kernel, and presentation of its result. That opt-in cost has not been measured on the M2 used for the table and is not folded into the direct-path claim. ## Not implemented - **The `lsp` effect.** Needs a worker plus a snapshot of the pane's path and content taken *before* it starts, as `LspJob` in `tty.zig` does; the core keeps editing while a query is in flight. Until then every query is answered with an EMPTY `lsp_resp` rather than dropped — dropping one leaves the keystroke that asked (insert-mode Tab after a dot) waiting forever, and dead for the rest of the session. - **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a job, a worker to run the command, and a `pipe_resp` event back. Same shape as `lsp`, one more response type. - **Detached sessions.** `Attach` (`SPC s a`) and `Detach` (`SPC s D`) exist on every hosted platform, this one included, because `Builtin.enabled` is `pardes.hosted`. Neither works here. `Detach` emits `Effect.detach`, this host fills in no `detach`, and `Pardes.perform` therefore reports `error.NotAttached` on the pane's message row (`src/pardes.zig:6837`). `Attach` emits `Effect.attach`, which `perform` turns into an `attach_req` the shell is supposed to consume from OUTSIDE `pump` with `takeAttach` — and `src/macos.zig` never calls `takeAttach`, so the word does nothing at all and says nothing either. Wiring it up means a unix-socket frontend loop beside the AppKit one, which is `src/detached/client.zig`'s job in the tty and SDL shells; see `docs/detached.md`. - **IME and marked text.** Only finished characters reach `pardes_key`, so a dead key composes nothing and Option is Alt rather than a compose modifier. Real composition means implementing `NSTextInputClient` *and* giving the core a way to render an underlined preedit run, which no backend has yet — the second half is why this is not just an AppKit protocol away. - **Tabs and splits at the window level.** One window, one grid; window tabbing is switched off rather than left to produce an empty second window. Pardes's own columns and panes are the layout, and a second window would need a second core, which the singleton ABI is precisely a decision not to have yet. - **A glyph atlas.** Drawing is CoreText per row: runs of cells sharing a face and a colour go out as one `CTFontDrawGlyphs`, ASCII glyph ids are resolved once per face at init and everything else is cached on first sight. That is enough for a grid this size, and it is still a cmap-and-rasterizer path where the SDL shell has a 2048² atlas. It has now been profiled rather than guessed at (see Performance): the glyph pass is 264 us of an 879 us frame, which is not where the time is, so the escalation is still not warranted. When it is, it is ghostty's: a `CAMetalLayer` installed into the view and driven from Zig, with the ABI growing one `platform` pointer field. - **A Tahoe icon asset.** `src/macos/icon.swift` emits a full-colour `.icns`, every one of the ten sizes, and that is the correct and only format at a 13.0 deployment target. macOS 26's Dock defaults to the `ClearAutomatic` icon style, which desaturates any icon that does not ship the new appearance variants, so ours renders there in grey while apps built with Icon Composer keep their colour. Matching them means an `Assets.car` produced by an Xcode 26 tool, which is the first thing in this backend that would actually require Xcode — hence not done. The file itself is verifiably correct: `iconutil -c iconset` round-trips all ten, and the tag bar is `#3465A4` at every size. - **Distribution.** Ad-hoc codesigning and a DMG are wired in; a Developer ID is one `-Dmacos-identity=` away and notarization one `notarytool` call. Still absent: a universal binary, bundled fonts, localization. The app has an icon, a plist that says what it opens, a deployment target it actually enforces and a signature; the missing universal binary is the deliberate remaining gap.