Skip to content

zig/0.17: merge main (9 commits, to #569) - #574

Merged
foxnne merged 35 commits into
zig/0.17from
zig/merge-main-1018b
Oct 18, 2026
Merged

foxnne merged 35 commits into
zig/0.17from
zig/merge-main-1018b

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Part of fizzyedit/plans#5 (plans/ZIG_0_17_PLAN.md, step 2): catching zig/0.17 up with main again (9 commits, through #569), the last before the switch.

Please merge by fast-forward, not squash

jj bookmark set zig/0.17 -r cfed055fcfb6 && jj git push --bookmark zig/0.17

#558 was squashed, so zig/0.17 had lost main as an ancestor. A merge, and the switch PR itself, would then have gone back to #490 as the base and conflicted on every line the rewrite changed. This PR is two commits:

  1. record #558's merge of main as a parent: zig/0.17 plus zig/0.17: merge main (22 commits, to #509) #558's own merge commit (44849f2, whose second parent is main app: control server on by default, socket in a private folder #509). It keeps zig/0.17's tree exactly (empty diff).
  2. merge main (9 commits, to #569): with app: control server on by default, socket in a private folder #509 as the base, only two files conflicted, the dvui pin and the fingerprint.

A squash would undo step 1, and the switch PR would conflict.

What changed beyond the merge

main's commits in this merge:

SDK impact

  • Fingerprint moved: recorded in sdk/src/version.zig, sdk_version untouched.

Verified

  • macOS (arm64, Zig 0.17.0):
    • scripts/check-fmt.sh
    • zig build: 71/71
    • zig build test: 109/109 steps, 637/638 (1 skipped)
    • zig build check-tests-reached: 998 tests in 122 files
    • zig build check-web: 42/42
    • zig build test-sdk-version: 35/35
    • zig build test-integration: 78/78 steps, 608/608
    • plugins/image standalone: 43/43
  • Windows, Linux, the example repos: CI.

🤖 Generated with Claude Code

foxnne and others added 30 commits October 17, 2026 21:09
…do (#495)

Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5b
(after 5a, #487).

## What changes

Cmd-Z stays linear: it takes the last change, whoever made it. It now
says whose that is.

- **The Edit menu names it.** When the step Undo would take was made by
someone else, the row reads **"Undo Claude's Draw"**, and Redo likewise.
The person's own steps read "Undo", so working alone looks as it always
has. The title is dynamic, read when the menu opens; it reads the
owner's `historyLine` only once someone besides the person has acted on
the document (`Host.step_authors`).
- **The bubble after it has a Redo.** Pressing Undo (key or menu) on
someone else's step shows **"Undid Claude's Edit"** on the document's
pane, in place of the plain "Undo" its run would show, with a **Redo**
that puts the step back. Redo acts only while that step is still next to
redo.
- **`ActivityFeed.pushAction`.** A feed item may carry a button. It
alone takes clicks, on a small layer of its own, so the rest of the feed
still never covers the work. An item with a button stays 6 s instead of
2.4 s. `draw` returns the tag of the button pressed.
- **`Editor.stepToTake`** finds the step an undo or redo would take,
where it leaves the line, and its author. It is shared by the menu
title, the bubble and `fizzy.undo`'s `expect` (which #487 had inline).

Muting the run's own "Undo" reuses `Host.activity_muted` (the demo-seek
flag) for the duration of that one command. It is put back before the
feed draws, and the code says so.

## SDK impact

- [x] None (the fingerprint does not move; `test-sdk-version` passes).

## Verified

- **Integration** (`headless: the whole editor comes up…`): an author
labelled "Claude" writes into the document in front.
  - `stepToTake` names Claude.
- `editor.undo()` (Cmd-Z's path) restores the text, and the pane's last
feed item reads "Undid Claude's …" with a Redo tagged with that step.
  - Pressing it brings the text back.
- **Seen on macOS** in a sandbox fizzy built from this branch: an MCP
client named `claude-code` wrote into an open `note.txt`, then Cmd-Z was
pressed through the input tape.
- The pane showed **"Undid Claude's Edit · Redo"** above the history
transport.
- Clicking Redo at its spot brought Claude's line back (`text.read`: 3
lines again).
- The Edit menu's title was not looked at on screen; it is the same
lookup the test checks.
- Gates on macOS: `check-fmt`, `zig build test` (574/575, 1 skipped),
`zig build test-integration` (53/53 steps), `check-web`,
`test-sdk-version`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #518

## What changes

An app built on fizzy (Graphl, with its own `app-display-name`) got
fizzy's About dialog and Help menu whatever it did.

- **Which build is fizzy**: the new build option `app_is_fizzy`
(`AppInfo.is_fizzy`) is true when fizzy is the build's **root package**
(`b.pkg_hash` is empty). An app reaches fizzy as a dependency
(`buildApp`), so it is never classed as fizzy. This is structural: a
fizzy build that renames itself, or a fork, isn't misclassified by its
names, and nothing in any build.zig has to remember to set it.
- **About dialog**:
  - The title is `AppInfo.about_title_z` ("About <display name>").
  - The fox and the fizzyed.it link show only for fizzy.
- The "GitHub:" row shows only when `AppInfo.own_repo_url` is true:
fizzy, or an app that set `-Drepo-url` (`repo-url` defaults to fizzy's
for every app).
- The "Automatic updates are not included in this build." line is gone.
- **Status bar**: the app-name button runs `fizzy.about` through
`editor.app.host.runCommand`. Fizzy owns no documents, so no claim can
hold it.
- **Help menu**:
- The `fizzy.about` item reads "Check for Updates…" only with the
updater and a feed of the app's own (`auto_update.impl and
AppInfo.own_repo_url`, comptime). Otherwise it reads "About <app>".
  - `fizzy.about` uses lucide `info` rather than `download`.
  - `fizzy.restart` is "Restart <display name>".
- **Report a Bug** goes to a new `-Dapp-issues-url`
(`AppInfo.issues_url`).
- The default comes from `build/issues_url.zig`'s `derive`, a pure
function unit-tested as `fizzy-issues-url-tests`: an explicitly set
non-empty `repo-url` with all trailing `/` and then `.git` trimmed, plus
`/issues`; else fizzy's issues for fizzy; else empty. `-Drepo-url=`
(empty) gives empty, not `/issues`.
  - Empty disables the command through its `isEnabled`.
- **Menus grey with their commands**: a menu `CommandItem` with no
`enabled` of its own now falls back to `host.commandEnabled(id)`, in
both the in-app bar (`Menu.zig`) and the native `.bar` path
(`backend_native.menuEnabled`). Report a Bug, Restart and every other
command with an `isEnabled` grey out with no per-item predicate.
- **Packaging**: `app_is_fizzy`, `app_repo_url_set` and `app_issues_url`
are carried through `Config` and set in `pack_opts` as well as
`build_opts`, so the Velopack executable compiles.

A hook for an app's own About body (logo, links, text) is left for a
follow-up, as the issue says.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (101/101 steps,
including `fizzy-issues-url-tests`), `zig build test-integration`
(53/53), `zig build check-tests-reached` and `zig build
test-sdk-version` (main's fingerprint) pass.
- The packaging path: `zig build package` (Debug, no signing) succeeded,
50/50 steps. That compiles `exe_for_package` against `pack_opts` and
runs `dotnet vpk pack` locally.
  - I did not open the dialog or menu to look at them.
- [ ] Windows:
- [ ] Linux:
- [x] Web: `zig build check-web` passes.

## Follow-ups

- Letting an app's plugin replace a fizzy command (`fizzy.about`,
`fizzy.reportBug`) is a separate SDK PR, #536 (part of #518). Once it
lands, Help's About label should follow a replacement's title.
- An app built with Velopack and no `repo-url` still checks fizzy's
update feed, because `repo-url` defaults to fizzy's. That is packaging
identity, #520, and is not fixed here.
- A hook for an app's own About body (logo, links, text).
- The About dialog is still modal (the corner-button issue).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 2. Follows
#490 (merged), with `main` merged in.

## What changes

`GET /events` on the control socket streams the app's events as
Server-Sent Events. That's every event on the host's bus: fizzy's
`fizzy.document.*`, `fizzy.folder.*` and `fizzy.files.*`, plus anything
a plugin emits. `?topics=a,b` picks names, or prefixes ending in `*`;
with no topics a client gets everything. Dark behind `FIZZY_CONTROL=1`,
like the rest of the server.

```sh
curl -N --unix-socket ~/fizzy-profile/control.sock 'localhost/events?topics=fizzy.document.*'
event: fizzy.document.opened
data: {"path":"/…/hello.txt"}

event: fizzy.document.saved
data: {"path":"/…/hello.txt"}
```

- **One conversion, many clients.** The relay converts each event's ZON
payload to JSON once. `Server.publish` appends the same SSE message to
every stream that wants it. A payload that isn't ZON goes as `{"zon":
"<text>"}`.
- **Nothing when unused.** The relay is a pseudo-plugin, like the
`fizzy_plugin` that owns fizzy's own commands. It subscribes to `*` only
while a stream is open and is withdrawn when the last one closes, so
with no stream an emit still costs the bus one atomic load. Idle with a
stream open, `devtools.stats` reads 0 frames: the keep-alive is written
by the stream's own thread, with no UI wake.
- **Streams end cleanly, even when the client has gone quiet:**
- Each connection is an `Io` task, which `stop` cancels. std interrupts
a task's blocked read or write on every OS. A socket `shutdown` doesn't
end a blocked write on Windows, and closing the socket under one is
`unreachable` in std there.
- A client that falls more than 8 MB behind gets nothing more buffered,
and on POSIX its socket is shut down at once.
  - A keep-alive comment every 15 s finds clients that went away.
  - At quit, `routes.stop` withdraws the relay before the server stops.
- A stream is registered as it's answered, on the UI thread. Events
published while its headers go out are buffered for it, and the relay
doesn't drop out between frames.
- **A std trap, worked around:** `BodyWriter.flush` flushes only the
connection's writer, never the body writer's own buffer. The body writer
gets no buffer, so each write reaches the connection at once.

### Changed from the plan

- **No new SDK seam.** The plan proposed `host.publish(topic, zon)` for
plugins' topics. The host already has that bus (`Host.emit` /
`subscribe`, with prefix and `*` patterns), so the relay listens to it.
No fingerprint move.
- **Sessions are not in this step.** Nothing on the server holds state
yet: claims need an owner, and recordings are the agent plugin's until
step 5. A session id with nothing behind it would be a seam without a
consumer. Sessions come with step 5, when `agent.*` commands want claims
and tapes. I'll update the plan.

## Review

An independent review pass found 8 issues. All fixed, with tests:
- **High:** a client that stopped reading hung the quit. Its thread
blocked in a write and `stop` waited for it.
- **High:** a stream registered only after its headers went out. Its
first events could be lost, and the relay could switch off for a frame.
- Queued requests were dropped when memory ran out.
- Every event paid a ZON parse even when no stream wanted it.
- `HEAD /events` was refused.
- An event name containing a line break could forge SSE messages.
- Unsendable payloads were dropped silently. They now arrive as `null`.
- The relay's state was module-global. It now lives on `App`.

Verifying the stall fix on Windows turned up a test bug, not a server
one. A test client reading "to the end" sometimes hangs there, because
Windows' AF_UNIX doesn't always tell a reader the other side closed. The
test client now reads `Content-Length` bytes, as HTTP clients do, and
the pitfall is written in `app/AGENTS.md`. One real-world trace remains:
a `curl -N /events` on Windows may not notice fizzy closing the stream
at quit.

## SDK impact

- [x] None. It uses `Host.subscribe` / `withdrawPlugin` as they are.
`test-sdk-version` passes.
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- **Unit tests:** a stream gets only the topics it asked for, in order,
and ends when the server stops; topic parsing and matching.
- **Integration test:** with a real `Host` and server, the bus listens
only while a stream is open, an emitted and delivered event reaches the
stream as JSON, and after `routes.stop` nothing listens.
- **Live:** `curl -N /events` while driving fizzy over the same socket
received the document events. `HEAD /events` gets headers only. Fizzy
quits at once with a stalled stream client attached that never reads.
- **Gates:** fmt, `zig build`, `test` (583/584, the skip is
pre-existing), `test-integration` (53/53 steps), `check-web`,
`test-sdk-version`, `check-tests-reached`.
- [x] Windows: fizzy's `fizzy-control-server-tests.exe`, now 11 tests,
passed 30/30 runs in the Windows 11 ARM64 VM. The tests include: a
stalled stream client, overrun, a stopped server with a blocked writer,
an idle keep-alive connection at stop, the handover, and an event
published as its stream is answered. The x86_64 cross-compile links.
- [ ] Linux: CI. Cross-compiles for x86_64-linux without libc.
- [x] Web: `check-web`; nothing is added there.

## Follow-ups

- Step 3: `/openapi.json`, which can list `/events` and the declared
events (`Host.declareEvent`) too.
- `settled` as an event, for scripts that wait for the UI to settle: the
automation service knows it. It needs that service to emit, which is a
change of its own.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ft (#535)

Part of #519

## What changes

1. **The corner button no longer lights under a modal.** With About
open, hovering a place's corner faded the button in (and its ring), but
the click went to the dialog. `Region.cornerButton`'s `near` read only
the raw `mouse_pt`. It now also requires that no modal subwindow other
than the current one is open. That check is
`core.dialogs.modalOverCurrent()`, the modal-only half of
`canvasPointerInputSuppressed`, pulled out as a small pub fn;
`canvasPointerInputSuppressed` now calls it and behaves as before. The
cheap rect test runs first, so the subwindow walk happens only while the
pointer is at the corner.
- Only modals count, not "pointer over any other subwindow". Otherwise
pixi's glass bubbles near a canvas corner, tooltips or a nested place's
controls would fade the button out.
2. **The Output panel's tab strip is back on the left.**
`OutputPanel.drawTabStrip` set `gravity_x = 1.0`, though the file's doc
comment says the strip is on the left, and on the right it sat under the
corner button. Dropped, so the strip is the first child of the
horizontal box again, with the log to its right.

An earlier revision of this PR moved the button into a `FloatingWidget`
to get it over focus rings. Review found that caused input regressions:
- an unclipped hit rect over the neighbour in a squeezed place;
- `near` failing over any other subwindow;
- clicks landing on the full 36 pt float while the glass was still
growing in;
- the zones and ring still drawn under focus rings anyway.

It is reverted; the button stays in the place's front-to-back block as
on `main`.

## SDK impact

- [ ] None
- [x] Core-only or additive: reaches plugins at the next SDK release
(`core.dialogs.modalOverCurrent` added; `canvasPointerInputSuppressed`
unchanged in behaviour). `test-sdk-version` passes, so the fingerprint
did not move.
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS: `scripts/check-fmt.sh`, `zig build` (46/46), `zig build
test` (99/99 steps), `zig build test-integration` (53/53 steps), `zig
build check-web` (17/17) and `zig build test-sdk-version` (12/12) all
pass. A verdict tape (`FIZZY_VERDICT` + `FIZZY_DEMO`, sandbox
`--profile`) also passes, with no leaks or warnings. It drags the
Sidebar out by its corner button (a float appears), resets, then opens
About and presses and drags at the same corner: no float appears. Then
it closes About. **Not seen with eyes:** the display was asleep, so
screen captures were black. I have no screenshot of the button staying
dark under About or of the Output strip on the left.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: compiles (`check-web`), not run

## Follow-ups

- **Focus rings and error outlines drawing over the corner button's
glass (the rest of #519)** needs a dvui change. Front-to-back blocks
must be kept after later draw-last strokes (the `front_len` approach in
the issue's source report). That is planned with the dvui pin bump in
#526.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…lds (#540)

Fixes #526
Part of #519

## What changes

Repins dvui to `foxnne/dvui-dev` `fdc03d4` (tag `fizzy-sdk-0.2.30-2`).
That is the previous pin `f646ec0` plus the changes below, with no
upstream rebase.

1. **`dvui.testing.snapshot` builds on Zig 0.16** (`e2c9a85`, #526). It
was still on the pre-0.16 file API, so any test calling it failed to
compile. This takes upstream's own port (`30adbcaa` through `2fc3f883`)
whole: `snapshot(src, frame, opts)` gains `SnapshotOptions` (a custom
path), the snapshot directory may be absolute, and messages go through
`std.log` instead of stderr (on 0.16, stderr output fails a test run).
Upstream's `test snapshot` comes with it, so the function is compiled.
This commit comes out empty at the next rebase.
2. **Closed `RenderFrontToBack` blocks are drawn after draw-last
strokes** (`f07c334`, reworked in `fdc03d4`; the draw-order part of
#519).
- **The bug:** a closed block went in at the *start* of the subwindow's
`render_cmds_after`, while draw-last strokes (`WidgetData.focusBorder`,
a text entry's error ring, `.after` strokes) were appended to the *end*.
So the explorer filter's focus ring lay on the pane corner button's
glass.
- **Main case:** closed blocks now go in a list of their own,
`Subwindow.render_cmds_front`. `endRendering` draws it after
`render_cmds_after`. Draw-last commands stay O(1) appends, and nothing
counts or has to be reset beyond clearing the new list with the others.
- **Order among blocks:** each block goes at the start of the list, so
the first block closed is still drawn last. Within a block, its own
commands come first, then its draw-last commands, then the blocks closed
inside it.
- **Deferred drawing:** `Window.defer_render_cmds_front` is set by
`RenderFrontToBack` (to its own list) and by `Picture` (which draws it
last at `stop`).
- **Older code:** a queue deferred without a front list, by code that
predates it, gets a closing block at the start of its after list, as
before.

In fizzy: `backend/src/viewports.zig`'s `Picture.subwindow` takes a
subwindow's lists to draw them into a viewport. It now takes
`render_cmds_front` too and draws it last, in dvui's order.
`docs/DEPENDENCIES.md` lists both changes and the tags. (The first cut
of this PR, tag `fizzy-sdk-0.2.30`, counted the blocks at the end of
`render_cmds_after`. Review replaced that with this design. The tag
stays, unpinned, because tags are never moved.)

Known minor: dvui's `Debug` widget outline (`Debug.zig`) is a draw-last
stroke, so it now draws under a front-to-back block.

Parts 1 and 3 of #519 (the corner button under a modal, the Output tabs)
are #535's.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0x78f2a3cc371828ef` → `0x2897dc6ec36f713b`), `sdk_version` untouched,
PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

For plugin authors:
- `Subwindow.render_cmds_front` and `Window.defer_render_cmds_front` are
new.
- Code that takes a subwindow's lists to draw elsewhere should take
`render_cmds_front` too and draw it last.
- Code that installs its own deferred queues should set
`defer_render_cmds_front` alongside `defer_render_cmds_after`.
- `dvui.testing.snapshot` takes a third argument (`.{}`).

## Verified

- [x] macOS:
- fizzy, with the URL pin: `scripts/check-fmt.sh`, `zig build`, `zig
build check-web` and `zig build test-sdk-version` passed. `zig build
test`: 99/99 steps. `zig build test-integration`: 53/53 steps, 561/561
tests. `scripts/check-examples.sh`: all 8 builds ok.
- dvui: `zig build test -Dbackend=testing` passed 266/266. That includes
`test snapshot` and the new test "closed RenderFrontToBack blocks are
drawn after draw-last strokes, before or after they closed". The new
test covers sibling blocks, a nested block, strokes before and after,
deferred queues installed as `Picture.start` does, the fallback for a
queue without a front list, and the list cleared with the frame. It
fails when blocks go back into `render_cmds_after`.
- The testing backend makes no texture targets, so a real `Picture` is
not exercised.
- **Not seen on screen:** the display was asleep when I tried a sandbox
fizzy with a tape that focuses the explorer filter, and the capture came
back black.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: `check-web` only

## Follow-ups

- Look at a focused explorer filter under the sidebar's corner button on
a desktop.
- The draw-order change is worth proposing upstream (the Debug outline
could then go after the front list). The snapshot change drops out at
the next rebase onto upstream.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #521

## What changes

`App.newFileID` was a plain `+= 1` on a `u64`. Plugins call it through
`Host.allocDocId`,
and the text, image and archive plugins do that inside `loadDocument`,
which runs on the load
worker thread (`Plugin.zig`'s contract, `FileLoadJob.workerMain`). Two
loads at once, or a load
racing a UI-thread `createDocument`, could get the same id. The counter
is now a
`std.atomic.Value(usize)` and `newFileID` does `fetchAdd(1, .monotonic)
+ 1`. `usize` because
wasm32 has no 64-bit atomics; 2^32 documents can't fit in a wasm32 page
anyway. No other code touches
the field.

Reported by Graphl.

## SDK impact

- [x] None

## Verified

- [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig
build check-web`. The race itself isn't reproduced; the change is the
counter's type.
- [ ] Windows:
- [ ] Linux:
- [ ] Web:

## Follow-ups

None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…lp (#532)

Fixes #517

## What changes

A plugin's top-level menu in the in-app menu bar (Windows, Linux, web,
and macOS's debug bar):

- **is clickable on Windows and Linux.** Only fizzy's own titles called
`pushTitleBarInteractiveRect` (from `menuItem`); a plugin's
`MenuContribution.draw` can't, so a press on its title was a caption hit
and dragged the window. `Menu.draw` now pushes the menu bar's own rect
once, and `menuItem` no longer pushes per title. The bar widget
(`widgets.menu`, no `expand`) sizes to its titles, so its rect is
exactly theirs and the strip beyond the last title still drags the
window. No wrapper widgets, so every title keeps its widget id.
- The push is gated on `titlebar_hit_tested`, as
`registerFloatingTitleBarRects` is for the dropdowns, so the debug
in-app bar on macOS (View > "Show DVUI Menu (macOS)") agrees with its
dropdowns.
- The strip test (strip height, `r.y < strip_h`, push) is one helper,
`Editor.pushTitleStripRect`, shared with the sidebar rail's icons, which
keep their `custom_titlebar` gate.
- **sits between View and Help.** The bar walked `host.menus` in
registration order, and fizzy registers File/Edit/View/Help after the
bundled plugins, so a plugin menu came before File (or after Help, for a
plugin loaded later). The order is now `src/editor/menu_order.zig`, a
std-only iterator: fizzy's menus but Help, then plugin menus in
registration order, then Help, skipping hidden ones. `Menu.draw` walks
it with one draw-and-catch.
- Fizzy's own are told apart by `draw == drawModelMenu`, not `owner ==
null`, which a plugin may leave unset.
- Help is the one whose `ctx` is the new `menu_model.help`, not an id
string. The native macOS builder now uses `menu_model.help` too.
- A unit test (`fizzy-menu-order-tests`) covers fizzy's menus first,
plugins in registration order (one loaded after fizzy's), Help last, a
hidden menu skipped, and a plugin with no `owner` still counted as a
plugin.

The native macOS bar already put plugin menus before Help
(`platform.menu.setExtras`), so its order is unchanged. The stale "Order
= bar order" comment in `Editor.zig` is corrected.

No SDK change.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

`scripts/check-fmt.sh`, `zig build`, `zig build test` (with the new
ordering test), `zig build check-web` and `zig build
check-tests-reached` on macOS. The bar's push branch now compiles on
macOS too (`titlebar_hit_tested`). No bundled plugin registers a
top-level menu, so there was nothing to click locally.

- [ ] macOS: builds; the in-app bar isn't drawn there unless the debug
toggle is on (not checked by eye)
- [ ] Windows: not run (title-bar behaviour unverified)
- [ ] Linux: not run (title-bar behaviour unverified)
- [ ] Web: compiles (`check-web`); order not checked by eye

## Follow-ups

A Windows or Linux check with a plugin that registers a top-level menu
(pixi's, or `fizzyedit/example-plugin` with one added): click its title,
see it between View and Help, and check the empty strip right of Help
still drags the window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #516. This is the first of two stacked PRs; #534 wires it in and
fixes the issue.

## What changes

`app/watch/folder_events.zig` gains the half of the folder watcher that
decides which folders a folder-by-folder backend (inotify) watches. It
is std-only and unit-tested on a temp tree. Nothing calls it yet except
the dot-folder event filter, which `FolderWatcher` now reaches as
`underSegment(folder, path, dotName)`; the rest of the wiring is #534.

**Why.** On inotify, nightwatch's `watch` adds one watch per directory,
recursing everywhere: no skip list, no cap, and it keeps walking after
the kernel runs out (#516).

`Watches` replaces that walk.

**What is watched**
- **Skipped:** hidden folders, `node_modules` and `zig-pkg`, at any
depth below the root (`underSegment(root, path, skipWatchDir)`).
- "Below" means the root followed by a separator: `/pp/node_modules` is
not under `/p`. `inside()` holds that rule for every check.
- **Still watched:** ordinary names like `target` and `zig-out`.
`IgnoreRules` drops their events.
- **Symlinks** are not followed.
- **Unknown entry types:** an entry whose type the listing doesn't give
(DT_UNKNOWN) is stat'ed without following links.

**Counting live watches**
- Live watches count against a cap of 16384.
- The watched paths are kept sorted. A folder's subtree is found by
binary search in O(log n + k), without building the prefix, so no path
is too long for the search.
- A walk collects what it adds and merges it in one pass at the end. A
rename rewrites its run, which stays in order, and merges it back.

**Giving watches back**
- A folder that is deleted, moved out of the root, or moved into a
skipped name gives back its watches and those below it. A watch that has
moved is removed under its new name.
- A folder renamed over an empty watched one leaves one key, not two.
- A folder renamed within view keeps its watches.
- A folder renamed out of a skipped name into view is walked, and its
contents are announced.
- A watched folder is never walked twice.

**Why a watch was refused** is asked, not guessed: `ctx.why(path)`
returns a `Refusal`.

| `Refusal` | Meaning | What happens |
|---|---|---|
| `.pool` | The OS is out of watches. | The walk stops, said once per
stretch of being behind. |
| `.own` | A problem with that folder, named with the errno. | Lost
(below). |
| `.gone` | A race: the folder went first. | Nothing is said. |
| `.again` | It might work now. | Tried once more. A second refusal is
judged the same way, and if it is `.again` again, the folder is lost. |

**Folders that can't be watched**
- Lost folders are a set, not a counter. Each is said once and never
re-probed by later walks.
- A folder leaves the set when it goes, or moves (it is tried again
under its new name). `whole()` can then become true again.
- These count as lost:
  - permission, loop or bad-name errors when opening a folder
  - a path too long to walk
- These go on the frontier for a retry instead:
  - running out of fds or kernel memory
  - an I/O error

**Out of memory: one rule.** Any allocation failure anywhere sets a
sticky `degraded`, said once, and `whole()` stays false for the session.
A watch whose key can't be kept is removed, so no live watch is ever
untracked. A test fails each of the first 200 allocations of a session
in turn (walk, a new folder, renames, a folder going) and checks, after
every one, that the backend's live watches equal the tracked set.

**The frontier**
- A walk that stops remembers where: the refused folder, plus each
partly walked folder above it.
- Each entry records what it is still owed: its own entries and
everything below, or only what its subfolders hold.
- A folder's entries are announced before anything below it is walked. A
resumed folder therefore never repeats them, and a subfolder that was
finished is not walked again. A folder that a walk finishes is taken off
the frontier, and off the list `catchUp` is working through.
- Entries follow renames and go with deletions; one helper, `reframe`,
does both.
- `catchUp` goes on from the frontier, never from the root. `retry()`
lets a timer try again. `waiting()` says whether time can help; it can't
for the cap.

**Announcing.** What a new folder held before its watch landed is
announced through `ctx.created`. The exceptions are a folder the backend
already reported (a move-in), and one reached silently by the root's
first walk, which is announced once its own event is handled. The first
walk itself announces nothing.

**`whole()`** is true when every folder is watched: nothing waiting,
nothing lost, not degraded.

**`Doorbell`** wakes a thread from one that must not block, allocate
under a lock or touch an `Io` (nightwatch's reader). It is a pipe with
an atomic flag in front, so a burst of rings writes one byte. `ring` and
`answer` both swap the flag with acq_rel ordering; the comment on
`Doorbell` explains why that is enough. It is POSIX-only
(`has_doorbell`). A poll failure is returned, not spun on.

## SDK impact

- [x] None

## Verified

- [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`
(99/99 steps), `zig build check-tests-reached`, `zig build check-web`.
`zig build test` reports 609/610 tests passed, 1 skipped.
`folder_events.zig` has 34 tests. Among them:
- a walk that stops between two subfolders and is resumed announces each
entry exactly once and tries only the folders not yet watched
- lost folders are said once, not re-probed, forgotten when they go, and
tried again under a new name
  - `.again` then the pool blocks, while `.again` twice loses the folder
  - a folder moved out of the root goes
  - the separator-boundary rule
  - the allocation-failure sweep
- the earlier cases: refusals, retry, cap, announcing, renames, cancel,
and the doorbell
- [x] Windows: `zig build check -Dtarget=x86_64-windows-gnu` compiles
the unit tests (the `Doorbell` tests skip there), and `zig build
-Dtarget=x86_64-windows-gnu` cross-compiles the app. Neither was run.
- [x] Linux: `zig build -Dtarget=x86_64-linux-gnu` cross-compiles. The
tests are std-only and run in CI on Linux.
- [x] Web: `zig build check-web`.

## Follow-ups

- An I/O error partway through listing a folder that owes announcements
re-announces that folder's entries on retry, rather than risk missing
any.
- DT_UNKNOWN and over-long paths have no test: neither is easy to
reproduce on a temp tree.

- The wiring, the errno probe, the retry timer, and the
`setProjectFolder`/Recents changes are in #534.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #525

## What changes

A crash report whose faulting PC is not code (a call through a bad
function pointer: heap, an unloaded dylib) no longer loses its caller or
prints garbage frames. The handler no longer calls the dynamic loader.

**The rule:** the report acts only on an address that is **definitely
not code**. Acting means recovering a caller, stopping the walk, or
marking a frame `(not code)`. Whatever can't be told is `unknown` and is
written as it was before this PR: std walks, and the raw address is
printed. The answer comes from the kernel, which knows every mapping
whoever made it (JIT, a library dlopened by a library, a dylib kept
mapped after unload). It never comes from a table that might be
incomplete. All of it lives in a new `app/crash/loaded.zig`:
- **Linux:** `/proc/self/maps`, read once per report with raw
`open`/`read` syscalls into a static 512 KB buffer. It is parsed once
into a static array of up to 8192 mappings (range, exec, base, name),
and each lookup binary-searches that array.
- Code is an `x` mapping. Not code is another mapping, or no mapping
when the file was read whole and fit; otherwise unknown.
- Names come from the maps line's path, with the kernel's ` (deleted)`
suffix stripped.
- An image's base is the start of the same file's first mapping, matched
by dev and inode. That holds where lld puts code a page past its file
offset, so `lo - offset` would be wrong; tested on that layout.
- **Windows:** `VirtualQuery`. Code is `MEM_COMMIT` with any
`PAGE_EXECUTE*`, not code is anything else, unknown when the query
fails. The base is always the allocation base. The name comes from the
module table (taken at `init`/`writeTo`) only when that entry starts at
the same base, which guards against a stale entry. Otherwise the frame
prints as `?+0x…`.
- **macOS:** dyld's add/remove-image callbacks keep a table of images
(`__TEXT` range and name), plus dyld itself from `TASK_DYLD_INFO` on
64-bit targets. dyld removes only what it unmaps, so `removeModule` no
longer drops anything. An address the table doesn't hold asks
`mach_vm_region_recurse`, descending the shared cache's submaps. If the
table fills (1024 images), only names suffer, and `refresh` logs it
once. Replacing an entry writes a free slot and only then lets the old
one go, round-robin, so a crash reading the table sees one whole entry
or the other, never a torn one.

What it does with that:
- **The caller is recovered** when the PC is not code: the return
address the call left (`*(sp)` on x86_64, `lr` on aarch64), printed as
`caller (by call convention):`. That happens only when that word is
code; a stack word that isn't code names no caller.
- A jump or tail call through the bad pointer leaves the grandparent
there; a comment notes the limit.
- The stack is then walked from the caller's context with std's own
unwinder: PC = return address − 1 (`rsp = sp + 8` on x86_64). std takes
a context's PC as exact, so the unwind-info lookup lands in the calling
function even when its call is its last instruction. std's "+1" for a
context's PC then turns it back into the return address it prints.
- If that walk finds nothing, std's walk from the original context runs.
- **A return through a corrupt return address names no caller:**
`caller: (caller unknown: pc came from a return)`.
  - aarch64: `lr == pc`.
- x86_64: the word just below `sp` equals `pc` **and** `*(sp)` is not a
code address. The word below `sp` lies below the stack, where anything
may linger, and a real call through a bad pointer leaves a code return
address at `*(sp)`.

  Both shapes are tested through a pure `callerFrom`.
- **`*(sp)` is read so it can't fault a second time:**
- Linux: through `process_vm_readv` on our own pid, which fails rather
than faults.
- macOS: only within the thread's
`pthread_get_stackaddr_np`/`pthread_get_stacksize_np` bounds.
  - Windows: only within the TEB's `StackLimit`..`StackBase` bounds.
- **The walk stops at the first frame after the PC that is not code:**
`(unwind stopped: 0x… is not code)`. Each frame is located once, and
that result decides the stop, the header's `crashed in <module>` (from
frames before the stop), and the printed frame. A module only names an
address: its ELF span covers its data too, so fizzy's own `.data` is not
code. On platforms the kernel query isn't wired for (the BSDs, illumos,
haiku, …) everything is unknown, which leaves the report as it was.
- **Frames outside every module print as `image+0xoffset`.**
- **A second `init` doesn't chain the fault handler to itself.**
- **The header records the thread and registers:** thread id and name
(`pthread_getname_np` on Linux, Apple OSes, FreeBSD and NetBSD),
`pc`/`sp`/`fp`, and for SEGV/BUS/ILL/FPE the signal's `si_code`. Abort
and trap are left out, since there `si_code` names the sender. std's
fault handler isn't given `siginfo`, so `init` chains a small handler in
front of std's that notes the code in one atomic word tagged with the
thread id, so another thread's fault can't lend its code. A
`threadlocal` can allocate on first touch on macOS.
- **No large stack buffers in the handler** (a Windows report can come
from a stack overflow): the module copy, the frame locations and the
maps text are static.

Not changed: the issue's note that the context-PC frame is printed one
byte early. std's `StackIterator` already returns a context's PC **plus
one** (`ctx_first`), so `ret -| 1` gives the exact PC.

### Demos

Scratch programs outside the repo; not committed. macOS, aarch64
natively and x86_64 under Rosetta.

`outer` → `callThrough(heap_ptr)`, before (aarch64):
```
demo crashed in demo 0.0.1: Bus error at address 0x10183db20
frames:
  0x10183db20
  demo+0xfde87          <- outer; callThrough is missing
  demo+0xfdc7f
  demo+0xfdbb3
  0x180f3bdff dyld
```
After (aarch64):
```
demo crashed in demo 0.0.1: Bus error at address 0x100ea99a0 (code 1)
thread 17127651
registers: pc 0x100ea99a0 sp 0x16f83e540 fp 0x16f83e560
caller (by call convention): demo+0xfe2cf (pc is not code)
frames:
  0x100ea99a0 (not code)
  demo+0xfe2cf          <- callThrough
  demo+0xfde87
  demo+0xfdc7f
  demo+0xfdbb3
  dyld+0x1fdff
```
Before (x86_64): `demo+0x121208 / +0x120fec / +0x120f31 / 0x20445bb27`,
with callThrough missing. After (x86_64):
```
caller (by call convention): demo+0x1215c5 (pc is not code)
frames:
  0x7faa21706080 (not code)
  demo+0x1215c5         <- callThrough, from *(sp)
  demo+0x121208
  demo+0x120fec
  demo+0x120f31
  dyld+0x12b27
```
A pivoted stack (x86_64: `rsp = 0x10000`, then a jump into the heap).
The first revision of this PR read `*(0x10000)` in the handler, faulted
there and wrote no report. Now:
```
registers: pc 0x7fed1bf06040 sp 0x10000 fp 0x301b5a690
frames:
  0x7fed1bf06040 (not code)
  pivot+0x120f31
  dyld+0x12b27
```
A return through a corrupt return address (x86_64 `push heap; ret`;
aarch64 `mov x30, heap; ret`, same result):
```
registers: pc 0x7fbb23f06040 sp 0x301f44620 fp 0x301f44680
caller: (caller unknown: pc came from a return)
frames:
  0x7fbb23f06040 (not code)
  ret+0x120f31
  dyld+0x12b27
```

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig
build check-tests-reached`, `zig build test-sdk-version`. The crash
tests (15) pass natively and as x86_64 under Rosetta. The demos above
ran on aarch64 and x86_64 (Rosetta). Unit tests cover:
- the report text: caller line, from-a-return, registers, `si_code`,
`image+offset`, the stop at not-code, nothing stopped or marked for
unknown, a module's data not being code, the header honouring the stop;
- `callerFrom` for x86_64 and aarch64, including a lingering bad pointer
below `sp` that must not read as a return, and a non-code stack word or
`lr` that must not be named;
  - replacing a table entry leaving the old one whole;
  - the thread-tagged fault code;
  - the safe stack read;
- `loaded.at` on this code, a dyld-shared-cache function, data, heap and
an unmapped address, and the kernel query (`mach_vm_region_recurse`) on
its own;
- the `/proc/self/maps` parser and lookup on a sample with lld's layout,
a deleted file, a gap and the heap.

The call-ends-its-function case for the caller context isn't exercised
end to end, because Zig emits a trap after a noreturn call, so its code
never ends in one. A ReleaseFast demo does show std's own `-1` handling
the same shape one frame up: `outer`'s last instruction calls
`callLast`, its return address is `callLast`'s first byte, and the
report still names `outer`. C code that ends in a call to a noreturn
function is what the `-1` on the caller context covers.
- [ ] Windows: `zig build -Dtarget=x86_64-windows-gnu` and the crash
tests cross-compile; not run.
- [ ] Linux: `zig build -Dtarget=x86_64-linux-gnu` and the crash tests
(x86_64, aarch64) cross-compile; not run here. CI runs them;
`/proc/self/maps` and `process_vm_readv` are only exercised live there.
- [x] Web: `zig build check-web`.

## Follow-ups

- **The proper fix for the lost caller is upstream,** in std's
`StackIterator`: its FP fallback after a context PC with no unwind info
should start from the return address the call left, not the frame
pointer. This patch works around it in fizzy.
- **This patch depends on std's "context PC + 1" convention**
(`StackIterator.next`'s `ctx_first`): it writes `pc + 1` for frame 0 and
replaces std's `caller + 1` with `caller`. Check it on the Zig 0.17 move
(fizzyedit/plans#5).
- std's own unwinder, which the handler still calls, asks the loader for
unwind info (`SelfInfo/Elf.zig`, `SelfInfo/MachO.zig`). Only fizzy's
part of the report is loader-free.
- Thread names on Windows (`GetThreadDescription` allocates) and
`si_code`'s Windows equivalent are left out. Windows names images loaded
after `writeTo` as `?`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#534)

Fixes #516. **Stacked on #543**, which holds the std-only folder walker
(`folder_events.Watches`, `Doorbell`) and its tests. This PR wires it
in. Its base is `app/folder-walker`; it retargets to `main` once #543
merges.

## What changes

Opening a large folder on Linux no longer hangs the editor or fills
stderr with `NOSPC`. That covers a home folder, and a tree with
`node_modules` or a package cache.

**Why it hung.** `FolderWatcher` called nightwatch's `watch(folder)`. On
inotify that adds one watch per directory, recursing into every
subdirectory with no skip list and no cap. It ran synchronously on the
UI thread, both from `setProjectFolder` and at startup. After the kernel
runs out of watches, it logs twice per directory and keeps opening every
directory left. nightwatch's handler wrapper does the same on the reader
thread for every directory created later.

**What it does now.** nightwatch is upstream (`neurocyte/nightwatch`),
not a fork of ours, so the fix is on fizzy's side. `FolderWatcher`
drives nightwatch's `Backend` directly (a public declaration on
`nightwatch.Default`). Which folders to watch is left to `Watches` from
#543.

### The walker thread

- One walker thread per open folder does the first walk of the root.
After that it works through folder events: appeared, gone, moved.
- **Reader thread stays fast.** nightwatch's reader thread allocates
each event outside the lock, only links it in under the lock, and rings
the `Doorbell`. It never waits on the walker.
- **Allocator.** The app's allocator is `std.heap.DebugAllocator(.{})`,
which is thread-safe on native.
- **Stopping can't hang.** The walker answers the doorbell before it
checks for cancel, so a stop rung at any point is seen. `stopWatch`
rings, joins, tears down the backend, then frees what is still queued.
- **Retrying on a timer.** While something is left out that time can
help with, the walker also wakes on a timer and retries: 5 s, doubling
to 60 s, and reset once caught up. That covers a root that couldn't be
watched at all, and watches freed by another program. The cap is not
retried on a timer; only a folder in this tree going frees room under
it.
- **Out of memory** anywhere in the walker is logged once (`degraded`)
and turns `active` off for the folder's session. A watch whose key can't
be kept is removed.
- **Poll failure.** If the doorbell can't poll, the walker logs once and
stops cleanly, and `active` turns false. It doesn't spin.

### Why a watch was refused

nightwatch's `add_watch` reports only `WatchFailed`. The walker
therefore keeps a scratch inotify instance
(`inotify_init1(IN_CLOEXEC|IN_NONBLOCK)`). When a folder is refused, it
calls `inotify_add_watch(path, IN_ONLYDIR)` on that instance, then
`inotify_rm_watch`, to get the real errno:

| errno | treated as | what happens |
|---|---|---|
| `ENOSPC`, `EMFILE`, `ENOMEM` | the pool is out | The walk stops. One
line says the system is out of inotify watches and names
`fs.inotify.max_user_watches`. Said once per stretch of being behind,
including when the root itself is refused. |
| `ENOENT`, `ENOTDIR` | a race | Nothing said. |
| success | room again | The watch is tried once more. |
| anything else (`EACCES`, `EINVAL` from a FUSE mount, …) | that
folder's own problem | It is left out, with one line naming it and the
errno. |

No more guessing from refusal patterns.

### What plugins see

- **New folders' contents.** What a new folder held before its watch
landed (an extract, a clone) is reported as `.created` through the event
ring. A folder moved in from outside the tree is not reported a second
time: inotify reports it with everything in it, and the reader spots
that from the depth-first `created` events following the folder's own. A
folder created while the root's first walk was running, and reached by
it, gets its contents reported once its own event is handled.
- **`Host.folderWatchActive`** is `whole && !dropped`. `whole` comes
from the walker: true once the first walk finished and nothing is left
out, waiting or given up. `dropped` is set once a folder event couldn't
be queued for want of memory. The only consumer I found is atlas's index
sweeper, which re-reads it each sweep to pick a rescan interval, so
true→false just shortens the interval. `FolderWatcher`'s doc comment
says which folders are never watched.
- Backends that watch a subtree with one watch (FSEvents on macOS,
`ReadDirectoryChangesW` on Windows) add the root only, as before.

### Also from the issue

- `setProjectFolder` resolves a relative path against the cwd, on native
builds, and only for a path that is neither a mount nor absolute. A path
that can't be found gets "… can't be found." A path that isn't a folder
gets "… is not a folder." and `error.NotADirectory`.
- Opening a recent folder (the menu, and macOS's native Recent Folders)
goes through `Editor.openRecentFolder`.
- A recent that exists but is no longer a folder (`NotADirectory`,
`NotDir`) is dropped.
- One that can't be found stays, since an unplugged drive can come back.
  - Errors are not passed on, so they no longer cut short the menu draw.
- The Open Folder dialog's start directory is unchanged.

## SDK impact

- [x] None

## Verified

All gates were run on this branch, and separately on #543 alone.

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (99/99 steps;
609/610 tests, 1 skipped), `zig build check-tests-reached`, `zig build
check-web`.
- The walker's logic is covered by #543's tests, including the refusal
classification through an injected `why`.
- macOS itself watches through FSEvents, where only how the backend is
created changed. That path builds but was not exercised at runtime.
- [x] Windows: `zig build check -Dtarget=x86_64-windows-gnu` and `zig
build -Dtarget=x86_64-windows-gnu` compile. Neither was run.
- [x] Linux:
- `zig build -Dtarget=x86_64-linux-gnu` cross-compiles the inotify path,
the errno probe and the timer.
- Not run on Linux. The probe and the reader-side move-in detection are
covered by CI and the logic tests only, and were not tried against a
real `max_user_watches` limit.
- [x] Web: `zig build check-web`.

About 360 lines of this diff are outside tests. #543 is about 470.

## Follow-ups

- **nightwatch's `remove_watch` is linear.** It scans its whole watch
map for each removal, under the mutex the reader thread also takes. So
`rm -rf` of a large watched tree costs O(k·n) inside nightwatch, while
fizzy's own bookkeeping is O(log n + k). The fix belongs upstream: a
path→wd index.
- **A folder renamed over an empty watched one.** inotify follows the
move with `IN_DELETE_SELF` for the replaced folder. nightwatch reports
that by path, which can drop the moved folder's watch. `Watches` keeps
one key, but the backend's record is nightwatch's to fix.
- **Move-in detection can miss.** The check looks only at the newest
queued event. If the walker takes the batch between a moved-in folder's
event and its first child, that folder's contents are reported twice.
Duplicates are harmless to consumers.
- **`.gitignore` doesn't decide what is watched.**
- **`$HOME` and `/` are not refused.** They are bounded by the skip list
and the cap.
- **A folder left out for its own reasons** keeps `folderWatchActive`
false for the session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #519

## What changes

This follows up #540, which merged with the first cut of the front list
(`fizzy-sdk-0.2.30-2`). It repins dvui to `foxnne/dvui-dev` `44010a3`
(tag `fizzy-sdk-0.2.30-4`), which is `cc60612` plus a commit addressing
the two later reviews. What is drawn on screen does not change: blocks
still draw above every draw-last stroke in their subwindow.

- **Closing a block is an append.** A subwindow's `render_cmds_front` is
a `RenderFrontToBack.Blocks`: closed blocks appended in closing order,
with each one's start offset. `Blocks.drawOrder` hands them out the
other way round, so the first block closed is still drawn last. The cost
is amortized constant time per command, but a nested block's commands
are copied again at each enclosing level.
- **A block with nothing in it adds nothing.** This covers an
`initReset` block or an empty one; neither appends an empty entry any
more.
- **Deferred queues are one unit.** A `Picture` or a block records into
a `dvui.RenderQueues` (commands, draw-last commands, blocks), installed
through one pointer, `Window.defer_render`. It replaces
`defer_render_cmds`, `defer_render_cmds_after` and the paired front
field. The three can't be mismatched, so the pairing check and the
legacy fallback are gone; nothing in dvui set them separately.
- **One replay order.** `RenderQueues.render` is the only place that
defines the order queued drawing draws in. `Picture.stop` and
`Window.renderSubwindow` both use it.
- **`renderSubwindow(sw, .{ .take })` takes first, then draws.** It
empties the subwindow's queues before replaying them, as fizzy's old
code did, so anything the replay queues (say, a draw-last stroke from a
custom draw command) is kept for later.
- **Fizzy calls `dvui.currentWindow().renderSubwindow(sw, .{ .take = …
})` at its six sites** (`src/editor/Popout.zig`,
`backend/src/os_windows.zig`). The one-line
`viewports.Picture.subwindow` wrapper is gone.

Known minor: dvui's `Debug` widget outline is a draw-last stroke, so it
draws under a front-to-back block.

`docs/DEPENDENCIES.md` updates item 10 and the list of tags. `-3` was
never pinned.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0x2897dc6ec36f713b` → `0xd624ada9fe0e74d7`), `sdk_version` untouched,
PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

For plugin authors:
- `Window.defer_render: ?*dvui.RenderQueues` replaces
`defer_render_cmds`, `defer_render_cmds_after` and
`defer_render_cmds_front`.
- `Subwindow.render_cmds_front` is a `RenderFrontToBack.Blocks`.
- To draw a subwindow elsewhere, call `Window.renderSubwindow`.

## Verified

- [x] macOS:
- fizzy, on current `main` with the URL pin: `scripts/check-fmt.sh`,
`zig build`, `zig build check-web` and `zig build test-sdk-version`
passed. `zig build test`: 101/101 steps. `zig build test-integration`:
53/53 steps, 562/562 tests. `scripts/check-examples.sh`: all 8 builds
ok.
- dvui: `zig build test -Dbackend=testing` passed 266/266. The test
"closed RenderFrontToBack blocks are drawn after draw-last strokes,
before or after they closed" covers:
- sibling and nested blocks, and the block order both as stored and as
drawn;
    - deferred `RenderQueues` installed as `Picture.start` does;
- empty and `initReset` blocks adding nothing (fails without the early
return);
- `renderSubwindow` with and without `take`, including a custom draw
command whose `.after` stroke survives the replay (fails if the take
happens after drawing);
    - clearing with the frame.
- Not exercised: a real `Picture`, since the testing backend makes no
texture targets.
  - Not seen on screen.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: `check-web` only

## Follow-ups

- What `core/widgets/BlurBackdrop.zig` captures
(`sw.render_cmds[cmd_start..]` only): fizzyedit/plans#27.
- Look at a focused explorer filter under the sidebar's corner button on
a desktop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

Fixes #520

## What changes

An app built on fizzy now packages, updates and identifies itself as
itself, not as fizzy.

**One "is fizzy" signal.**
- Fizzy's root `build.zig` passes `Options.own` only when fizzy is the
root package (`b.pkg_hash.len == 0`).
- `Options.own` carries fizzy's bundle id, update feed, `VERSION` file
and its whole branding set: `icon.png`, `fizzy.ico`, `fizzy.rc`,
`fizzy.icns`, `info.plist`.
- #533's `app_is_fizzy` now derives from `opts.own != null`. This branch
is rebased onto #533. Its `own_repo_url` and `app_repo_url_set`
semantics are unchanged.

**Branding.**
- Fizzy's build takes its set whole unless it is renamed (`-Dapp-name`)
or given an icon (`-Dapp-icon`, `-Dapp-icon-ico`, `-Dapp-icon-icns`).
There is no string comparison: fizzy's argv is unchanged, and a renamed
or re-iconed fizzy never ships a plist naming the wrong executable or
icon.
- Any other build gets the app set:
  - its icons, with fizzy's art where it gives none;
  - a generated resource script;
  - a plist that vpk writes from `--bundleId`.
- `-Dapp-windows-rc` and `-Dapp-macos-plist` override either set.

**App-neutral defaults.**
- **Bundle id:** `dev.fizzy.<component>`, built by
`build/bundle_id.zig`, which is unit-tested.
- Anything outside `[A-Za-z0-9.-]`, including each byte of a multi-byte
character, becomes `-`, and runs of `-` collapse to one.
- A leading `-` or digit and a trailing `-` are removed; if nothing is
left, the component is `app`.
- **Update feed:** none, unless `-Drepo-url` is given.
- **Version:** 0.0.0 unless the app passes `app_version`. The app's own
`VERSION` file can't be read here, because a dependency's build doesn't
know the root package's directory.

**Generated resource script.**
- It holds the app's icon and a VERSIONINFO built from its name, display
name and version.
- It starts with `#pragma code_page(65001)`.
- Control characters are dropped from the strings, quotes are doubled
and backslashes escaped.
- The version parse drops a leading `v` and cuts at `-` or `+`.

**Names.**
- `--packId` is `<app-name>`.
- `--mainExe` is the executable's own file name.
- `--packTitle` comes from `app-package-title`, defaulting to
`app-name`, on every OS.

**Icons.** `app-icon` (a PNG) is the window icon, the infobar logo and
the settings-tree logo, through an `app_icon` module. That module also
exports `pixel_art`: fizzy's icon is drawn with nearest-neighbour
sampling, an app's is smoothed. The PNG is also the new Linux `--icon`.

**`packageall`.**
- Each run gets this build's build file (`--build-file
<root>/build.zig`) and inherits the cwd this build was run in. A
relative `-D` path means what it meant here: Zig resolves a `LazyPath`
option against the build runner's cwd, which is the cwd `zig build` was
run in, not the build root (checked with a probe build).
- It passes the command-line `-D` options except the target's own
(`target`, `cpu`, `ofmt`, `dynamic-linker`). `-Doptimize` is passed only
if it was given; `--release` is passed through as given.
- `windows-msvc-libc` and `fetch-msvc` go to the `*-windows-msvc` runs
only.

**`fizzy.addPackageSteps(b, fizzy_dep)`** (new, additive; `buildApp` is
unchanged) gives an app's own build `package`, `desktop` and
`packageall`.
- It panics if `buildApp` hasn't run. It skips only the configure pass
that fetches Velopack.
- If the app already has one of the three steps, it logs which and
returns `error.PackageStepTaken`.
- The installers go into the app's `zig-out/<channel>`, once, through
`fizzy_dep.namedLazyPath("package")`. That directory is vpk's output; on
a target that can't be packaged, it is a directory whose step fails with
fizzy's message.
- Fizzy's own `package` step is not run for an app: as a dependency, it
would install into `.zig-cache/i/<hash>`.
- The doc says the MSVC options reach fizzy only if the app declares and
forwards them.

## SDK impact

- [x] None

## Verified

These pass on macOS, run one at a time under the machine's load:
`scripts/check-fmt.sh`, `zig build`, `zig build test` (591/592, 1
skipped, now including `fizzy-bundle-id-tests`), `zig build
test-integration` (54/54 steps), `zig build check-web`, `zig build
test-sdk-version`, `zig build check-tests-reached`, and `EXAMPLES_JOBS=1
scripts/check-examples.sh` (all eight builds).

The `vpk` argv was checked by running `zig build package` with a stub
`dotnet` that prints the `vpk` arguments and writes a fake installer:

| Build | vpk got |
|---|---|
| fizzy | `--packId fizzy --packVersion 0.2.0 --mainExe fizzy
--packTitle fizzy --plist assets/macos/info.plist --icon
assets/macos/fizzy.icns` (unchanged values) |
| fizzy `-Dapp-name=myapp` | `--packId myapp --mainExe myapp --packTitle
myapp --bundleId com.foxnne.fizzy` |
| fizzy, run from `src/` with
`-Dapp-icon-icns=../assets/macos/fizzy.icns` | `--bundleId
com.foxnne.fizzy --icon ./../assets/macos/fizzy.icns` (relative path
resolved correctly) |
| example-app as `minimal_app`, display `Minimal "App" \ Ü<TAB>`,
`app_version` `v1.2.3+build`, macOS | `--packId minimal_app
--packVersion v1.2.3+build --mainExe minimal_app --bundleId
dev.fizzy.minimal-app`; fake installer in `zig-out/arm64-macos/` only |
| the same app, `desktop -Dtarget=x86_64-linux-gnu` | `--packId
studio_app --mainExe studio_app --packTitle "Studio App" --icon
<app>/icon.png` |
| the same app, `package -Dtarget=x86_64-windows-gnu` | fails through
the app's step with fizzy's "requires the MSVC ABI" message |

More checks:

- **`packageall` from `docs/`:** with `-Dapp-icon=../assets/icon.png`,
the first run was `zig build package -Dtarget=x86_64-windows-msvc
-Dapp-icon=../assets/icon.png --build-file <root>/build.zig`, with the
cwd inherited.
- **Windows resources:** the same app built for `x86_64-windows-gnu`.
`minimal_app.exe` holds `Minimal "App" \ Ü` (the tab dropped),
`v1.2.3+build` and `minimal_app.exe`, with FILEVERSION 1,2,3,0, and no
`fizzy` or `foxnne`.

Platforms:

- [x] macOS: the gates and dry runs above. No real `vpk pack` was run.
- [ ] Windows: the gnu cross build and resource check above. No MSVC
package was built.
- [ ] Linux: argv only, from the cross dry run above.
- [x] Web: `check-web`.

## Follow-ups

- The web favicon is served by the site (`web/index.html` points at
`/img/icon.png`), so it stays fizzy's.
- `assets/macos/Fizzy.entitlements` has no option for an app's own.
- `.velopack-msvc/` resolves against fizzy's build root, so for an app
it lands in fizzy's package directory under `zig-pkg`. This was already
so before this change.
- `fizzyedit/example-app` could adopt `addPackageSteps`, `app_version`
and its own icons, in a companion PR there.
- `zig build packageall --release=fast` panics in a jj workspace with no
`.git`, because nightwatch's build runs `git describe`. This was already
so before this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#27 (one glass material). This only moves the
pills onto core's shared glass call; the material itself is that
issue's.

## What changes

The key and command pills that a demo or a working agent shows (and the
"Catching up…" seek pill) are now the same glass as the player's
transport bar. Before, they were a frosted text pane (`frostPane`, which
`LiquidField` draws with the text look: more of the window's colour, a
hairline edge). Their keycaps were also solid control-fill boxes, so the
glass showed only as a thin rim around a dark slab, which read as
opaque, especially over a dialog.

- `app/automation/overlay.zig`: `pillGlass` draws a pill through
`core.dialogs.glassBubbles`, the call the transport bar uses. It grows
out of its origin as before, and the shadow fades with it. `glass` and
`pillGlass` share `grown`.
- `core/keycaps.zig`: `DrawOptions.cap_fill`, the colour of a `.caps`
keycap; the control fill when unset, so the command palette and keybind
settings are unchanged. The pills use the text colour at 10%, so the
glass reads through the caps.

Caption cards keep `frostPane`: `glassBubbles` draws capsules only, and
a card several lines tall can't be one. Under plans#27's single material
they join through the shared card call.

Tried and dropped: drawing the pills as the OS's Liquid Glass
(`native_glass.add`, as `carriedCard` does). The in-app glass is what
was wanted.

## SDK impact

- [ ] None
- [x] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

`test-sdk-version` passes: the fingerprint did not move.

## Verified

- [x] macOS: `FIZZY_DEMO=tour` in a sandbox profile, window screenshots
through the run. The ⏎ pill is a capsule with the bar bubbles' edge and
a faint cap. Not seen over a dialog. `zig build`, `zig build test`, `zig
build test-integration` (53/53 steps), `zig build check-web`, `zig build
test-sdk-version`, `scripts/check-fmt.sh`.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds (`check-web`); not looked at.

## Follow-ups

- Caption cards onto the same material, through plans#27's shared
glass-card call.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #524 (the hover look; the hand cursor and touch hover move to
#547)

## What changes

Panel tabs, rail icons and document tabs now show a hover look. All
colours come from the theme, and nothing allocates per frame.

- **Panel tab labels:** `Chooser.label` takes `hovered` (wired in
`Layout.tabsIn` from `Item.hovered()`). An unselected label brightens
from `control.text` to `window.text`, as rail icons already did.
- **Rail icons:** `Chooser.icon` takes the item's `WidgetData` (from
`Item.data`). An unselected hovered icon sits on the row hover wash
(`core.dialogs.rowHover()`) over the cell's border rect, rounded with
`corners.row`.
- **Document tabs:** an inactive tab under the pointer is filled with
`window.fill_hover` at the content opacity. The colour comes from this
frame's hover and is passed to the tab's own `borderAndBackground(.{
.fill_color })`, so nothing is stacked and the tab's options aren't
touched. The close button and dirty dot behave as before.

**`core.widgets.hovered`** (also used by `Menu.zig`) changes in two
ways:
- It now decides from where the pointer ends the frame: dvui's single
`.position` event, which is always the frame's last. Before, it used the
first mouse event that matched.
- It is false while the widget holds the pointer itself (pressed, or
dragged along its strip), so a tab dragged across its strip doesn't stay
lit.

It still checks the widget's border rect. Hover is computed lazily,
where it's read (`Item.hovered()`, the document tab).

## SDK impact

- [ ] None
- [x] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

The fingerprint didn't move (`zig build test-sdk-version` passes).

## Verified

- [x] macOS:
- **Gates:** `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig
build test-integration` (53/53 steps, 561/561), `zig build check-web`,
`zig build test-sdk-version`, `zig build check-tests-reached`.
- **Sandbox fizzy** (`--profile`), driven by a demo tape and captured
with the agent plugin's `fizzy_capture` (display asleep):
- a hovered inactive document tab measures (36,37,45), against
(23,24,30) at rest;
- the hovered Plugins rail cell shows the (62,64,78) wash and is clear
at rest.
- **Not seen:** the panel-tab label hover, because that layout had no
strip with several views.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds (`check-web`), not seen.

## Follow-ups

Tracked in #547:
- **Hand cursor:** it needs a cursor-priority seam. `dvui.cursorSet` is
first-wins, and a split is drawn after the region before it, so a tab
asking for `.hand` would steal a neighbouring split's resize cursor near
the sash.
- **Touch hover:** it needs a pointer-kind seam. dvui leaves its pointer
where a touch lifted, so a tapped item can stay lit, and on native SDL
reports a trackpad's touches as fingers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ctures (#500)

Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), steps 5 and 6:
the first of two PRs that make export an extension of the scrub bar.
This one grows the bar into a strip of pictures while you scrub. The
next adds an export button to the strip that opens a trim window dragged
from both sides, and exports what the window holds.

## What changes

**The strip.** Scrubbing grows the transport upward into a strip of the
document's history as pictures, the way a video's scrubber shows frames:
- **What scrubbing is:** a drag on the line, or an undo or redo held
down (a second repeat).
- **How long it stays:** the strip lingers like the bar does, and stays
while the pointer is on the bar.
- **The tiles:** each tile stands for a stretch of the history and shows
the picture nearest its middle. The tile under the knob is outlined.
- **Pressing a picture** goes to that point, as a press on the line
does.
- **Gaps:** a stretch with no picture stays plain glass until a scrub
passes over it.
- **Its corners:** the line's bubble is a full capsule round the line
alone, and eases to the card radius (`core.corners.card`, at the
person's roundness) as it grows, so the pictures sit inside its corners.
`core.dialogs.Bubble` gains an optional `radius` for it; without one a
bubble is a capsule as before.

**The pictures** (`app/HistoryFrames.zig`, one per document pane):
- **What a picture is:** the pane as it was drawn, cropped above the bar
as it stands (its strip included, so no picture holds the strip), read
from the frame the app already keeps in a texture
(`FrameTarget.snapshot`, which now takes an output size and scales on
the GPU).
- **When one is taken:** the frame after the document's track moves (a
step recorded, undone, redone, or scrubbed to), from the frame that
first drew the move. The move is seen after the document draws, so an
edit made in or before its draw is in that frame, and a scrub's jump
(made after the document drew) is seen, and drawn, the frame after. A
drag across the line takes a picture at each stop it lands on.
- **No plugin involvement:** pictures are keyed by the history entry on
top, so any owner's documents have them, with no SDK change.
- **Limits:** at most 64 a document (192×96 each, under 5 MB), the
oldest taken going first. They're freed with the pane.
- **Only while recording:** a document without a track has no strip, and
the bar is as before.

**Measured** in a running Debug fizzy, profiled over 5 s while scrubbing
back and forth: `history.frame` averaged 0.05 ms where it ran, 0.44 ms
at most, on about one frame in ten. Nothing runs on a frame where the
history did not move.

## Review fixes

- **No strip inside pictures.** A picture was cropped a fixed 76 px
above the pane's bottom, so pictures taken while the strip stood (about
120 px) held the strip; it is now cropped at the bar's top.
- **Pictures fill in while scrubbing.** A two-frame wait was reset by
every move, so a continuous drag took no pictures until it stopped; it
was not needed (see "When one is taken") and is gone.
- **The tile search stops at its stretch's ends.** It ran on to the
whole history per tile while one end was passed.
- **The memory note was wrong** (96 at 240×120 is 11 MB): now 64 at
192×96, under 5 MB.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
check-web`, `zig build test-sdk-version` and `zig build
check-tests-reached` pass.
  - `zig build test-integration`: 53/53 steps, 560/560 tests.
- **In a running fizzy** (its own profile, tracks on, driven through the
agent plugin's bridge, seen through fizzy's frame capture): after a few
edits, a drag back and forth on `history.line` grew the bar into a strip
of pictures of the document at its steps, the current one outlined, and
the strip went back down a few seconds after. After the review fixes, a
1.5 s drag across five fresh steps filled every tile, and none of the
pictures contains the strip.
- **Not covered by a test:** the headless test backend has no render
targets, so no pictures are taken there; the strip is seen, not tested.
- [ ] Windows: not run.
- [ ] Linux: CI.
- [ ] Web: `check-web` builds. On the web the frame is kept in a texture
only when something asks (`FrameTarget.want`), which a pending picture
does.

## Follow-ups

- **Export from the strip** (next PR): an export button on the strip
opens a trim window over it, dragged from both sides. The export holds
what you scrubbed: the document at the window's left edge, then each
history step to its right edge. It reuses #497's yellow bracket and trim
rules.
- **Pictures for steps never seen while open** (from before the session,
or skipped by a fast scrub) stay plain. An owner-drawn preview hook
could fill them; not needed yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6. Stacked
on #500 (the strip): export becomes part of the scrub bar. This replaces
#497's trim in a tape's own tab.

## What changes

**An export button comes with the strip.** When scrubbing grows the bar
into its strip (#500), a share bubble grows in at the bar's end. It
shows only for a recorded document, from where its track began on.

**Pressed, it opens a window over the strip**, as macOS trims a
recording:
- **The bracket:** a yellow bracket over the part to export, at first
the whole history the track holds, with a handle at each side. The strip
and line outside it are dimmed.
- **Dragging a side** takes the document to that point (`historyGoTo`,
one jump a frame, as scrubbing does), so the person sees where the part
begins and ends. A press inside the window moves the playhead within it.
- **The summary:** over the line, in words: *2 steps, 12 s, by you and
Claude* (`Tracks.lineSummary`).
- **Buttons:** undo's button becomes × and closes the window; the export
bubble becomes a yellow ✓ and exports. Redo is off while the window is
open.
- **Sides are clamped:** neither can go before the track's start
(`export_from`), and the window always holds at least one step
(`history_transport.Trim`, the rules from #497).
- **Closes itself:** the window closes when the history changes under
it.

**What's exported is what the strip shows** (`Tracks.exportLine`):
- **The keyframe:** the document's file at the window's left stop. It's
written by its owner with the document taken there and brought back in
the same call (`fileAt`, now shared with `exportTape`).
- **The steps:** the recorded ones that made each entry from there to
the right stop (the step that added it, and those that joined it), in
the line's order and timed as they came. Undos and redos taken between
are left out, since the strip doesn't show them. An entry made before
the track began refuses (`NotRecorded`).
- **Where it goes:** the tape is kept in `tapes/` and noted in glass on
the document. The person's document and its history end where they were.

**Removed:** the tab trim from #497 (`TrimBar`, `TapeTabs`' trim mode,
`Editor.shareDocument`). A tape tab plays a tape again, as in #485.
`fizzy.tracks.export { from, to }` still exports a range of the raw
track.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
check-web`, `zig build test-sdk-version` and `zig build
check-tests-reached` pass.
  - `zig build test-integration`: 53/53 steps, 560/560 tests.
- **Integration:** a document of five entries with the last undone,
exported from stop 2 to stop 5. It checks:
    - `lineSummary` says 3 steps;
- exactly one tape is kept, whose keyframe is the document after two
entries (`"two\none\n"`) and which holds 3 steps, the undone entry's
included;
    - the document's text and history position are unchanged.
- **In a running fizzy** (its own profile, tracks on, driven through the
agent plugin's bridge, seen through fizzy's frame capture):
    - a scrub grew the strip and the export bubble;
- pressing it opened the yellow window over the whole history ("5
steps…");
- dragging the left side to 60% took the document there and the summary
to "2 steps";
- ✓ wrote one tape, noted "Saved notes-….tape" in glass, and closed the
window.
- **A view moving panes:** during two earlier runs the document's tab
moved to the other pane. It didn't reproduce when opening the window and
pressing ✓ by themselves. Both times came right after I rebuilt, which
restarts the sandbox and restores its layout, so the restart looks like
the cause rather than this change.
- [ ] Windows: not run.
- [ ] Linux: CI.
- [ ] Web: `check-web` builds. Not run in a browser.

## Follow-ups

- **The window's summary** rescans the track each frame while the window
is open (`lineSteps`): nothing at today's sizes; cache it on the window,
keyed by its stops and the track's step count, if long tracks show it.

- **Previewing the window** with play, as macOS has: the window's steps
played over the document from its left side. The redo bubble is the
natural place.
- **Real-time pauses** (kept by `fizzy.tracks.export { real_time }`)
have no control on the bar.
- **Steps before the track began** can't be exported: their effects were
never recorded. Once tracks outlive the document (plan step 10), that
reaches back further.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while working on #514: why the web app sometimes takes a long time
to load.

## Why

`web/index.html` starts the editor only after two animation frames
(`waitForLayout`, so the canvas has its size), and a page that is not
shown gets none. Measured in a hidden page: 0 animation frames in 5 s,
the loading screen up the whole time. So a tab opened in the background,
or a page in a window that is covered or on another Space, does not even
begin downloading the editor until someone looks at it. Then it
downloads: `fizzyed.it/app/web.wasm` is 3.4 MB gzipped, and it came at
80–130 KB/s in three tries (26–42 s; see "Not this change").

## What changes

In worker mode (Chromium), the page fetches and compiles `web.wasm` as
soon as its script runs (`WebAssembly.compileStreaming`), shown or not:
- `startWorker` posts the compiled module with the worker's `init`,
after waiting for the compile, which has usually finished by then.
- The worker instantiates the module it was handed
(`WebAssembly.instantiate`) and fetches nothing. Its old path (fetch and
`instantiateStreaming`) stays for when there is no module: a failed
early compile logs a warning and falls back to it.
- **Nothing downloads twice.** A plain preload or early `fetch` would
warm the cache, but a page shown at once starts its worker a frame
later, while the first download is still running, and fetches the wasm
again. Handing over the module avoids that.
- **What's left to the frame gate** is creating the worker and
instantiating, both quick.

The main-thread path (Safari, Firefox) loads through dvui's `web.js`,
which fetches the wasm itself; it is unchanged here.

## Not this change

The download itself is slow because Cloudflare does not cache `.wasm`:
`cf-cache-status: DYNAMIC` on every request to
`fizzyed.it/app/web.wasm`, so every load goes back to GitHub Pages at
about 100 KB/s, while the same machine reads 31 MB/s from Cloudflare. A
Cloudflare cache rule ("eligible for cache", long edge and browser TTL)
for `/app/*.wasm` and plugin wasm fixes that, and every URL here carries
a cache-buster, so long TTLs are safe. It is a setting in the Cloudflare
dashboard, not code.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] `scripts/check-fmt.sh`, `zig build`, `zig build check-web`; `zig
build test-integration` 53/53 steps (nothing here is in it).
- [x] **Shown at once** (a headless Chrome driven over the DevTools
protocol, fresh profile): the editor came up ("startup: shown 134 ms
after main", loading screen gone), and the local server logged
`web.wasm` fetched once, before the worker script.
- [x] **Not shown** (the desktop app's browser pane, behind the chat,
`visibilityState: "hidden"`): the page fetched `web.wasm` while hidden
(the Resource Timing entry is there).
- [ ] Firefox and Safari: unchanged path, not run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 7: "web:
play a tape from a link. `?tape=<url>`, loading the tape's owners from
the store, the player shape; the site embeds one."

## What changes

**`?tape=<url>`** on the web app fetches the tape (the page and its
worker both read it) and hands it to fizzy (`FizzyWebPlayTape`, listed
in `build/web.zig`'s exports).

**The tape waits for its plugins** (`TapeTabs.openWhenReady`):
- **What it needs:** it reads which plugins its documents belong to
(each keyframe file's `owner`), with a check that accepts any owner,
since whether they can apply its steps is asked once they are loaded.
- **Missing ones on the web** are asked of the store each frame until
they arrive, up to a minute. The store's catalog is fetched first if
nothing has (`PluginStore.wantCatalog`): on a page that never opened the
store, nothing had, so an install by id silently did nothing.
- **Loaded for the visit, not installed** (`PluginStore.loadForVisit`,
`PluginManager.loadFromUrl`): the build is linked as an install would
be, but not remembered for the next visit and nothing is asked. Before
this, the first-install "File types" dialog opened over the tape.
Someone who wants the plugin installs it from the store as ever.
- **Then it plays:** in a tab of its own (one document's tape), or on
the demo stage (anything else, as `?demo=` does). It plays with nothing
else in view (`show_only_document`, as `?open=…&show=` does), so the
page is the tape.
- **On the desktop**, `Demo: Play a Tape…` opens a tape the same way.
With a plugin missing it says which ("This tape needs the pixi plugin")
instead of opening a document no owner can step. Nothing is fetched
natively.
- The tape is looked at from the next frame's top, never inside the
page's call: the page can call between frames, where dvui's frame state
is not there.

**The player shape:** the plan's "one view and the transport, nothing
else" is the existing show-only-the-document state, so no new layout
shape is needed. A copyable shape for apps built on fizzy stays a
follow-up.

**A trap written down** (`web/AGENTS.md`): a new `export fn` the page
calls must be listed in `build/web.zig`'s `export_symbol_names`.
Unlisted, it compiles and its name even appears in the binary, but the
page sees `exports.FizzyWebX` undefined and its guard returns without a
word. This cost most of the debugging here.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
check-web`, `zig build test-sdk-version` and `zig build
check-tests-reached` pass.
  - `zig build test-integration`: 53/53 steps, 561/561 tests.
- **Integration:** the tab player's test now opens its tape through
`openWhenReady`, played the next frame. The same tape re-encoded to name
a plugin that isn't loaded opens nothing and stops waiting.
- [x] **Web, in Chrome** (a headless Chrome driven over the DevTools
protocol, its page and worker consoles read, screenshots taken), against
`zig build web` served locally:
- `?tape=tapes/notes.tape`, a text tape exported from the strip: the
page opened only the tape's tab, played it to its end, and the tab was
clean.
- `?tape=tapes/pixi.tape`, three pencil strokes recorded in pixi on the
desktop and exported (744 bytes), on a fresh profile with no plugins
(`-Doptimize=ReleaseSmall`, to match the store's builds): pixi v0.1.47's
web build loaded from the store at 3.1 s and the strokes played into the
sprite. No dialog, and nothing in `localStorage` afterwards.
- [ ] Windows, Linux: CI.

## Seen while testing, not this change

- **The web app's loading can take a minute.** `fizzyed.it/app/web.wasm`
(3.4 MB gzipped) came at 80–130 KB/s in three tries (26–42 s), while the
same machine reads 31 MB/s from Cloudflare. Cloudflare answers
`cf-cache-status: DYNAMIC` for `.wasm`, so it never caches it and every
load goes back to GitHub Pages. A Cloudflare cache rule for
`/app/*.wasm` (and plugin wasm) fixes it; that is a dashboard setting.
`max-age=600` on files whose URLs carry a cache-buster could be far
longer too.
- **A page opened unseen does not start loading.** `waitForLayout` waits
for two animation frames, and a hidden or background page gets none (0
in 5 s, measured), so the editor's download only begins once the tab is
shown. Starting the wasm and worker fetch at once would take that off
the critical path: a small `web/index.html` change, for its own PR.

## Follow-ups

- **The site's embed:** fizzyed.it showing a tape from `?tape=` (the
website repo).
- **A copyable player shape** for apps built on fizzy.
- **Fit to View** for a played pixi tape (#444).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…oad (#536)

Part of #518

## What changes

An app's plugin had no way to take over one of fizzy's commands
(`fizzy.about`, `fizzy.reportBug`). Fizzy registers its commands first,
`Host.registerCommand` appended a duplicate id, and `Host.command`
returned the first match. This is the SDK half of #518; the About/Help
half is #533.

**The model.** Each id has a `CommandSlot` holding:
- **bases**: registrations without `replaces`. The first is the base.
- Another plugin's later base is ignored with a warning naming both
owners, and kept in reserve in order.
- The same plugin registering its own again updates its entry in place,
with no warning.
- **overrides**: registrations with `Command.replaces = true`, oldest
first, at most one per owner. Re-registering moves an owner's override
to the top.

The **active** command is the newest override, else the base.
`commands`, the list the palette, routes and keybind settings walk,
holds one active command per id, in first-registration order.

- **A base with none before it becomes the base**, beneath any
overrides. That covers an override registered before its base's plugin
loads, and a reload under an override.
- **Unloading a plugin** removes its bases and overrides, and the next
registration is active again. A reserve base takes over when the first
base's plugin goes. An id with nothing left leaves `commands`.
- **Reaching what was replaced**: `Host.callReplaced(caller, id, args,
results)` runs the next entry below `caller`'s override (the next older
override, else the base), the way `callCommand` would.
- **Recursion**: while an **override** of an id is running, or a
registration it reached through `callReplaced`, running that id by id
from inside is refused. `callCommand` returns `.failed`; `runCommand`
returns `error.CommandRanItself` and logs a warning.
  - The count is per slot (`overriding`), kept by `running`/`ran`.
- A plain command with no override running its own id works as on
`main`.
- **Lookup without scanning**: `Host.command_slots`
(`StringHashMapUnmanaged` via `getPtr`, keys are host-owned copies) maps
an id to its slot and the active command's index.
- `command(id)` now returns `?*const Command`: it points into the
mirrored `commands`, so writing through it would be lost. No caller in
this repo writes through it.
- For a new id, everything that can fail happens before the map or
`commands` changes.
- **`running_command` is a copy** (`?Command`), and
`runCommand`/`callCommand` run a copy of the entry.
- **Claims**: a command is held on a claimed active document when
either:
- (a) its owner is the document's owner, which is `main`'s rule,
unchanged; or
- (b) it is named for the document's owner. `commandNamespace` gives the
loaded plugin whose id is the longest prefix of the command id followed
by `.`, else the first segment for a plugin not loaded yet.
- That holds an override of `pixi.undo` even when it was registered
before pixi's own, and handles dotted plugin ids (`acme.paint.undo`
belongs to `acme.paint`, not `acme`). There is no base lookup.
- Pixi overriding `fizzy.about` is held on a claimed pixi document
again, by rule (a), as any pixi command is on `main`.
- **New File is out of scope for now**: overriding a `<plugin>.new*`
command (detected through the same `commandNamespace`) is refused with a
warning. The chooser and `dispatchNewDocument` are as on `main`.
- **Plugin info** (`pluginList`) counts each plugin's registrations
(bases, reserve, overrides) in one pass over `command_slots`.
- `docs/PLUGINS.md` §3.4 documents:
- a duplicate id without `replaces` is ignored (kept in reserve), while
a plugin re-registering its own updates in place;
  - overrides, `callReplaced` and the recursion refusal;
  - claims;
  - the New File limit.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(0xcd68cc1a32bd51c9, combined with #540's and #544's moves after
rebasing onto `main`), `sdk_version` untouched, PR labelled `sdk`.
- The move comes from the new `Command.replaces`, `Host.command_slots`,
and `Host.running_command` becoming a value.
- `command()` now returns `?*const Command`; that did not move the
fingerprint. A plugin that wrote through `host.command(id)` would stop
compiling. None does:
    - No caller in this repo does.
- `scripts/check-examples.sh` (example-plugin and every example-app
shape) builds against this tree.
- Local checkouts of pixi, atlas, zig, ghostty, drive, agent and chat
were grepped: only chat reads it, for null checks (`src/Explorer.zig`,
`src/Commands.zig`).
    - So no repin PR needs a change for it.
  - For plugin authors:
- a duplicate command id without `replaces` is ignored (in reserve) with
a warning;
    - `replaces` overrides a command;
    - `callReplaced` reaches what an override replaced.
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
  - Rebased onto `main` (#533, #540, #544, #537).
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (105/105 steps),
`zig build check-tests-reached`, `zig build test-sdk-version` and
`EXAMPLES_JOBS=1 scripts/check-examples.sh` (all 8 builds ok) pass.
- `zig build test-integration` passes (54/54 steps; fizzy-sdk-tests
70/70). Its "failed command:" line is the existing automation
demo-replay warning.
  - Tests cover:
- an override before its base, and a second base ignored while an
override stands;
    - the same owner re-overriding (one entry, moved to the top);
- a plugin re-registering its own base (updated in place, no reserve
copy);
- three deep with the middle one unloaded, and a reload under an
override;
    - a reserve base taking over;
    - the id leaving with its last registration;
- `callReplaced` falling through, with `callCommand(id)` refused from
the override and from the base it reached;
    - a plain command running itself three deep;
- `runCommand` from inside an override returning
`error.CommandRanItself`;
- claims: an override held when registered before pixi's own and before
pixi loads, another plugin's command not held, and a dotted `acme.paint`
id held for `acme.paint` but not `acme`;
    - a New File override refused.
- Tests that warn on purpose lower `testing.log_level` and restore it
with `defer`.
- [ ] Windows:
- [ ] Linux:
- [x] Web: `zig build check-web` passes.

## Follow-ups

- Overriding a New File command (`<plugin>.new*`): which plugin the
chooser lists it under, and who receives the new document when the
override wraps the plugin's flow.
- `runReplaced` for an override that runs without arguments, if one is
wanted. `callReplaced` with empty args covers it today.
- Help's About label (static since #533) should follow an override's
title. It needs a null-terminated title for the native menu, so it is
left out of this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 3. Follows
#499 (merged), with `main` merged in.

## What changes

`GET /openapi.json` on the control socket answers an OpenAPI 3.1
description of the server, generated per request from what is registered
at that moment. Plugins loading and reloading change it, so a client or
code generator always sees the app as it is.

```sh
curl --unix-socket ~/fizzy-profile/control.sock localhost/openapi.json
```

- **A path per command, saying what the server enforces and no more.**
Each `/commands/{id}` (the id percent-escaped) has a `post`:
- **Declared parameters:** the body is a JSON Schema of exactly those,
with `additionalProperties: false`, the required ones listed, and each
parameter's type: `boolean`, `integer`, `number`, `string`, or `string`
+ `enum` for an enumeration, the one constraint `Command.Params` checks.
- **Hints, not limits:** a number's declared bounds and suggested
choices are only hints the server never checks, so they go in the
parameter's description rather than its schema. Otherwise a generated
client would refuse values the server takes.
- **Hand-written `runWith`** (as the chat plugin's commands are): any
object.
  - **Only `run`:** no body.
  - **Defaults** are included when they convert to JSON.
- `operationId` is the command id, and `tags` names the owning plugin.
Every command shares the error responses (400/404/409/500) and one
`Error` schema. A repeated id gets one path, the one the server runs.
- **`/events`** lists every declared event (`Host.declareEvent`) in its
description and in `x-fizzy-events`, each with its example payload
converted to JSON.
- **Results are untyped (any JSON).** No command declares a result type
yet. The seam is a result type beside `Command.Params(…)` that
`openapi.commandPath` would read.

Live: fizzy's own spec is 55 KB compact (56 paths, the 9 declared
events, unique `operationId`s), and a fetch round-trips in 3–11 ms in
Debug. `HEAD` doesn't generate it.

### Review

An independent review pass found 8 issues. Fixed, each covered by the
test:
- **Invalid document:** the any-value schemas were written as `[]`,
which isn't a schema. Every validator would reject the whole document.
- **Chat's commands looked argument-less:** declaring no params, they
were described as taking only `{}`.
- **Limits the server doesn't have:** number bounds and suggested
choices in the schema would make clients refuse values the server
accepts.
- **Invalid JSON:** an infinite bound would have produced invalid JSON
(now moot, since no bounds are written).
- **Repeats and collisions:** duplicate ids repeated a path key, and the
fixed operationIds could collide with command ids (now under
`control.`).
- **Unescaped path keys.**
- **Cost:** generation now writes compact output and skips `HEAD`.
- **Gaps:** `/openapi.json` wasn't described.

### Changed from the plan

- **No committed snapshot of fizzy's spec.** It would fail CI on every
edit to any command's description, and the live endpoint is always
current. Step 4's `docs/CONTROL.md` will point at it.
- **No external validator in CI.** The test checks the structure, and
that every `$ref` resolves. I didn't run a third-party OpenAPI
validator, since none is installed here and fetching one is a download.
A CI job running one is a cheap follow-up if you want it.

## SDK impact

- [x] None.

## Verified

- [x] macOS: an integration test over a `Host` with registered commands
and a declared event checks:
  - the paths;
- the body schema per parameter kind (types, bounds, enum choices,
defaults, the required list);
  - every error status;
  - an optional body for a command with no required parameters;
  - the event's JSON example;
  - that every `$ref` resolves.

Live, I fetched the spec from a running fizzy and inspected it. Gates:
fmt, build, `test`, `test-integration`, `check-web`, `test-sdk-version`,
`check-tests-reached`.
- [x] Windows: the x86_64 cross-compile links. Nothing here is
platform-specific.
- [ ] Linux: CI
- [x] Web: `check-web`; nothing is added there.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes

`main` doesn't compile, on native or the web: `src/editor/TapeTabs.zig`
uses `app.store` (from #514, the tape link's wait for its plugins), but
the file only names `@import("app").automation`. Each PR passed on its
own, and the two landed together. This names `app` once and takes
`automation` from it.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS: `zig build` and `zig build check-web` compile again, and
`scripts/check-fmt.sh` passes.
- [ ] Windows:
- [ ] Linux:
- [x] Web: `check-web`

## Follow-ups

None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 4. **Stacked on
#508** (base `app/control-openapi`). Retarget to `main`
once #499 and #508 merge.

## What changes

- **On by default** on every native platform. Settings > Control server
(`control_server` in `settings.zon`) turns it off and on, followed live
once a frame like *Restart when rebuilt*. `FIZZY_CONTROL` is gone. A
server that can't start logs why and isn't retried until the setting
goes off and on. It never starts in tests.
- **A private folder.** The socket is `<config>/control/control.sock`,
and `start` makes the folder `0700`. That closes the one finding
deferred from #490's review: with a group-writable umask, the socket was
briefly group-reachable between `listen` and its `chmod 0600`. The
server test checks both modes.
- **`fizzy --control-path`** prints the socket's path and exits, before
the single-instance lock would hand the launch to a running fizzy. It
resolves `--profile` and `FIZZY_PROFILE` with the same code a launch
uses (`singleton.resolveArgs`), so `curl --unix-socket "$(fizzy
--control-path)" …` reaches the fizzy started the same way.
- **`docs/CONTROL.md`**: where it listens and who can reach it, the API
and its errors, events, the OpenAPI description, and what's still to
come. Linked from `AGENTS.md`.

## Review

An independent review pass found 8 issues. Fixed:
- **Crash:** std's `UnixAddress.max_len` is 108 on macOS, whose
`sockaddr_un` holds 104, so a 105–108-byte socket path panicked in
`listen`. With the server now on by default and the new `control/`
folder, a deep `--profile` was enough to hit it. Paths are now checked
against the real size (`Server.max_path`). A config folder too deep
listens in a private folder under `$TMPDIR` (else `$XDG_RUNTIME_DIR`,
else `/tmp`), named by a hash of the config folder, and `--control-path`
prints that same path. Live: an 86-byte profile ran, listened there
(`0700`/`0600`) and answered curl. Written up as a pitfall in
`app/AGENTS.md`.
- **Latch never reset:** a failed start stuck until restart, because
turning the setting off never cleared it.
- **Failed `--control-path`:** if it failed, it fell through into a full
launch. It now always exits, with 1 and the reason when it can't print.
- **Folder chmod followed a symlink:** a link in the folder's place is
now refused.
- **Socket file left behind** when the server failed after `listen`.
- **Fallback mismatch:** `--control-path` now uses the editor's fallback
folder (the executable's).
- **Docs vs code:** `docs/CONTROL.md` now lists the plain-text statuses
(400/413/417/503) apart from the JSON ones, adds 405, and documents the
long-path fallback and Windows capture. The 503 now reads "the control
server is stopping", since turning the setting off sends it too.
- **Windows console (suspect, not changed):** a Windows release is a GUI
program with no console, so `--control-path` must be captured there
(`$sock = fizzy --control-path`). The doc says so. Untested on Windows.

## SDK impact

- [x] None.

## Verified

- [x] macOS:
- **Default on:** a plain `fizzy --profile` run listens at
`control/control.sock`, the folder `drwx------` and the socket
`srw-------`.
- **Live toggle:** writing `.control_server = false` to `settings.zon`
removed the socket live, and `true` brought it back, answering curl.
- **`--control-path`** prints the right path for the default config
folder, for `--profile`, and for a relative `FIZZY_PROFILE` (made
absolute against the cwd). It starts no fizzy.
- **Gates:** fmt, build, `test` (589/590, the skip is pre-existing),
`test-integration` (53/53 steps), `check-web`, `test-sdk-version`,
`check-tests-reached`.
- **Not checked by eye:** the new Settings row. It's the same
`drawToggle` row as its neighbours.
- [x] Windows: the x86_64 cross-compile links. The folder's mode isn't
set there, since the config folder is under `%LOCALAPPDATA%`, which only
the user can open.
- [ ] Linux: CI
- [x] Web: `check-web`; the setting does nothing there.

## Follow-ups

- Step 5: MCP on the same socket, sessions, and the agent plugin's tools
as `agent.*` commands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
build/package.zig takes main's restructure (#537) with the 0.17 port reapplied: vpk through velopack.addVpkRun, the pack directory through installDirPath, paths through b.root, release_mode on b.graph, user options read as 0.17's map, and pathFromRoot gone. build/app.zig's per-app VERSION read is declared to the configure cache. rewrite.sh re-run; three tests' string repeats in app/control are splatted arrays. dvui-dev moves to zig-0.17 at 8fa9b9c, with main's four fizzy-dev render-order commits ported (#540, #544). The SDK shape fingerprint is re-recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 5 (5a). Follows
#509 (merged), with `main` merged in: the later answers
are now built into `main`'s command slots (`callResolved`, which
overrides and `callReplaced` go through).

## What changes

**A command may answer in a later frame.** This is for work that
outlives the frame: a language server's reply, a build, a picture taken
at the end of the next frame.

```zig
fn format(state: *anyopaque, args: Args, call: *sdk.Command.Call) anyerror!void {
    const self: *Plugin = @ptrCast(@aligncast(state));
    self.pending = call.later();          // keep the ticket, return with no result
    self.lsp.requestFormat(args.path);
}
// …frames later, on the UI thread:
host.finishCall(self.pending, .{ .ok = result_zon });   // or .failed = "why"
```

- **Callers that can wait:** `Host.callCommandAwaiting(id, args, arena,
sink)` answers `.now` (what came of it) or `.later` (the ticket), and
calls `sink.done` once with the outcome when it finishes.
- **Callers that can't:** plain `callCommand`, menus, keybinds and the
palette are unchanged. A deferred command counts as started, and its
answer is dropped. None of the ~36 existing call sites changed.
- **Letting go:** `callWanted(ticket)` lets a command stop work nobody
wants, and `abandonCall(ticket)` lets a caller stop waiting.
- **No waiter hangs:** a plugin unloaded or withdrawn while it owes
answers fails them, so every waiter hears something.
- **Non-agent consumers:** any long command over the control server, and
in this PR fizzy's own **`fizzy.screen`** (what's on screen as data:
windows, widgets, roles, names, tags, rects, from the automation
service's snapshot, taken over the next frame) and **`fizzy.settled`**
(`true` once nothing is in flight, `false` at `timeout_ms`). Both are
`internal`, for scripts and tests: `curl … /commands/fizzy.screen`.
They're the first half of the plan's step 5a, the automation service as
commands.
- **The control server holds the request open** until its command
answers (`routes.State.waiters`). It keeps the requests it has taken but
not answered (`Server.outstanding`), so `stop` answers those with 503
too, after letting go of their calls.
- **Docs:** `docs/PLUGINS.md` §3.4 ("Answering later") and
`docs/CONTROL.md`.

## Review

An independent review pass found 10 issues. Fixed, with tests where they
can have one:
- **High: tickets mixed up between nested calls.** A command that called
another that defers shared that command's ticket, and the caller was
handed the wrong one, which could end in use-after-free after a stop.
Each `runWith` now takes its ticket before it runs. Tested: nested calls
keep distinct tickets, and each answer reaches only its own waiter.
- **High: deadlock.** `answerStream`'s out-of-memory path re-took the
server lock.
- **High: early answers lost.** An answer given inside `runWith` (a
cache hit) was dropped, leaving the caller waiting for good. It now
reaches the caller at once. Tested.
- **Leak:** a call nobody waits for no longer keeps an entry, and
`abandonCall` removes its entry.
- **Dangling sink:** a sink names its plugin (`CallSink.owner`) and is
let go of, never called, when that plugin goes. Tested.
- **Spin:** `fizzy.screen` re-asks for its snapshot each frame and gives
up after 120 frames, instead of refreshing forever. A first version of
this fix dropped the refresh and took 1 s; restored. It's 40 ms now, and
the app goes idle after.
- **Hang at stop:** `stop` moves out what it owes without allocating, so
running out of memory can't leave a request unanswered.
- **Docs:** a caller that can't wait sees `.ok` with no result and goes
on at once.
- **Not changed:**
- A client that hangs up while its command is outstanding isn't noticed
until the answer comes or the server stops. Its connection task waits
uncancelably. Watching the socket for a hang-up is a follow-up.
- `finishCall` is UI-thread only, as documented. A worker posts back
first.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0x70dba3df7455c34f`), `sdk_version` untouched, PR labelled `sdk`.
Additive for plugin authors: `ticket`, `deferred`, `failed` and
`later()` on `Command.Call`; `callCommandAwaiting`, `finishCall`,
`callWanted`, `abandonCall` and `CallSink` (with an `owner`) on `Host`.
Existing plugins need only a rebuild at the next SDK release.
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- **sdk test:** a waiter hears the answer once; a second `finishCall` is
ignored; plain `callCommand` drops the answer; an abandoned call's
answer goes nowhere; a withdrawn plugin fails what it owes.
- **Integration test over a real socket:** a POST to a deferred command
stays open across frames, then gets `200 {"done":true}` when
`finishCall` comes. A second one still waiting at `routes.stop` gets
503, and its call is abandoned.
- **Live:** curl on `fizzy.settled` gave `true` in 2 ms. `fizzy.screen`
gave the screen as JSON (windows, rail tabs, the open document) in 42
ms, two frames.
- **Gates:** fmt, build, `test` (590/591, the skip is pre-existing),
`test-integration` (53/53 steps), `check-web`, `test-sdk-version`,
`check-tests-reached`.
- [x] Windows: the x86_64 cross-compile links.
- [ ] Linux: CI
- [x] Web: `check-web`.

## Follow-ups

- **5b:** MCP on the control socket: `tools/list` from the commands
(inputs from the OpenAPI schemas), `tools/call` through
`callCommandAwaiting`.
- **5c (fizzyedit/agent):** its tools (`run`, `act`, `build`, `profile`,
`tape`, `capture`) as `agent.*` commands answering later, sessions with
claims and tapes, and `fizzy-mcp` shrinking to a stdio proxy.
- `fizzy.capture`-style pictures (the automation service's `capture`) as
a command, with how a picture travels over JSON settled first.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes

`scripts/ci-retry.sh` now retries a dependency fetch whose DNS lookup
failed.

On #549 (Zig 0.17), `Tests (Linux)` failed twice, about 15 minutes
apart, fetching codeberg's `translate-c` (a `git+https` dependency under
dvui):

```
error: unable to discover remote git server capabilities: NameServerFailure
```

The script matched only `TemporaryNameServerFailure`, so it printed "not
a fetch failure — not retrying" and failed on the first attempt.
Codeberg resolved fine from a laptop at the same time, and other runs
with a warm cache never fetched it. The pattern is now
`NameServerFailure`, which covers both, plus the git fetch's own
wording, `unable to discover remote git server capabilities`. It also
retries a server error (`bad HTTP response code: '5xx`): an SDL fetch on
#549 failed with `'500 Internal Server Error'` and was not retried. A
4xx, a failing test or a compile error still fails on its first attempt.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] `bash -n scripts/ci-retry.sh` passes.
- [x] The new patterns match the exact lines from both failed logs, and
a `404` does not match.
- [ ] Not seen retrying in CI: that needs the DNS failure to happen
again.

## Follow-ups

- `zig/0.17` needs this too once `main` is merged forward into it;
#549's runs go through that branch's copy of the script.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 5 (5b). Follows
#538 (merged), with `main` merged in.

## What changes

`POST /mcp` on the control socket speaks the Model Context Protocol's
Streamable HTTP transport: one JSON-RPC message per request, answered as
JSON. An agent reaches the same commands curl does.

- **The tools are the commands.**
- `tools/list` reads the registry (the runnable first of each id), each
tool with a `title`, a description naming its plugin, and an
`inputSchema`.
- The `inputSchema` is the body schema `/openapi.json` gives the
command, now one function both use (`openapi.bodySchema`): declared
params exactly; any object for a hand-written `runWith`; nothing for a
`run`-only command.
- **`tools/call`** runs the command as `POST /commands/{id}` would: the
same JSON→ZON edge, with enum strings becoming tags. It goes through
`callCommandAwaiting`, so a command that answers later (#538) answers
its call then. The JSON-RPC id is kept on the waiter.
- **Results and errors:**
- A result is `{content:[{type:"text", text:<result as JSON>}],
isError}`.
- A command that fails, refuses its arguments or isn't enabled is a tool
result with `isError: true`.
- An unknown tool is `-32602`, an unknown method `-32601`, bad JSON
`-32700`, and a batch `-32600` (batching left the protocol in
2025-06-18).
- **`initialize`** answers protocol `2025-06-18`, or `2025-03-26` when
the client asks for it, with the `tools` capability and instructions
that point at `fizzy.screen` and `fizzy.settled`. `ping` is answered,
and notifications get `202 Accepted`.
- **Not yet:** a server-sent stream (`GET /mcp` answers 405),
`Mcp-Session-Id`, resources and prompts.
- **Reaching it:** MCP clients that can only dial a URL or speak stdio
need a proxy for now. That's step 5c (`fizzy-mcp` shrinking to a stdio
proxy onto this socket) and step 6 (loopback TCP).
- `/mcp` is listed in `/openapi.json` and documented in
`docs/CONTROL.md`.

## Review

An independent review pass found nothing that would make the TypeScript
or Python SDK clients, or the Inspector, fail against `/mcp`. Fixed from
its findings:
- **`instructions`:** it named fizzy-only commands from framework code
(`app/`), so an app built on fizzy would be told about tools it doesn't
have, and it claimed tools run "from the palette". Now `fizzy.screen`
and `fizzy.settled` are named only when registered.
- **Client-sent responses** are taken with 202, as the transport says,
rather than refused.
- **Ids** echo exactly (numbers kept as written), and ids that aren't
strings or numbers get `-32600`.
- **Non-UTF-8 results** become `isError` results instead of a broken
body.
- **`arguments: null`** counts as none.

Left open:
- **Batches:** 2025-03-26 asked for batch support, which 2025-06-18
dropped. No client sends them, so it's a deliberate gap, noted in the
code.
- **Dotted tool names in Claude Code:** dots are allowed by the spec,
but the Anthropic API's tool names allow none. Whether Claude Code maps
them gets checked in 5c, through the proxy, before this is relied on.

## SDK impact

- [x] None (beyond #538's, below it).

## Verified

- [x] macOS:
- **Integration test, without a socket:** `initialize` negotiates the
asked-for version; the notification gets 202; `tools/list` gives the
tools with typed input schemas; `tools/call` returns the result, refused
arguments give `isError: true`, and an unknown method gives `-32601`.
- **Integration test, over a real socket:** a tool whose command answers
later holds the HTTP request open, then answers with the JSON-RPC result
carrying the original id.
- **Live, with curl against fizzy:** `initialize`, then
`notifications/initialized` (202), then `tools/list` (54 tools,
`text.insert`'s schema typed), then `tools/call` on `fizzy.settled`
(`true`), `fizzy.screen` (answered a frame later), `text.goToLine` (its
result); plus `-32602` for an unknown tool, `-32601` for
`resources/list`, and `-32700` for garbage.
- **Gates:** fmt, build, `test` (590/591, the skip is pre-existing),
`test-integration` (53/53 steps), `check-web`, `test-sdk-version`,
`check-tests-reached`.
- [x] Windows: the x86_64 cross-compile links.
- [ ] Linux: CI
- [x] Web: `check-web`.

## Follow-ups

- **5c (fizzyedit/agent):** its waiting tools (`run`, `act`, `build`,
`profile`, `tape`, `capture`) as `agent.*` commands answering later,
sessions with claims and tapes, and `fizzy-mcp` as a stdio⇄socket proxy,
so Claude Code and the chat plugin use this endpoint.
- Notifications on a `GET /mcp` stream
(`notifications/tools/list_changed` when plugins load), from the same
event relay `/events` uses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5, "not
during the person's gesture". The maintainer chose the simple form:
refuse and say to try again, rather than queue the undo.

## What changes

- **`error.PersonBusy`** is the one name an owner's `historyGoTo` may
refuse with: the person has a gesture open (a stroke under their hand),
and someone else is acting. It is documented on the vtable entry;
nothing enforces it.
- **`Plugin.historyGoToWhy`** is `historyGoTo` answering why it did not
go, the owner's error by name (`errorName`, so it reads right across a
dylib), or null when it went. It logs nothing; `historyGoTo` keeps its
bool and its log line, built on it.
- **`fizzy.undo`/`fizzy.redo`** with a path or `expect` answer *"the
person is drawing in /p/coin.pixi; try again when they stop"* on
`PersonBusy`, and name any other reason instead of a bare "could not
undo".

Pixi's side, refusing under the person's stroke or open transform, is
fizzyedit/pixi#56.

## SDK impact

- [x] None for the fingerprint (`test-sdk-version` passes): a new method
on `Plugin` and a doc comment. Plugin authors gain one documented error
name.

## Verified

- Unit test (`sdk/src/Host.zig`): a stub owner refusing `PersonBusy`
reads back as `"PersonBusy"`, and a move that goes reads as null.
- Seen on macOS with pixi#52, in a sandbox built from this branch, with
two MCP clients at once:
  - one dragged a slow pencil stroke across a sprite with the pointer;
- the other called `fizzy.undo .{ .path }` 1.2 s in, and got *"the
person is drawing in …/coin.pixi; try again when they stop"*;
  - the same call after the stroke ended undid the stroke.
- Gates on macOS: `check-fmt`, `zig build test`, `test-integration`
(54/54 steps), `check-web`, `test-sdk-version`, `check-tests-reached`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…off (#507)

Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 3's last
part: tracks on by default, "in a PR of its own once a running fizzy's
numbers meet the targets (a setting replaces the switch)".

## What changes

- **A setting replaces the switch.** Settings › History › **Record
document history** (`record_history`), on by default.
- **Applied when it changes**, each frame's top, before the tracks' own
work. A watched hand-edit of `settings.zon` applies too.
  - **`FIZZY_TRACKS`** still overrides it, as fizzy's other switches do.
- **`fizzy.tracks.record`**, an agent's or a test's say, holds until the
setting next changes.
- **Off means forgotten.** Turning recording off now drops what was
recorded (`setTracking` ends every track), so documents keep only their
undo history, as the plan's "Privacy" section says. Nothing of a track
is ever written unless the person exports.
- **Docs:** `app/Tracks.zig` and `docs/AUTOMATION.md` no longer say
tracks are behind a switch.

## Measured against the plan's targets

macOS, Apple silicon, **ReleaseFast**:

| Target | Measured |
|---|---|
| Recording, any frame: nothing per frame | `bench-tracks`: a frame's
top with nothing asked, 0.93 ns |
| Recording, while asleep: no timers, no wakes | idle with recording on
and off (`fizzy_profile`, 4–5 s windows, Output panel showing): 1 frame
a window either way, the profiler's own request |
| A pixi stroke: < 20 µs on the UI thread | `tracks.step` at most 7 µs
over three pencil strokes and four undo/redos in a running fizzy
(devtools stats, every scope over 13 s, 405 frames); `bench-tracks`
0.13–3.4 µs a step with its share of checkpoints |
| Checkpoint, 512×512 × 8 layers: < 2 ms | `bench-tracks`: an 8 MB
document 0.85 ms, 1 MB 0.11 ms |
| Undo or redo: the same frame | `pixi.undo` at most 0.04 ms,
`fizzy.undo` 0.1 ms with recording on; the worst frame over the run was
5.2 ms |

Not measured here: Windows and Ubuntu (VMs not run), the web, and an
8-hour session's memory (plan step 11's subject).

**Turning recording back on** captures each open document's base once (a
frame's work per document, measured above as a checkpoint), then it is
quiet again.

## SDK impact

- [x] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
check-web`, `zig build test-sdk-version` and `zig build
check-tests-reached` pass.
- `zig build test-integration`: 53/53 steps, 561/561 tests, **every test
now running with recording on**, the bundled tour and demo tapes
included.
- **A new integration test:** the setting is on by default. Turning it
off ends recording, unhooks the recorder and forgets every track;
turning it on gives every open document a track again.
- **In a running ReleaseFast fizzy** (its own profile, no
`FIZZY_TRACKS`): `fizzy.tracks` reported recording on and a track per
open document. Typing and pixi strokes recorded, measured as above.
- [ ] Windows, Linux: not run (CI builds and runs the integration tests
on Linux).
- [ ] Web: `check-web` builds.

## Follow-ups

- **The plan's frame-budget check** (bundled tapes' frame times with
recording on matching off within noise, failing the build on a
regression) is not added. Timing comparisons in the headless run are
noisy on shared CI. The integration run now records throughout, which
catches a recording failure but not a slow one. The seam is
`bench-replay` growing a recording-on case whose numbers CI keeps, as
the plan's "Profiling it" says.
- **Seen while measuring, not this change:**
- pixi 0.1.47 (the store build) crashed with a null pointer in
`File.loadFizzyZip` when fizzy's restart hand-over gave it back an
`art.pixi` that the text editor had held (opened as text when pixi was
still loading at startup). Malformed bytes should refuse, not crash. The
crash report is kept.
- A file named on the command line opens with whichever plugin is loaded
by then: `art.pixi` opened in the text editor because pixi was still
loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #547. Serves #555 (plans#27), whose corner button needs to tell
touch from mouse.

## What changes

- **Hand cursor:** panel tabs and rail icons (everything drawn through
`core.widgets.Tabs`) and document tabs show the hand under a mouse.
- **Splits keep their arrow:** within a split's reach of its sash, the
split's resize arrow still wins over a tab's hand.
- **No hover after a tap:** on a touchscreen, a tapped tab, rail icon,
menu row or button no longer stays lit, and its tooltip no longer comes
up once the finger lifts.
- **macOS trackpad:** a trackpad's fingers no longer reach dvui as
`touch0` presses at the finger's position on the pad. They are mouse
input only, as they already were through SDL's mouse events.

**For plugins:** `dvui.pointerKind()` (`.touch` or `.mouse`) and
`dvui.cursorSetPriority(cursor, .normal | .resize | .drag)`, both from
dvui-dev. #555 should read `dvui.pointerKind() == .touch`.

## Design: the pointer kind and cursor priority seam

Two questions every hover look and cursor needs answered once a frame:
*is this pointer a finger or a mouse*, and *does a resize handle claim
the cursor here*.

**Pointer kind: from the event source, or inferred by fizzy?**

- **A. dvui's window says it (chosen).** dvui-dev's `Window` sets
`pointer_kind` as the backend adds events: a touch button
(`touch0`..`touch9`, from `addEventPointer` or `addEventTouchMotion`) is
`.touch`; mouse motion, a mouse button or the wheel is `.mouse`. Read
with `dvui.pointerKind()`. The window is the one every plugin dylib and
wasm side module already draws into, so they all read the same answer,
with no frame key and no module global. dvui also keeps which touch
buttons are down: after a touch with none down, the frame's `.position`
event goes off every window and to no widget. A lifted finger hovers
nothing, so dvui's buttons, menu rows, tooltips and
`core.widgets.hovered` all stop showing hover after a tap without a
check of their own.
The backend decides what a touch is. SDL reports a macOS trackpad's
fingers as touches (`SDL_TOUCH_DEVICE_INDIRECT_*`, at the pad's
normalized point), and fizzy's backend passed them on as `touch0`
presses at that point mapped into the window. Nothing in fizzy or the
store plugins read them as anything else: every touch consumer
(`CanvasWidget`, `Tabs`, `ContextWidget`, `viz`, pixi, atlas) reads
touch buttons as fingers on the screen. So the backend gives only
`SDL_TOUCH_DEVICE_DIRECT` fingers a touch button, one per finger
(`touch_slots.zig`), and the trackpad stays the mouse it already is.
- **B. fizzy infers it** (`core.input`, scanning each frame's events for
touch buttons). This is what #542 tried. State in a module is per image
(each dylib and wasm side module has its own), a per-frame key needs a
frame id that is unique on every backend, and a hover look in a dvui
widget (a menu row) would still need dvui changed. Putting it on `Host`
instead would add an SDK seam for something dvui already sees.

**Cursor: priority in dvui, or ordering in fizzy?**

- **A. `dvui.cursorSetPriority(cursor, priority)` (chosen).** The
highest asked in a frame wins, and between equals the first. `cursorSet`
is `.normal`, as before. A resize handle asks at `.resize` (fizzy's
`Split`, float edges; dvui's `PanedWidget`, `GridWidget` columns and
floating window edges), and the carried view at `.drag`. A tab's `.hand`
can't take a split's arrow, whichever is drawn first, and *which widget
takes a press doesn't change*.
- **B. The split claims ahead**: it handles the pointer before the
region beside it draws. #542 tried this and dropped it, because it took
presses from scrollbars near a sash and took the cursor during other
widgets' captures. A tab checking how close it is to a split instead
would tie the tab to the layout.

**For #555 (plans#27):** the read is `dvui.pointerKind() == .touch`. It
replaces the corner's `_chooser_touch` bool. A finger that lifted leaves
no `.position` hit, so the corner's mouse-hover reveal won't fire after
a tap.

### The pieces

- **dvui-dev** (`fizzy-dev`, tag `fizzy-sdk-0.2.30-5`,
`docs/DEPENDENCIES.md` items 11 and 12):
- `e04bccc` Window: the pointer kind, and a lifted finger hovers nothing
  - `20e56f1` cursorSet: a priority, so a resize handle's cursor wins
- **Backend:** `backend/src/touch_slots.zig` is std-only and
unit-tested. Each finger on the screen keeps its own touch button from
down to up, so two fingers are two buttons on native touchscreens too.
Before, every finger was `touch0`, and `FINGER_CANCELED` was dropped.
- **Users:**
  - `Tabs.Tab` asks for `.hand` on its `.position`.
  - The workbench's document tab asks for `.hand` when hovered.
  - `Split.grab` and the float's resize edges ask at `.resize`.
  - `Layout.init`'s view-drag cursor asks at `.drag`.

The cursor priority doesn't change routing. A press over the edge of a
tab inside a split's reach still goes to the tab, drawn first, as it did
before this PR (a test pins this). That mismatch, an arrow shown where
the press goes to the tab, already existed. Changing it means changing
routing, which #542 showed has costs. It's left as a follow-up.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

The dvui pin moves from `fizzy-sdk-0.2.30-4` to `fizzy-sdk-0.2.30-5`,
because `Window` gained `pointer_kind`, `touches_down` and
`cursor_priority`. The fingerprint is now `0x35bac8377071cd23`, on top
of #538's. For plugin authors:
- `dvui.pointerKind()` and `dvui.cursorSetPriority` are new.
- `cursorSet` behaves as before.
- A plugin's own hover looks lose their hover after a tap with no change
on the plugin's side.

## Verified

- [x] macOS:
- **Gates:** `scripts/check-fmt.sh`, `zig build`, `zig build test`
(107/107 steps), `zig build test-integration` (54/54 steps, 601/601),
`zig build check-web`, `zig build test-sdk-version`, `zig build
check-tests-reached`, `scripts/check-examples.sh` (all 8 ok).
- **dvui-dev:** `zig build test -Dbackend=testing`, 268/268. New tests:
    - "a finger that lifts hovers nothing, and a mouse hovers again"
    - "cursorSetPriority: the higher asked wins, whichever asked first"
- **New fizzy tests** (`core/widgets/Tabs.zig`, dvui testing backend,
under `fizzy-sizing-tests`):
    - "a tab shows the hand"
- "beside a split, the split's arrow wins over the hand, and a press
goes where it did". With `Split` set back to plain `cursorSet`, this one
fails with "expected .arrow_w_e, found .hand".
    - "a tapped tab is left without a hover look"
  - **Backend unit tests** (`backend-touch-slots-tests`):
    - "a trackpad's fingers get no slot"
    - "each finger on the screen keeps its own slot until it lifts"
    - "an eleventh finger is not seen"
- **Not seen on screen:** the hand and the arrow in a running fizzy, and
a trackpad in the sandbox.
- [ ] Windows: builds in CI only. The direct-touch path is untested on a
touchscreen.
- [ ] Linux:
- [ ] Web: `check-web` only. The phone tap that leaves no hover look
needs seeing on a phone.

## Follow-ups

- **Ad-hoc touch tracking** in `core/widgets/CanvasWidget.zig`
(`last_input_was_touch`) and `core/viz/viz.zig` (`touch_pointer`) could
read `dvui.pointerKind()` instead.
- **Arrow shown, press to the tab:** inside a split's reach, over the
tab drawn before it, the arrow shows but a press goes to the tab. This
was already the case before this PR (see above).
- **Stale comment:** `app/store/PluginStore.zig` says "last cursorSet
call wins". It is the first, so its `.arrow` override doesn't do what
the comment says.
- **Upstream:** propose both dvui-dev changes to upstream dvui.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#513)

No plan issue: a bug found while driving a sandbox.

## What changes

`fizzy --profile <dir> <dir>/work/art.pixi` opened the sprite in the
text plugin, as binary garbage, and it stayed there after pixi loaded.
Opening the same file later through `fizzy.openFiles` went to pixi.

**The cause is not startup ordering.** User plugins load synchronously
in `Editor.postInit`, before the first frame drains argv and the
single-instance handoff (`singleton.drainPending`), so making those
opens wait for user plugins would change nothing. The file goes to text
when pixi is *not loaded* at that moment, and nothing revisits it when
pixi arrives later. Reproduced here with a Debug fizzy that refuses a
ReleaseFast pixi (`AbiOptimizeMismatch`), followed by a compatible build
dropped into `plugins/pixi/`, which the watcher then loads
(`reconcileFailedPluginBinaries`). The same gap applies to an undecided
drop-in loaded from the store, a store install, and an enable.

**The fix:** whenever who opens which extension may have changed
(`rebuildExtensionOwnerCache`, which every arrival, update, enable,
uninstall and File Types edit already calls), the next frame runs
`Editor.rehomeFallbackDocuments`. It hands each document the fallback
editor holds *only as the fallback* to the plugin that resolves for its
extension now. The document is detached and reopened in the **same tab**
(`Placement.in_place`: it takes the old tab's slot, shown only if that
tab was showing, and focus stays where the user left it). It is left
alone when:
- it has unsaved changes,
- it is held by an `sdk.Claim`,
- there is nothing to read again (no file on disk and not on a mount),
- or its extension is one the fallback lists in its own `fileTypes`.

The pass waits while the file-type question (`FileTypeDefaults`) is up,
so that answer decides where the document goes. The dialog's line "files
already open stay where they are" now says that files open as plain text
only because nothing else could open them move to the user's choice.

## SDK impact

- [x] None

## Verified

- [x] macOS: in a sandbox profile, launched `fizzy --profile <p>
<p>/work/art.pixi` with a ReleaseFast pixi (refused, so it opens as
text). Then I dropped in a Debug pixi built against this tree. The log
shows `plugin watcher: 'pixi' loaded …` and then `art.pixi: moving from
Text to Pixi, which opens it now`. Screenshot: one `art.pixi` tab, drawn
by pixi.
- [x] Test: a new block in the headless whole-editor test unregisters
the image plugin, opens a `.tga` (the fallback holds it), registers
image again, and checks that the document moved to image with its tab
taken over and none added. With the re-home disabled it fails (`expected
Plugin@…, found Plugin@…`).
- Gates, all run locally:
  - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`: pass.
  - `zig build test`: 99/99 steps.
- `zig build test-integration`: 53/53 steps, 561/561 tests. It prints
`failed command:` because of warnings: the existing replay warning, plus
text's "binary bytes" warning for the test's `.tga`.
  - `zig build test-sdk-version`: pass.
  - `zig build check-tests-reached`: pass.
- [ ] Windows:
- [ ] Linux: integration test only (CI)
- [ ] Web: builds; the disk check is skipped there (a web document is
re-read only through its mount)

## Follow-ups

None needed for this bug.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne and others added 4 commits October 18, 2026 13:33
Part of fizzyedit/plans#10 — `plans/HISTORY_TIMELINE_PLAN.md` step 4
("text, then pixi: going back is a step"), text's half. It is also what
`plans/SHARED_HISTORY_PLAN.md` step 6 ("Go back to before Claude's
change") waits on.

## What changes

Going back over twenty steps to look at something, then typing, used to
throw the twenty away. Now the line loses nothing and stays one line:

- **`textcore/History.keepGoneBack`**, where `end` and `push` cleared
redo, handles an edit that lands after going back:
  - the steps gone back over go back on the undo stack as they were;
  - each one's reverse follows, newest first (`Transaction.inverted`);
  - then the edit lands.
  
The text doesn't change, because the reverses bring it to where you went
back to. Dragging the timeline to a step gone back over brings it back
like any other.
  - `Committed.back` says how many steps were gone back over.
  - Each reverse carries `back_to`, the step the jump went to.
  - An edit with nothing gone back over is unchanged.
- **No branches and no new hook members.** `historyLine`/`historyGoTo`
already say everything. A reverse is titled **"Back to Typing"** (its
target's own kind, or "the Start"), so a run of them reads as one move.
- **Credit for a jump belongs to whoever went back**
(SHARED_HISTORY_PLAN, "Going back is the jumper's"). `Document.undo`
remembers `Host.acted()`. When an edit later makes the jump steps, they
are reported under that author with `actAgain`: the restored steps as
`redone`, the reverses as `added`, each with its own `top`. So Claude's
next write after the person went back never makes Claude the author of
taking back the person's work.
- **`Host.acted()`** now falls back to the frame's input author (the
person at the keys) when nothing has called `act`, matching `author()`.
Before, a person's undo stored "nobody". This is a behaviour change to
an SDK method; the fingerprint does not move.

## What it changes elsewhere

- The recording test's step sequence for an edit after going back is now
`…undone, redone, added, added` instead of `…undone, added`. Replaying
the recorded steps into a second document still gives the same text.
- **Tracks:** a track whose start was undone past and edited over used
to refuse to export, because its start was gone from the history. Its
start is now still on the line, so it exports, and the test now asserts
that. Starting over (`restarted`, a restore) still loses a track's
start, so the refusal path itself is unchanged.

## Not in this PR

- **The plan's cap on line length** ("a cap per document drops the
oldest steps"). The line now grows by the steps gone back over each
time. The seam is `keepGoneBack`: a cap would drop from the bottom of
`undo_stack` there.
- **Pixi's half,** its own PR next.

## SDK impact

- [x] None for the fingerprint (`test-sdk-version` passes).
`Host.acted()` returns the input author when there is no explicit `act`.

## Verified

- **Unit (textcore):**
- an edit after going back two steps: nothing to redo; six steps on the
line (three, two returned plus their reverses, the edit); `back_to` set
on the reverses; and every point on the line goes to the right text
("ab\n", "ab", "abc", "abcz");
  - an edit with nothing gone back over adds only itself.
- **Integration** (`headless: the whole editor comes up…`): Claude
writes, the person undoes it, Claude writes again.
  - Claude's first write is still on the line.
- The reverse is titled "Back to …" and is the person's (`stepAuthor`).
  - Claude's new write is Claude's.
  - Going to the first write shows its text.
- **Gates on macOS:** `check-fmt`, `zig build test` (631/632, 1
skipped), `test-integration` (54/54 steps), `check-web`,
`test-sdk-version`, `check-tests-reached`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/AUTOMATION_PLAN.md` (the format section's "`zig build
demos` for the site, to come").

## What changes

`zig build demos`, part of `zig build web`, writes every tape fizzy
hosts into `zig-out/web/demos/`:

- each bundled demo (`src/editor/demos/catalog.zig`), built from its
script
- each hand-written tape in `docs/demos/*.zon`, read and written again

All of them are in the deflated binary form, with an `index.json` naming
each tape's title, length and whether it is live. web.yml already zips
`zig-out/web`, so `fizzyed.it/app/demos/` and
`fizzyed.it/testapp/demos/` each serve the tapes built from their own
commit. A page on the site embeds
`fizzyed.it/app/?demo=demos/hello.tape`, and the tape can't drift from
the app that plays it. That's the case for keeping editor tapes here
rather than in a repo of their own: steps, anchors and commands are tied
to the app version. Sizes: tour 1.7 KB, markdown 0.9 KB, hello 0.5 KB.

The tool (`scripts/demos.zig`) reads each tape back from the bytes it
wrote and checks it the way the app does: chords in the keymap's
spelling. A tape with steps is refused, because only the app knows which
plugins' step formats it applies (`Demo.stepsCheck`). That's the seam to
widen when a hosted tape needs steps.

To build on the host without dvui, the demo scripts now import `tape`
directly instead of going through `app`. `wireAppModule` gives its
consumer `tape` beside `app`. The rail's Files anchor is now spelled out
in the catalog rather than built from `workbench.view_files`, and a new
test in `tests/demos.zig` holds the two equal.

Only fizzy's own build (`Options.own`) has the step. Fizzy's package
leaves out `docs/` and `scripts/`, so on an app built on fizzy it would
fail at configure. CI now runs `zig build demos` beside `check-web`, so
a script or tape that stops building fails its PR rather than the deploy
after it. `web.yml` also redeploys when `docs/demos/**` or the tool
changes. Two tapes with the same name, such as a `docs/demos/tour.zon`
beside the bundled tour, are refused instead of overwriting each other.

## SDK impact

- [x] None

## Verified

- [x] macOS: `zig build`, `zig build test`, `zig build test-integration`
(54/54 steps; `fizzy-demo-tests` runs both tests, the new anchor one
included), `zig build check-tests-reached`, `zig build
test-sdk-version`, `scripts/check-fmt.sh`; the tool refusing a second
tape named `tour`
- [ ] Windows:
- [ ] Linux:
- [x] Web: `zig build web` puts `demos/{tour,markdown,hello}.tape` and
`index.json` beside `index.html`. Playing a fetched `.tape` is the
existing `?demo=` path, not yet tried in a browser with these files

## Follow-ups

- The website embedding a tape (click to load, so the wasm isn't fetched
with the page) is fizzyedit/website's change.
- A hosted tape with steps needs the plugins' step formats at build
time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dvui-dev moves to its zig-0.17 head 1b29a16, which carries the windowing work's 59c45df and #563's three dvui commits (pointer kind, cursor priority, float edges), ported by fizzy-e6. build/web.zig lists docs/demos through b.root and declares the listing. rewrite.sh re-run. The SDK shape fingerprint is re-recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne merged commit 57476de into zig/0.17 Oct 18, 2026
@foxnne
foxnne deleted the zig/merge-main-1018b branch October 18, 2026 19:07
@foxnne foxnne mentioned this pull request Oct 18, 2026
3 of 4 tasks
foxnne added a commit that referenced this pull request Oct 18, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): fizzy, its
backend and its bundled plugins move to Zig 0.17. Every piece was
reviewed as its own PR into `zig/0.17`, and this PR lands that branch.
`main` is already merged in (through #555), so it carries no conflicts.

## What changes

**The move to 0.17** (`minimum_zig_version = "0.17.0"` everywhere), as
reviewed in:
- #437: the switch, scaffolded with marked stand-ins
- #438: the build's custom steps become helper programs
- #448: release packaging on 0.17, with Velopack 1.2.161 (+ #452's zon
arenas)
- #488: settings and command schemas read 0.17's `@typeInfo`, with a
test that the SDK fingerprint hashes the same facts as on 0.16
- #489: per-pointer capture through `captureMouseGet`
- #494: the Windows traps in `docs/PITFALLS.md`
- #496: `blitData` without an `extern struct` `@bitCast`
- #498: `fizzy.plugin.installBeside`, for a plugin's helper program
- #505: the 0.16 inflate workaround goes; the docs say 0.17
- #561: review fixes. A settings payload with pointers is a compile
error, and build reads go through `b.root`.
- #573: the plugin install helper compiles once, in Debug
- the catch-ups with `main` (#486, #503, #558, #574, and the last merge
here), each with `scripts/zig-0.17/rewrite.sh` re-run. That script is
deleted in the last commit.

Most of the diff is mechanical: `zig fmt`'s upgrades (`@backingInt`,
`std.lang`, …) and one-answer renames. The hand migrations are the build
(configure/make split, no custom steps), the zon arenas, `@typeInfo`,
translate-c for `@cImport`, and the `@bitCast` audit. A high-effort
review of the whole `main`…`zig/0.17` diff found five things; the three
correctness ones are fixed in #561, and one efficiency one in #573.

**Also on the branch, merged there by the maintainer:** the windowing
work.
- #480–#483: plain dvui apps on macOS
- #492, #501, #504, #510: OS windows for floating windows, full screen,
pointer routing
- #546: window materials

**Forks:** dvui-dev at its `zig-0.17` head `1b29a16`, which carries
fizzy-dev's patches on upstream's 0.17 `main`, including #540, #544 and
#563's dvui commits. Also assetpack, md4zig, sdl_zig and zig-objc on
their `zig-0.17` branches, and velopack-zig `3d553b7` (tagged
`fizzy-zig0.17`).

## Measured

macOS arm64, under load from other sessions, each step alternated
between the two. 0.16 is `main` #509; 0.17 is `zig/0.17` with #561.

| | Zig 0.16 | Zig 0.17 |
|---|---|---|
| App, cold Debug build | 94.4 s | 78.6 s |
| App, warm no-op rebuild | 0.7 s | 0.4 s |
| `test-integration` (compile + run) | 108.9 s | 90.4 s |
| App, ReleaseFast build | 142.8 s | 131.5 s |
| Image plugin, cold standalone build | 11.6 s | 14.0 s (with #573) |
| Startup, Debug (median of 5) | 565 ms | 466 ms |
| Startup, ReleaseFast (median of 5) | 175 ms | 177 ms |
| Text, ReleaseFast, µs/frame: idle / mid / scroll / typing | 305 / 424
/ 435 / 403 | 240 / 306 / 286 / 290 |
| Markdown, ReleaseFast, 4 docs, median of 5, µs/frame: idle top / mid /
scroll / resize | 1177 / 1735 / 2104 / 1319 | 1270 / 1699 / 1833 / 1192
|

The one slowdown is markdown idle at the top of a document, +8%. The
rest of the plugin build's gap is dvui's own translate-c on 0.17.

## The plan's gate

- **ZLS:** no 0.17 release yet (latest 0.16.0). zigtools/zls#3270 is on
ZLS's master. Completions across `fizzy_sdk` from a plugin are not
checked.
- **Minimum OS:** 0.17 needs Darwin 15+ (OS X 10.11); fizzy's macOS
minimum is 11.0, so no change. Windows 10+ and Linux 5.10+ are not
stated by fizzy.
- **Debug leak reports:** still print (`DebugAllocator`, deprecated,
unchanged). The move to `SafeAllocator` is a follow-up with its own
Debug frame-time check.
- **Velopack:** packaging proven by a test release on macOS (signed and
notarized), Windows and Linux. An update in place was not tested.
- **Every target builds:** in CI. Windows and Linux were not run by hand
on the VMs.

## SDK impact

- [x] Fingerprint moved (`0x5f9c818a6972efd4`, recorded in
`sdk/src/version.zig`); `sdk_version` untouched. `sdk: release 0.2.30`
follows this at once. Plugins need Zig 0.17 to build against it: there's
no overlap window.
- Plugin authors see `minimum_zig_version = "0.17.0"` and
`fizzy.plugin.installBeside`. A settings payload holding pointers other
than a string is now a compile error. `docs/PLUGINS.md` says 0.17.0.

## Right after this merges

1. fizzyedit/example-app#8 and fizzyedit/example-plugin#13 merge. Then a
one-line PR points `scripts/check-examples.sh` back at `main` (it pins
the examples' `zig-0.17` branches until then, so `main`'s CI is never
red).
2. `sdk: release 0.2.30`.
3. The store plugins' 0.17 PRs repin and release: fizzyedit/pixi#51,
zig#15, atlas#16, drive#13, ghostty#14, agent#23, chat#16.

## Verified

- [x] macOS (arm64, Zig 0.17.0), on this tree:
  - `scripts/check-fmt.sh`
  - `zig build`: 71/71
  - `zig build test`: 109/109 steps, 637/638 (1 skipped)
  - `zig build check-tests-reached`: 998 tests in 122 files
  - `zig build check-web`: 42/42
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 78/78 steps, 608/608
- [x] All seven store plugins built against `zig/0.17`'s `sdk/` and
loaded together in a 0.17 fizzy (Debug, sandbox profile).
- [ ] Windows, Linux, the example repos: CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 18, 2026
Part of the multi-window plan (fizzyedit/plans#26).

**What changes:** `docs/DEPENDENCIES.md` lists dvui-dev `zig-0.17`'s
patches since patch 10:
- 11: front-to-back blocks and deferred queues (`ca8d4f8`, `8fa9b9c`);
- 12: a floating window shown in an OS window draws no background of its
own (`59c45df`);
- 13: the pointer's kind and the cursor's priority (`48496b7`,
`186a87f`, `1b29a16`).

This PR began as the repin to 59c45df and then 1b29a16. #574 pinned the
same commit and landed first, so the pin and fingerprint are gone from
here. `zig/0.17` has since landed on `main` (#575); `main` is merged in,
and only the docs remain.

## SDK impact

- [x] None (docs only).

## Verified

- [x] `jj diff --from main@origin` is `docs/DEPENDENCIES.md` alone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant