Skip to content

zig/0.17: merge main (40 commits, to #469) - #486

Merged
foxnne merged 41 commits into
zig/0.17from
zig/merge-main-1017
Oct 17, 2026
Merged

foxnne merged 41 commits into
zig/0.17from
zig/merge-main-1017

Conversation

@foxnne

@foxnne foxnne commented Oct 17, 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. It was 40 commits behind, up to #469.

Please merge this one by fast-forward, not squash

This PR is a merge commit with main as its second parent. Squashing it would drop that parent. The next catch-up would then find the old merge base, and every line rewrite.sh changed in main's code would conflict again.

Once CI is green, the clean way is a fast-forward of zig/0.17 to this commit: jj bookmark set zig/0.17 -r <this commit> then jj git push --bookmark zig/0.17. That's a push to the integration branch, not to main. Your call: squash also works, at the cost of re-resolving these lines next time.

What changed beyond the merge

  • Conflicts (11 files). Nearly all were main's builtin.target rename (zig: builtin.target.os, .cpu and .abi in place of builtin.os #435) against the same rename already on zig/0.17; those take zig/0.17's side. These files take main's side, then rewrite.sh:
    • scripts/check-examples.sh: main's fuller bsdtar fix.
    • plugins/text/src/Document.zig: the selection hash.
    • sdk/tape/binary.zig: the header now written early, and the versioned field read.
    • sdk/src/version.zig: re-recorded (below).
  • scripts/zig-0.17/rewrite.sh re-run over the code main brought in.
  • By hand:
    • app/state_delta.zig: a test's multi-character string repeat ("…" ** 100, gone in 0.17) is now a comptime loop.
    • sdk/tape/binary.zig: the tape reader's new indexed field loop, in 0.17's field_names shape.
    • recorded_sdk_shape_fingerprint is 0x0b0e62b7eac39f6d: main's SDK changes since the switch, on 0.17. It is released with the merge to main, as before.

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
    • zig build test: 95/95 steps, 232/232 tests
    • zig build check-tests-reached: 120/120
    • zig build check-web: 41/41
    • zig build test-sdk-version: 35/35
    • zig build test-integration: 77/77 steps, 559/559 tests
  • Windows, Linux, the example repos: CI. check-examples.sh wasn't run locally because the machine is under load from other sessions.

🤖 Generated with Claude Code

foxnne and others added 30 commits October 15, 2026 09:06
`app/crash/crash.zig`'s header still listed "forward a plugin dylib's
panic message to the host" as a follow-up. That landed in #360 (released
in sdk 0.2.23): the generated dylib root's `sdk.dylib.panic` forwards
through `EditorAPI.panic` → `fizzyPanic` → `std.debug.panicExtra` → the
root's `crash.panic`, so the report carries the plugin's message and
frames.

The comment now describes that path. Comment only; no code changes, so
no SDK impact.

**Verified:** read the path end to end (`sdk/src/dylib.zig:363`,
`src/editor/Editor.zig:2406`, `src/Entry.zig:253`);
`scripts/check-fmt.sh` clean.

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

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

`sdk_version` goes from 0.2.26 to 0.2.27. Merging this tags
`sdk-v0.2.27` and publishes its tarball.

**Why now:** the agent plugin's `stroke` step (fizzyedit/agent#11, on
agent `main`) uses `tape.Script.StrokePoint` from #431, which no
released SDK has, so agent's new CI (fizzyedit/agent#13) can't pass
against its pinned SDK until it repins to a release that carries it.

## SDK impact

- [ ] 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`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**`sdk` PRs since `sdk-v0.2.26`:**
- **#434:** two optional document vtable members, `historyLine` (a
document's undo history as a line of steps, with a `version`) and
`historyGoTo` (undo or redo to a step), with `Plugin.HistoryLine`. An
owner without them has no timeline; undo and redo are unchanged. The
fingerprint moved, so the repin automation opens a repin PR in each
store plugin.

**Reaching plugins, no fingerprint move:**
- **#431:** `tape`: a stroke carries the pointer in straight lines
(`Script.StrokePoint`).
- **#432:** `tape`: a caption follows where the pointer reaches, not a
pen's path.
- **#433:** `replay`: a tape's pointer gives way to a cursor the app
draws itself.
- **#427:** `core`: the bubble spinner keeps its bubbles near one size
at any scale.

Open `sdk`-labelled #437 (the Zig 0.17 switch) is a draft and not in
this release.

## Verified

- [x] macOS:
- On this head, `zig build test-sdk-version`: 12/12 steps succeeded.
`scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.27.tar.gz`.
- fizzyedit/agent (main plus fizzyedit/agent#14) builds against this
`sdk/` (`--fork`) for x86_64-windows, x86_64-linux-gnu and
aarch64-macos, and `zig build test` passes.
- [ ] Windows:
- [ ] Linux: CI.
- [ ] Web:

## Follow-ups

- The repin automation opens `sdk: repin to fizzy SDK 0.2.27` PRs in the
store plugins whose build needs it. agent repins too, and then
fizzyedit/agent#13's CI can go green.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 0)

## What changes

Nothing anyone sees. `@import("builtin").os`, `.cpu`, `.abi` and
`.object_format` become `builtin.target.os` and so on: 235 sites in 51
files, a pure rename. Zig 0.17 deprecates the short forms and 0.18
removes them. 0.16 already has `builtin.target`, so this lands on `main`
now and keeps the 0.17 switch smaller.

The step's other item, the `@hasDecl` audit, needs no change. 0.17 makes
`@hasDecl` see only `pub` declarations in every file, so a non-`pub`
target would quietly read as absent. All 28 sites in fizzy look for a
`pub` declaration. No store plugin or example declares `service_version`
or a layout's `context()` in a way that would be missed, and
`docs/PLUGINS.md` already shows `pub const service_version`.

## SDK impact

- [x] None. `sdk/tape` and `sdk/replay` are touched by the rename; the
fingerprint does not move (`zig build 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: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig
build test-integration` (53/53 steps, 522/522 tests), `zig build
check-web`, `zig build test-sdk-version`, all on 0.16.
- [ ] Windows: CI's cross-compile.
- [ ] Linux: CI.
- [x] Web: `zig build check-web`.

## Follow-ups

None for this step. The rest of the 0.17 work goes on the `zig/0.17`
integration branch (plans#5, "Where the work lives").

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

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

`scripts/check-examples.sh` failed on macOS: its `pack()` tars the tree
with bsdtar, which writes an AppleDouble `._name` entry beside every
file carrying extended attributes — every checked-out file has
`com.apple.provenance`. The fizzy tarball then held
`backend/patches/sdl/._0001-….patch` and friends, and the SDL patch step
(`run exe patch`) failed with `NoSections`, failing the example-app
studio/minimal/endless/dvui builds. CI runs on Linux with GNU tar, so it
never saw this.

`pack()` now gets the same treatment `scripts/pack-sdk.sh` already
applies: under bsdtar, `COPYFILE_DISABLE=1` plus `--no-xattrs
--no-mac-metadata`. The flags are passed only when `tar --version` says
bsdtar, so GNU tar on CI runs exactly as before.

## 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: before, `tar -cf -` of `backend/patches/sdl` held 10 `._`
entries; with the flags, 0. `EXAMPLES_JOBS=2 scripts/check-examples.sh`
prints `ok` for all eight builds (plugin, studio, minimal, studio-zon,
endless, dvui, replay, dvui-sdl3), exit 0.
- [ ] Windows:
- [ ] Linux: unchanged path (GNU tar, no flags); CI's examples job
covers it.
- [ ] 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 fizzyedit/plans#13 (step 1).

## What changes

An app built on fizzy chooses which of fizzy's plugins it links.
`fizzy.plugins.{workbench,text,image,archive,markdown,devtools}` are
entries in the list `fizzy.buildApp` takes, beside the app's own, and
`fizzy.plugins.all` is fizzy's own set:

```zig
try fizzy.buildApp(fizzy_dep, &.{
    fizzy.plugins.workbench,
    fizzy.plugins.text,
    .{ .name = "graph", .module = graph_dep.module("plugin") },
});
```

Fizzy's own entries have no module: `build/plugins.zig`'s `link` builds
each one for every executable (native, packaged, web), wired to that
executable's framework, and re-homes the app's modules the way the
native and web builds each did on their own before. Fizzy's build passes
`fizzy.plugins.all` through the same path, so the exe and the web build
no longer keep separate copies of the list. The test build keeps its own
list, because it tests fizzy itself. A plugin that isn't listed doesn't
register, and fizzy's dylib for it (text, markdown, image) is neither
built nor installed.

Two plugins are always built, because fizzy's own code imports them as
libraries. **workbench** must be listed: the editor still holds its
state (`editor.workbench`, the dylib's `arg_c`), and a list without it
fails the build with a message naming the plan. **markdown** is always
compiled, because the store draws READMEs with it, but it only registers
as a plugin when listed.

**Transition:** fizzy's CI builds example-app's `main`, and example-app
pins fizzy by commit, so neither repo can make the change first. Until
example-app lists fizzy's plugins, a `buildApp` list that names none of
them gets all of them first, as before. That fallback is three lines in
`buildApp`, and step 2 removes it. fizzyedit/example-app#6 lists them
explicitly (its minimal shape links only workbench and text), and it
repins to this once merged.

Also:
- `Entry.zig` drops an unused `@import("text")`.
- The `postInit` comment no longer says the bundled plugins don't ship
as dylibs. They do, and it now says why.
- The root `AGENTS.md` describes the list.

Overlaps in files only with the Zig 0.17 drafts (#437, #438), which
touch the whole build; this change touches no build step.

## SDK impact

- [x] None

## Verified

- [ ] macOS: `zig build`, `check-web`, `test`, `test-integration`,
`test-sdk-version`, `check-fmt`. The four built-in dylibs are still
installed to `zig-out/<target>/plugins/`. `scripts/check-examples.sh`
passes against example-app with explicit lists (its minimal shape links
only workbench and text), and against example-app's `main` through the
fallback.
- [ ] Windows:
- [ ] Linux: CI
- [ ] Web: `check-web`

## Follow-ups

- Step 2 (plans#13): a link-only build, and removing the fallback.
- workbench becomes optional once its state moves from the Editor into
the plugin.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#8 · Needed by fizzyedit/agent#16

## What changes

`EditorAPI.OpenOptions` gains **`beside: bool = false`**: open the
document beside the pane the person is in, leaving them that pane and
their focus. It's what fizzy does for a claimed document (`showClaimed`
→ `Workbench.showBeside`), now available without a claim.

- An open document goes beside at once (`openPath`, instead of focusing
it).
- One still loading carries the flag on its `Opening`, and goes beside
when it lands (`Openings.land`).
- `grouping` is ignored when `beside` is set.
- **`Host.openBeside` / `endOpenBeside`**: while one is open, documents
opened *or created* land beside the person. A sprite an agent creates
(`pixi.create`) appears beside them at once, fitted to that pane,
instead of opening over their document and then moving. The agent plugin
opens it around each command (fizzyedit/agent#16).

**Why:** an agent's commands claimed the document they edited
(fizzyedit/agent#10), which showed it beside the person but also held
back all their input to it for up to a quiet minute. Agents now work
*with* the person (fizzyedit/pixi#31 makes a pixi edit wait for the
person's open stroke or transform instead), so the hold goes
(fizzyedit/agent#16), and this keeps the "shown beside you" part.
`OpenOptions`' own comment anticipated it: the next answer to "where
does it land" is a defaulted field.

## SDK impact

- [ ] None
- [ ] Core-only or additive
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0xbd784c59f3146ea4`), `sdk_version` untouched, PR labelled `sdk`.
Plugins see one new defaulted field on `OpenOptions`.
- [ ] SDK release

## Verified

- [x] macOS, sandbox with fizzyedit/agent#16, pixi and chat built
`--fork` against this:
- an agent's `pixi.draw` on a background tab moved that sprite into a
pane beside the person's, while their pane and its active document
stayed theirs
- creating a sprite and drawing on it in one `fizzy_run` put it beside
them, and a sprite created after it stayed in their pane (checked by
capture)
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
test-sdk-version`, `zig build check-web`; `test-integration` left to CI
- [ ] Windows:
- [ ] Linux:
- [ ] Web:

## Found on the way (not fixed here)

- `fizzy_screen` leaves out some tabs: after a few documents had been
opened beside, a pane drawing six tabs reported two `workbench.tab:`
anchors. It misled a test here into "the document vanished".

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A document that was never saved (pixi's `untitled-1`, made by
`pixi.create` with no path) was lost when fizzy was killed or crashed.
On 2026-10-15 a 64×64 drawing went this way: after `kill -9` and a
relaunch, the log said `text: loadDocument failed: FileNotFound`, the
pane showed "Couldn't open untitled-1", and `recovered/` was empty.

## Why

The checkpoint (`Checkpoint`) and the restart session (`KeptDocuments`)
keep each document's `captureDocumentState`, plus `documentBytes` for a
dirty one.

- pixi's state is its layers' pixels, not a whole document, so it can't
fill an empty one.
- The bytes were asked for under `untitled-1`, which names no format, so
there were none.

At the next launch the document was loaded empty for its state to fill,
which pixi refuses. `openKept` then returned null, and `openPath` went
on to open `untitled-1` from disk, routed by its missing extension to
`text`.

## What changes

- **Kept as a save would write it.** A dirty document that has never
been saved is encoded under the name Save As offers. The new
`Plugin.bytesPath(doc)` gives that name (`untitled.pixi`); for a
document with a path it gives the path. The name is kept beside the
bytes as `save_as` in `session.zon`. The field defaults to empty, so
older sessions still read.
- **Reopened through its owner, untitled.** A new optional hook,
`loadUntitledDocument(title, bytes_path, bytes, out_doc)`, opens the
bytes as an untitled document with its title (`untitled-1`), dirty. Its
state is then restored over them. If the owner refuses that state, the
document is still whole, since it opened from its own bytes.
- **Never opened as a path.** If the owner has no such hook, or
reopening fails, `openKept` now returns `lost` rather than null. The
contents go to a recovery file named by the title plus the encoded
extension (`<t>-untitled-1.pixi`, which opens in pixi), and `openPath`
does not look for a file called `untitled-1`.
- `text` implements the hook.

## Plugins

- **pixi:** a 14-line `loadUntitledDocument` (`fromBytes(bytes_path,
…)`, then the title). It lands in fizzyedit/pixi after this releases.
Until then pixi's untitled sprites go to a correctly named recovery file
instead of nowhere.
- **agent:** the session recorder (`src/Recording.zig`) called
`documentBytes(doc, path)` and so recorded nothing for untitled
documents. That's the same root cause; it switches to `bytesPath`, which
also names the tape's mounted file so it replays in its owner. Both
plugin changes are ready locally and go up with the repin.

## SDK

Labelled `sdk`. The fingerprint moves (new optional vtable field
`loadUntitledDocument`); `sdk_version` is untouched. For plugin authors:

- `Plugin.bytesPath` is the path to hand `documentBytes` when you need a
document's bytes outside a save: a recording, or a copy you keep.
- An owner whose untitled documents should come back from a crash
implements `loadUntitledDocument`.

## Verified

- **Integration test** (`tests/integration.zig`, beside the restart
test). An untitled, dirty text document is kept with no state, so only
its bytes are left. It comes back untitled, dirty and whole. With the
hook disabled, the same test fails with `text: loadDocument failed:
FileNotFound`, the error from the incident.
- **macOS, sandbox profile of my own** (`--profile`, with pixi built
from its branch against this SDK via `--fork`):
1. A tape ran `pixi.create` (64×64, untitled) and `pixi.draw` (a rect, a
line, an ellipse).
  2. After a checkpoint, `kill -9`, then a relaunch.
3. `untitled-1` came back in pixi, dirty. The next checkpoint again
lists it as `dirty = true, save_as = "untitled-1.pixi"`.
4. Its exported pixels match the kept `.pixi`'s layer byte for byte: 240
rect, 64 line and 72 ellipse pixels.
  5. No `text:` error, nothing written to `recovered/`.
- `check-fmt`, `zig build`, `test`, `test-integration` (53/53 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>
Glass that keeps its frost until a witness changes
(`BlurBackdrop.Pane.witness`, pixi's
canvas buttons through `core.dialogs.glassBubbles`) now reads again
while the host draws a
region's swap over the view under it.

## What was wrong

Switching a pane's tab from grad.png (a blue-to-pink gradient, image
plugin) to a `.pixi`
sprite left the sprite's glass buttons tinted with the gradient for
good.

The readback was not reading an old frame: a frost reads the frame
target in its deferred
job, after everything beneath it has drawn this frame. What was beneath
it was the host's
swap. Every region swap (`Layout.drawSwapped` → `core.anim.transition`)
draws the outgoing
view's photograph over the incoming view and fades it out over ~0.4 s.
Pixi's buttons are
floating subwindows, so their frost reads the frame after that overlay
has drawn. The
witness (art, canvas rect, button rect, blur style) describes only the
sprite, and it held
still while the photograph faded, so the frost kept whatever frame of
the fade it first read.
In the repro log, the last capture came at fade alpha 0.95, and nothing
re-read after that.

## The fix

The witness can't describe this: the caller doesn't know what the host
draws over its view,
and a scene signature in every witness would put the host's state in
every plugin's hash.
Re-reading once on the frame after the frost forms doesn't fix it
either, because the fade
lasts many frames. Instead, the host says when it draws something no
witness covers:

- `BlurBackdrop.underChanged(rect)`: something no witness describes was
drawn over `rect`
this frame. It is kept in the shared dvui window (as `FrameTarget.want`
is), so a plugin
  image's glass sees what the host drew.
- `CrossFade.draw` marks its snapshots' rects every frame it draws one.
- A kept frost whose capture rect meets a mark from this frame or the
last one re-reads, in
its deferred job (`FrostJob.draw`). That job runs after the overlay has
reached the target.
The extra frame covers the first frame after the fade, when the overlay
has gone.

Cost: one data-store lookup per kept frost per frame, and nothing is
stored while no swap
runs. During a swap, the kept frosts under it re-read every frame, as an
unkept frost always
does.

The next thing that draws over a view in a way its witness can't see (a
carried view's
photo, say) calls `underChanged` the same way.

## SDK

No fingerprint move. Pixi's glass gets the fix once it is rebuilt
against this `core` (next
SDK release + repin). Until then, the host still marks and pixi just
doesn't read the marks.

## Verified

macOS, sandbox profile, pixi `main` built with `--fork` against this
tree. A live tape opens
sprite.pixi and grad.png in one pane, shows the sprite, then shows the
gradient for ~1.5 s,
then switches back to the sprite. The frame was read back from fizzy's
frame target after the
tape (the display was asleep):

- before: the dropper bubble stays blue from the gradient after the
switch
- after: both bubbles show the sprite's canvas

`zig build`, `zig build test` (new unit tests for the marks' this-frame
/ next-frame / expired
lifetime and overflow), `zig build check-tests-reached`,
`scripts/check-fmt.sh`.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ts it back (#444)

## What changes

**Fit mode for `CanvasWidget`** (pixi's sprite, the image viewer).

- A canvas starts fitted to its pane. While it is fitted, a pane that
*settles* at another size refits and recentres it: a document moved to a
narrower pane beside, a window resize, the explorer opening.
- The moment the person moves the view (a wheel over the canvas, a
pinch, a trackpad zoom, a drag-pan, a touch pan), it stops being fitted.
Nothing refits it after that, so their place is kept, as before.
- New content (a different size) starts fitted again.
- `fitToView()` fits it again and keeps it fitted.

Before this, a canvas settled once and never refit. A sprite an agent
created in the person's pane and then moved beside them kept a view
fitted to the wide pane: off centre and cropped in the narrow one
(reported with a screenshot).

**`fizzy.fitView`:** "Fit to View", Cmd-0, in the View menu. It routes
to the active document's owner as `<owner>.fitView`, the way Undo
routes, through a new `fitView` document verb. The image viewer
registers `image.fitView`. pixi's is fizzyedit/pixi (linked below).

Detecting "the person moved it" happens at the input sites, not by
comparing the viewport. The canvas's own pan-slack bookkeeping moves the
scroll position every frame as layout, so a before/after comparison
would misfire.

**Fit to View is animated.** It moves the view eased in and out
(`inOutCubic`, 450 ms at the app's motion
speed) and never past the fit. The app's `settle` spring arrived early
and swung past, which on a view read
as a lunge.

The move is a zoom about the one point on screen it leaves in place.
Every point of the content moves
toward or away from it by one shared factor, so x and y arrive together.
Moving the middle's data point in a
straight line had each axis's edges move at different rates. A move with
no zoom slides.

Where the view starts comes from the canvas's own state (origin + scroll
position + half the box, over the
scale), the inverse of where the fit places it. So a view already fitted
is recognized, and Fit to View does
nothing. Read off last frame's screen rects instead, the start was off
by a zoomed view's scroll bar.

**While fitted, the content follows the pane live.** A pane changing
size refits on every frame it changes,
so dragging a split or the explorer closing zooms it smoothly. Before,
it waited for the pane to hold still
for three frames and restored a snapshot of the old fit in the meantime,
then caught up in one jump. That
waiting and snapshotting still guard a view the person has placed. A
fitted view is worked out afresh each
frame, so there is no drift to guard against.

The person's pan or zoom ends a move where it is. With motion off, a fit
is the jump it was.

## SDK impact

- [ ] None
- [x] Core-only or additive: reaches plugins at the next SDK release
(core's `CanvasWidget`; the fingerprint is unmoved). pixi's command uses
it through an `@hasDecl` guard, so pixi builds on the released SDK
meanwhile.
- [ ] Fingerprint moved
- [ ] SDK release

## Verified

- [x] macOS, a profile of my own, with this change merged locally with
#442 and pixi built `--fork`:
- an agent created a 64×64 sprite and drew on it; it landed beside the
person's sprite fitted and centred
- after `pixi.view {zoom: 20}` on it (no longer fitted), toggling the
explorer twice resized both panes: the zoomed sprite kept its zoom, and
the untouched one stayed fitted and centred
  - Cmd-0 on the zoomed sprite fitted and centred it again
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
test-sdk-version`, `zig build check-web`; `test-integration` left to CI
- [ ] Windows:
- [ ] Linux:
- [ ] Web:

Animated fit and live refit: macOS, sandbox profile, measured with a
temporary per-frame log of the sprite's
screen rect (removed):
- **A fit from 12x on the bottom left:** each axis's share of its
distance per frame matched (0.24/0.24,
0.49/0.49, 0.83/0.83 … 1.00/1.00). No frame passed the fit, and the
first frame didn't jump.
- **A fit on a fitted view:** nothing moved.
- **A fit from 24x on the top left, then from the bottom right:** each
starts from where the view was.
- **Closing the explorer with the sprite fitted:** the sprite re-centred
on every frame of the pane's
  175 ms widening (1193 → 933 px). It didn't jump at the end.

## Follow-ups

- A Fit to View glass bubble among pixi's canvas bubbles, and the corner
controls as glass bubbles (asked for; next).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #444 (Fit to View); review that first. This PR's own diff is
the top commit.

## What changes

The round buttons that float over the app are glass bubbles now, the
same glass as pixi's canvas
controls and the demo player's transport:

- **The places' corner button (Arrange)** is a 36 pt glass bubble with
lucide's `layout-grid`, in
place of the bordered disc with entypo's grid. Glass has no opacity, so
where the disc faded, the
  bubble grows in from its centre, with its shadow.
- **The explorer's peek collapse button** is a bubble, in place of a
filled disc with a box shadow.
- **The image viewer has Fit to View** as a bubble at the viewport's top
left, where pixi keeps its
  canvas controls. It runs `fitToView` from #444.

`core.widgets.glass_button` is pixi's `glass_button.zig`, moved into
core so the shell and every
plugin can draw it: `options`, `background`, `wash`, `witness` (a
signature of what is under the
glass, so the frost is read again only when that changes), and `bubble`
(a viewport bubble in one
call: floating, clipped to its screen, icon, tooltip; true when
clicked). A toggled-on button
stays glass: its icon takes the highlight (`iconColor`), where pixi's
covered the glass with a
highlight-filled disc. Pixi keeps its own copy
until it repins onto an SDK carrying this.

The image plugin now depends on `icons`, as text does:
`build/plugins.zig`'s `link` passes it to the static module and
`build/exe.zig` to the dylib.

## Pitfalls hit

- A dvui button has a 4 pt margin by default, so a 36 pt `.rect` drew a
28 pt bubble with an icon
  padded nearly away. The corner button sets `.margin = .{}`.
- A floating widget made inside a `CanvasWidget` takes the canvas's
zoom. The Fit bubble is drawn
after the canvas closes, from the viewport rect taken while it was open.

## SDK

No fingerprint move. `core/` is new for plugins, and it reaches them
with the next SDK release.

## Verified

macOS, a sandbox profile (`--profile`), with captures through the agent
plugin:
- the corner bubble at 36 pt with its icon, lit under the pointer;
- the image viewer's Fit bubble at the viewport's top left.

The explorer's collapse button shows only while the explorer is peeking
(a narrow window). It
compiles but I didn't see it on screen.

Gates: `check-fmt`, `zig build`, `test`, `test-integration`,
`check-web`, `test-sdk-version`.

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

---------

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

Part of fizzyedit/plans#16 (step 1).

## What changes

The file tree keeps what is the tree's own, and stops being reached into
through the SDK. Three `Host`/`EditorAPI` entries that existed only for
the workbench go.

- **Expanded folders are the tree's.** `Explorer.open_branches` moves
onto the workbench's `Workbench` instance (`open_branches`,
`setBranchOpen`), and `explorerBranchIsOpen`/`setExplorerBranchOpen` go.
The instance is shared by both copies of the plugin (static and dylib),
which is why the state lives there and not in a module global. Fizzy's
demo snapshots read and restore it on `editor.workbench`; plans#16 step
2 moves that behind something the workbench offers.
- **A new file is revealed through the `files` service.**
`setExplorerNewFilePath` goes. The `files` service (now `service_version
= 2`) gains `reveal(path)`: "show this just-made path as a new file".
Fizzy's implementation drops the listing cache, because the caller
usually wrote the file itself, and emits `fizzy.files.reveal` (declared,
`Path` payload). The workbench subscribes and queues the reveal, so the
row opens selected, its name in inline rename, with the closing dialog
aimed at it, as before. Any other file view could do the same. The
workbench's own New Folder sets its pending path directly.
- **The tree's stash id is made in its own draw.** `file_tree_data_id`
was set and read within one draw, so fizzy clearing it whenever Files
was hidden (`resetFileTreeWhenFilesHidden` → `clearFileTreeDataId`) did
nothing. It is a local now, and both functions go.

Event delivery is queued until the next frame, so a reveal lands one
frame later than the direct write did. The tree already waits for the
row to be laid out, so nothing visible changes.

**Store plugins:** pixi (`src/dialogs/NewFile.zig`) and atlas
(`plugin.zig`, New Note) called `setExplorerNewFilePath`; both now call
`files.reveal`. Those are local jj changes in their repos, built against
this tree's SDK (`zig build --fork=<this>/sdk`), to push with the repin
to the release that carries this. No other store plugin, example-app or
example-plugin uses the removed entries or the `files` service.

**Overlap:** #446 also touches `src/editor/Explorer.zig` (the collapse
button). This change only removes `open_branches` and the hidden-tree
check there.

## SDK impact

- [x] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`

For plugin authors: `Host.setExplorerNewFilePath(path)` →
`host.getServiceTyped(sdk.services.files.Api)` then `.reveal(path)`.
`explorerBranchIsOpen`/`setExplorerBranchOpen` are gone, with no
replacement: the tree's open folders are the workbench's own state. A
plugin built against `files` service version 1 gets null from
`getServiceTyped` on this host (versions are refused, not cast), so it
degrades rather than misbehaving.

## Verified

- [x] macOS:
- **Gates:** `zig build`, `test` (91/91 steps), `test-integration`
(53/53 steps), `check-tests-reached`, `test-sdk-version`, `check-web`,
`check-fmt`.
- **New integration check** (in the whole-editor test): a file written
outside the service and passed to `files.reveal` is heard by the tree,
then taken once its row is laid out. Pointing the workbench's
subscription at another event name makes it fail at `expect(heard)`, so
it tests the round trip.
  - **Plugins:** pixi and atlas build against this SDK.
- [ ] Windows:
- [ ] Linux: CI
- [x] Web: `check-web`

Not looked at by eye: the dialog flying into the revealed row. The test
covers the tree taking the path, which is the step that re-aims the
dialog.

## Follow-ups

- plans#16 step 2: the Explorer's file parts move into the workbench,
Explorer becomes `SidebarPane`, and demos reach the tree's state through
the workbench.
- Push pixi's and atlas's changes with the repin PRs after the next SDK
release.

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

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

No plan issue: a bug report from pixi's layer list (2026-10-15).
Dragging a layer row out of the list kept something animating for as
long as it was held, and the carried row was not the glass a tab or a
file is when it is carried.

## What changes

**The loop.** The slot a lifted tree row leaves behind kept the row's
width from the moment it was lifted. While a row is dragged, pixi's list
adds a drop row below the last one. That makes the list taller, its
vertical scroll bar comes in and narrows the list, and the slot
overflows it sideways. The horizontal bar that follows changes the
list's height. The layers/palettes split, which fits that height with a
slide, slides, the vertical bar goes, and round again. The slot now
takes the list's width where the row did (`expand` horizontal) and keeps
only the row's height.

**The glass.** `TreeWidget`'s floating row always drew the app's frost
(`dialogs.carriedGlass`). Every other carried thing goes through
`ViewDrag`, which on macOS 26 is the OS's Liquid Glass in fizzy's carry
overlay. The new `dialogs.carriedCard` gives a carried thing drawn in a
floating widget of its own the same treatment:
- Where the OS offers its glass outside a view drag
(`native_glass.offered`, as pixi's dropper magnifier already uses), the
card is declared to it and the layer goes over the glass
(`screens.markCarried`).
- Elsewhere it falls back to `carriedGlass`, as before.

pixi draws nothing of its own for the carried layer; the row it carries
is core's. So the fix reaches pixi, with no change in pixi, when the
next SDK release repins it.

## SDK impact

- [ ] None
- [x] Core-only or additive: reaches plugins at the next SDK release
(the fingerprint does not move; `test-sdk-version` passes)
- [ ] 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 (26, Debug): a sandbox profile running this build, with pixi
built `--fork` against it. A sprite with three layers (`pixi.create`,
`pixi.addLayer` ×2); a layer row dragged out over the canvas and held
still for 3 s, measured with fizzy's profiler (`fizzy_profile` through
the agent plugin):
- before: the docking split's fit slide (`DockingWidget.zig:542`) asked
for every one of 202 frames, the list's scroll-drag
(`ScrollContainerWidget.zig:418`) and overscroll bounce (`:209`) about
50 each, plus ~100 min-size refreshes. Captures 0.4 s apart showed the
scroll bars coming and going and the rows shifting sideways.
- after: the only frames left come from the test driver holding the
pointer (`LiveDriver`), and the captures are byte-identical.
- glass: a screen capture mid-drag shows the carried row as the OS's
Liquid Glass capsule, refracting what is under it, with the row's
content over it. Released, the row drops back into the list.
- `check-fmt`, `zig build test`, `test-integration` (53/53 steps),
`check-web`, `test-sdk-version`.
- [ ] Windows: not run. There is no `native_glass.offered` there, so
`carriedCard` is `carriedGlass` as before.
- [ ] Linux: not run (CI). Same as Windows.
- [ ] Web: `check-web` builds; not run in a browser. Same path as
Windows.

## Follow-ups

- `ReorderWidget`'s floating item still carries a plain box shadow
rather than a carried card. It is a candidate for `carriedCard` too;
left out to keep this change to the tree.

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

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

A chat streaming a reply in (fizzyedit/chat) jittered: the conversation
dropped by a line
and came back, many times a turn. The cause is in the markdown service's
inline render
(`drawInline`, which a chat bubble uses).

`renderTopLevel` pins a block's emitted height to its height-table
entry. That keeps a
virtualized preview still while blocks come back from spacers.
`drawInline` passes no
viewport, so nothing is virtualized and every block is laid out every
frame, but the pin
still applied. A streamed reply changes its text on every chunk, and the
chat's type-in
changes it on every frame. Each change re-parses, which clears the
table:

1. Frame A: the edited block has no entry and is laid out at its real
height. If its text
layout is new (a paragraph that just started), the measurement is filed
`.deferred`,
   which keeps the estimate.
2. Frame B: the block is pinnable (`.deferred`), so the pin emits the
estimate, which is a
   line or so short.
3. Frame C: real again.

The caller sees H, then H minus a line, then H. With the chat following
the bottom, the whole
conversation moved down by that line and back.

The fix gates both pins on `virtualize`, so a render with no viewport
emits what it lays out.
The preview pane, which always has a viewport, is unchanged.

How it was seen: fizzy from main (Debug, macOS) in a sandbox profile,
with this markdown dylib
in place of the bundled one. A temporary driver in the chat plugin
streamed 8 scripted turns,
in 4 to 40 byte chunks every 20 to 140 ms. A per-frame log recorded each
message row's rect,
min size and the scroll position.

| over 20 s of streaming | before | after |
|---|---|---|
| frames the conversation moved back down | 15 | 0 |
| content-height changes that were shrinks | 22 of 78 | 0 of 36 |
| frames a message's row was a frame behind its own size | 75 | 35 (only
growth, one frame late, as dvui lays out) |

Example, one reply's row height across three frames with the same text:
98.1, then 80.0, then
98.1 before; 98.1 throughout after.

Test: the headless integration test's inline-render check now streams a
reply in 9 bytes a
frame and fails if the height ever drops. Without the fix it fails
("shrank from 36.4 to
19.6 at byte 18"); with it, `zig build test-integration` passes 53/53
steps, and so do
`zig build test` and `scripts/check-fmt.sh`. `check-web`,
`test-sdk-version` and the full
app build were left to CI: no SDK or build file changes.

No plan step; a fix found while measuring the chat. The chat's own
resize fixes are
fizzyedit/chat#14 and don't depend on this.

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

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

Compiles the SPIR-V and DXIL that fizzy's native backend embeds (Vulkan,
D3D12) from their HLSL in CI, and fails when what is committed is not
what the HLSL compiles to, uploading the fresh files as an artifact to
commit.

**Why.** Nothing noticed when an HLSL changed and its compiled files did
not: Windows and Linux quietly drew the previous glass after #227 (fixed
by hand in #242), and again since #373, whose shader changes are in the
HLSL but not in the DXIL or SPIR-V. Its first run here found worse:
main's glass HLSL has not compiled since #373 (`float3(x)` from one
scalar is Metal and GLSL, not HLSL). Fixed here (`(float3)x`).

**The toolchain** is SDL_shadercross at `1ff05bec`, with the
DirectXShaderCompiler release it pins itself (Microsoft's prebuilt
v1.9.2602, by SHA-256), built as shadercross's own CI builds it: only
SPIRV-Cross and shadercross, a few minutes, cached by the pin. With it
the backend's default shaders, unchanged since they were compiled,
reproduce byte for byte, SPIR-V and DXIL: the committed files were made
with this compiler. (An earlier run built shadercross's vendored DXC
from source instead, half an hour: its SPIR-V matched, its DXIL differed
in DXC's version string.)

**The glass** SPIR-V and DXIL are compiled again from #373's HLSL (with
the fix above). The DXIL's interface (input and output signatures,
pipeline state, feature flags) is identical to the old one's, so the
backend's pipeline for it is unchanged.

**First step of making the glass one source.** Next, in its own PR: the
glass written once, in HLSL, its Metal and WebGL GLSL generated from it
by SPIRV-Cross and checked by this same job, so the forms cannot drift
apart as they did here.

**Not seen on Windows or Linux yet**: the new glass on D3D12 and Vulkan
wants a look on those machines. `zig build` and `zig build check-web`
pass on macOS.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#13 (step 2). Builds on #441.

## What changes

An app built on fizzy can run with linked plugins only:

```zig
const fizzy_dep = b.dependency("fizzy", .{ …, .@"defer-app" = true, .@"dynamic-plugins" = false });
```

(or `zig build -Ddynamic-plugins=false`). In that build:

- **No plugin dylib is built or installed.** fizzy's four bundled ones
are skipped, as are the packaged copies.
- **Nothing loads at runtime.** One comptime constant,
`App.loads_plugins`, turns off:
  - the bundled-dylib probe in `postInit`
  - the user plugins directory scan (`loadUserPlugins`)
  - loading a plugin by id (the store's and settings' enable path)
  - a web page's `?plugin=` request
- **No store.** `PluginStore.register` still sets the `PluginManager`
(settings name plugins through it), then returns before the catalog
fetch and the store's view. `tick` returns at once.

Every gate is comptime, as wasm's are, so a dynamic build checks nothing
at runtime. Most other dynamic paths go quiet on their own when nothing
is `dlopen`ed: the rebuilt-plugin watcher walks only the loaded
libraries.

CI compiles the link-only build on Linux (`-Ddynamic-plugins=false
-Dno-emit`, about 10 s locally), because its gates are comptime and the
default build never compiles that branch.

## SDK impact

- [x] None

## Verified

- [x] macOS:
- **Build:** `zig build -Ddynamic-plugins=false` installs only the
executable, with no `plugins/` directory.
- **Runtime:** a `text.dylib` planted beside the executable and in the
profile's user plugins directory is not mapped into the link-only
process (`vmmap`). The default build, run with the same profile, maps
all four of fizzy's dylibs.
- **Screenshots:** the link-only window has no store icon in the rail,
and the default build has it above the settings cog.
- **Gates on the default build:** `zig build`, `test` (91/91 steps),
`test-integration` (53/53 steps), `check-tests-reached`,
`test-sdk-version`, `check-fmt`.
  - **Web:** `check-web` with and without the flag.
- [ ] Windows:
- [ ] Linux: CI (including the new link-only compile)
- [x] Web: compiles link-only; not run in a browser

Not exercised: opening Settings in the link-only build. Its plugin rows
read through the `PluginManager`, which is set as before.

## Follow-ups

- **Removing the `buildApp` fallback** (a list naming none of fizzy's
plugins gets all of them) waits until fizzyedit/example-app#6 is on
example-app's `main`, because fizzy's CI builds that `main`. It's a
small PR after the repin.
- **example-app: a link-only build** in its CI and in
`check-examples.sh`, after this merges, for the same ordering reason.

🤖 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 1: the tape
format.

## What changes

A tape can carry **a document's own history**, not just input:

- **`step` op** (`Tape.Step{ file, effect, motion }`): applies one
change's effect, in its owner's encoding, to a file of the keyframe
before it, shown being made over the op's `ms`. Effects replay exactly
at any window size and in any layout; input only replays in the window
it was recorded in. Steps land with **no frame between them**, so a seek
across an hour of drawing is one sequencer call.
- **Files carry bytes and an owner**: `File.text` may be a binary
document's bytes as on disk (ZON escapes them; round-trip tested), and
`File.owner` / `File.format` say which plugin applies its steps and in
which of its encodings.
- **`Check.steps`**: the app says whose steps, in which format, it
applies. Without it, a tape with steps is refused
(`error.StepsUnsupported`). A misspelled chord shows when pressed; a
step nobody applied would play a document that never changes and look
fine.
- **`Stage.step`** (optional): the replay player hands each step to the
stage, with `ms = 0` while a seek or catch-up runs. **Fizzy's stage does
not apply steps yet**: its `Input.check` has no `steps`, so fizzy
refuses such tapes for now. That lands with the tracks (plan steps 3–6).

**Binary form version 3:**
- Steps; files' owner and format; a **blob table** for effects and
motion. Not interned: effects almost never repeat, and hashing every
byte to find out costs more than it saves.
- An optional **deflated** body (`binary.Options{ .deflate = true }`):
the inflated length, then one zlib stream.
- Versions 1 and 2 still read. The v2 test uses a real file written by
`main`'s codec (`sdk/tape/testdata/everything-v2.tape`), not a patched
version number, since v3's layout differs.
- **std 0.16's inflate has two defects** a shared file must not reach.
It asserts past the end of its input on a stream cut short
(`tossBitsShort` adds consumed bits where it should subtract them), and
it reads the zlib checksum but never compares it. So the reader feeds it
zeros without end past the stream, caps the output at the header's
length (+1, to catch a stream that runs on), and checks Adler-32 itself.
Worth re-checking at the 0.17 move.

**Moved from the plan's step 1:**
- Keyframe "documents as state" became file bytes plus an owner. A
shared tape must create its document from nothing, and
`captureDocumentState` only restores in place.
- The idle-gap rule is deferred to the step that plays tracks. Nothing
plays steps until then, and it is the player's rule, not the format's.

**API:** `binary.write` / `binary.encode` take an `Options` argument
(call sites updated; nothing outside this repo calls them, checked in
example-app, example-plugin, agent and pixi).

Measured, `zig build bench-tape -Doptimize=ReleaseFast`, synthetic
strokes shaped as pixi's effect will be (pixi's real encoding is plan
step 4):

| Steps | Plain | Plain load | Deflated | Deflated write | Deflated load
|
|---:|---:|---:|---:|---:|---:|
| 2,000 (≈ 76 min of drawing) | 275 KB | 0.04 ms | 100 KB | 8.7 ms |
0.86 ms |
| 20,000 | 2.7 MB | 0.25 ms | 873 KB | 87 ms | 7.9 ms |
| 100,000 | 13.3 MB | 1.5 ms | 4.2 MB | 432 ms | 40 ms |

## 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 doesn't move (`test-sdk-version` passes): tapes cross
the plugin boundary as bytes. The `tape` and `replay` libraries ship in
the SDK tarball, so a plugin that calls `tape.binary.encode` gets the
new `Options` argument at the next release (none does today).

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build
test-sdk-version` and `zig build check-tests-reached` all pass.
- `zig build test`: 557/558 (1 skipped). `zig build test-integration`:
53/53 steps, 525/525 tests.
- New tape tests: steps' validation and ZON round-trip, the sequencer
landing steps together, binary v3 round-trip plain and deflated, v1/v2
reading, truncation at every length and 4,000 random flips per form. A
one-off 100,000 flips per form under ReleaseSafe passed.
- [ ] Windows: not run (std-only format code).
- [ ] Linux: CI.
- [ ] Web: `check-web` builds. Nothing plays steps there yet.

## Follow-ups

- Plan step 2: `encodeStep` / `applyStep` on the document vtable, text's
implementation.
- The idle-gap playback rule, with the step that plays tracks.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
**Stacked on #405** (its commits show here until it merges).

The glass written once, in HLSL; its Metal and WebGL GLSL generated from
it and checked by #405's job, instead of three programs kept in step by
hand. Keeping them in step is where the shader bugs came from: #373's
`float3(x)` was valid Metal and GLSL and broke the HLSL, and the
compiled forms went stale twice.

## What changes

- **`scripts/compile-shaders.sh`**: every `X.hlsl` with compiled forms
committed beside it, to each of them. SPIR-V, DXIL and Metal by
shadercross (the Metal SDL_GPU takes, as the backend's own default
shaders already shipped). GLSL ES 3.00 for WebGL2 by spirv-cross from
the SPIR-V, with NaN checks relaxed (HLSL's `min`/`max` otherwise come
out wrapped in `isnan` tests at every pixel), float precision high, and
the names the web binds (`vColor`, `vTextureCoord`, `uSampler`, `uTex1`,
`uData`). The workflow runs it and compares every form.
- **The glass loads the generated Metal and GLSL** (`LiquidField`); the
hand-written `liquid_glass.metal` and `liquid_glass.glsl` are gone. The
HLSL is the one source and carries the explanations the GLSL had.
- **The web compiles a complete generated source as it is**
(`web/web.js`, a `#version` line), on WebGL2 only: on WebGL1 the program
fails and the glass falls back to its meshes (generated loops are not
WebGL1's constant-bounded ones).
- `program_example`'s committed Metal, hand-written to show the
interface, is now generated like the rest (same interface).

## Verified

- **CI's compile check**: the backend's default shaders reproduce byte
for byte in all three of SPIR-V, DXIL and Metal with this toolchain.
- **macOS**: the same in-app About dialog captured through the agent
with the hand-written Metal (#405's build) and the generated one (this
build): plain glass differs by a mean of 0.18 of 255, at most 2 (the
dither); elsewhere only edges, where the dialog's contents sat a
fraction of a pixel apart between the two captures.
- **Web (Chromium, WebGL2)**: the generated GLSL compiles and links
against the web's own vertex shader, and `uData`, `uSampler`, `uTex1`
and `uMatrix` all resolve; this build's menus draw their glass through
it, no program failed, and it looks as main's did.
- `zig build`, `zig build test` (91/91 steps), `zig build
test-integration` (53/53 steps), `zig build check-web`.
- Not seen: Windows (D3D12) and Linux (Vulkan), as for #405; Firefox and
Safari on the web.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fizzy returns to the MIT license it had from 2022 until May 2026, and
writes down what that promises.

**Do not merge until Aryan-982 agrees to relicense #327** (4 lines in
`build/app.zig` and `tests/bench/bench_replay.zig`, the only outside
contribution made under GPL-3.0). `LICENSING.md`'s History section says
that consent was given.

## Why

GPL-3.0 worked against what Fizzy is: plugins are `dlopen`'d into the
editor's address space, which makes a closed or paid plugin legally
murky, and every app built with `fizzy.buildApp` would have to be GPL.
The goal is the opposite: the editor and framework free forever, with
plugins and applications free to be commercial.

## What changes

- `LICENSE`: MIT, `Copyright (c) 2022-2026 Colton Franklin and Fizzy
contributors`.
- `LICENSING.md`: every release stays MIT; Fizzy itself never goes paid,
open-core or "community edition"; the SDK never needs a key, account or
payment to load a plugin; plugins and apps may be closed and paid; the
store may list paid plugins; contributions are inbound = outbound, no
CLA.
- `TRADEMARKS.md`: what MIT doesn't cover. "a plugin for Fizzy" and
unmodified redistribution are fine without asking. A modified build
needs its own name and icon.
- `CONTRIBUTING.md`: a short "Licensing contributions" section, plus a
row in "Where knowledge lives".
- `readme.md`: a License section.
- `scripts/pack-sdk.sh`: the SDK tarball now includes `LICENSE` and
lists it in `.paths`, because plugins vendor the SDK's source and MIT
asks that copies keep the notice. This takes effect at the next SDK
release and doesn't touch released tarballs.

Sole trademark holder for now: Colton Franklin. Moving the trademark to
a fiscal host or foundation later is the natural next step if the
project wants that.

## How it was verified

- `scripts/pack-sdk.sh` produces a tarball containing `LICENSE`, with
`"core"` and `"LICENSE"` in `.paths`.
- `zig fetch` on the tarball hashes it as the `fizzy-0.2.27` package.
- No Zig source changed.

## SDK

No boundary change; the fingerprint and `sdk_version` are untouched.

🤖 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 2: owners'
steps, with text's implementation.

## What changes

A document's owner can report each change to its history as it happens,
and apply recorded ones.

- **`sdk.Step`**: `{ kind, id, effect, motion, ms }`. The kinds are:
  - `added`: a new history entry.
- `joined`: more of the entry on top, such as a run of typing. Only the
new part goes in `effect`.
  - `undone`: an entry taken back. Its effect is the entry's reverse.
  - `redone`: an entry put back.
  - `restarted`: the history began again (a reload or a restore).

Every effect applies **forward**, so a replay is effects in order, and
the tape format (#453) needs no direction.
- **`Host.recording(doc_id)` / `Host.recordStep(doc_id, step)`**: the
owner asks first, so a document nobody records encodes nothing.
**`Host.recorder`** is the app's seam: plan step 3's tracks install it.
Nothing installs it yet, so this PR changes no behaviour at runtime.
- **`Plugin.step_format`** (the owner's encoding, 0 = none) and
**`VTable.applyStep(doc, effect, motion, ms)`**: lands a step as a new
history entry. A step that doesn't fit what the document holds is an
error, and the document is left as it was. `ms` is the seam for showing
it made at pace; text lands it at once for now.
- **Text** implements both (format 1):
- A step is a transaction's edits (LEB128 position, removed bytes,
inserted bytes) and the selection after
(`textcore.Transaction.encodeStep` / `StepView` / `applyStepTo`).
- Removed bytes are kept so every step is checked against the text
before anything lands, and so it can be reversed.
- `History.end` and `pushComplete` now return what they did
(`Committed`), and `lastApplied` / `lastUndone` name the step undo or
redo moved. The typing path (`TextEditor.editNotifyEnd`),
`replaceRange`, undo, redo, reload and restore all report.

**Changed from the plan:** the owner *pushes* steps rather than the host
pulling them through an `encodeStep` hook. Pulling needs the host to
hear of each step first, which is `SHARED_HISTORY_PLAN`'s
`historyStepped`, not built yet (plans#15 is still a proposal). Pushing
needs nothing, and only the owner knows whether a keystroke joined its
run. When authors land, a step's author is the host's to attach by `id`.
I'll amend the plan.

## 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

For plugin authors: all of it is additive. `step_format` defaults to 0
and `applyStep` to null, so an owner that records nothing changes
nothing. Fingerprint `0x92a6bd46850d47c3` → `0x6fc6a45bf3a13b00`.

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build
test-sdk-version` and `zig build check-tests-reached` pass.
- `zig build test`: 561/562 (1 skipped). `zig build test-integration`:
53/53 steps, 529/529 tests.
- A new integration test records a document while typing a key at a time
(`added`, `joined`), a command's insert, undo, redo, undo, then an edit
after going back. It checks the seven kinds in order, plays them into a
fresh untitled document to the same text, checks an unrecorded document
reports nothing, and checks a step that doesn't fit is refused with the
document untouched.
- textcore unit tests: forward and reverse round-trips, refusal,
truncation at every length, 4,000 bit flips, and commits reporting
joined versus added.
- [ ] Windows: not run.
- [ ] Linux: CI.
- [ ] Web: `check-web` builds. Nothing records there yet.

## Follow-ups

- Plan step 3: the host's tracks install the recorder (timing,
checkpoints, the focus track, profiling).
- Pixi's steps (plan step 4) after the SDK release that carries this.
- Showing text's steps typed out at pace (`ms`), and text's `motion`
(per-keystroke timing), are seams for later.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A Debug fizzy at `main` crashed a few seconds after a fizzy-mcp run:

```
panic: index out of bounds: index 0, len 0
sdk/tape/Sequencer.zig:185:23 in advance: `const op = ops[self.cursor];`
sdk/replay/LiveDriver.zig:162:25 in frame
```

## Why

`LiveDriver.frame` asks the sequencer for `seq.nextAt()`, which is `inf`
when nothing is left. `Sequencer.advance` decided it had reached the
target with `next_at > until`, but with both infinite that is false, so
it read `ops[cursor]` past the end.

In `LiveDriver.frame` only an **empty** tape can be there: a tape that
runs out is finished in the same frame (`seq.done()`). An empty live
tape is valid. `Tape.validate` has a test for it (`Tape.zig`, "empty"),
and builders make them: the agent plugin's `Script.stroke` with no
points adds nothing, so a run whose tape steps add no ops plays an empty
tape. So I didn't add a guard in the driver or reject empty tapes. The
sequencer's end-of-ops test is now explicit (`cursor >= ops.len`), and
`now` never takes an infinite target. An empty tape now finishes on its
first idle frame (`.finished`), like any other.

## Testing

- New `sdk/tape` test, "asked for what is next with nothing left, it has
reached the end": it advances an empty tape, and a spent one-op tape, to
`nextAt()`. Without the fix it panics with the same `index 0, len 0`.
- `scripts/check-fmt.sh`, `zig build test` (91/91 steps), `zig build
test-sdk-version` pass locally. `test-integration` and `check-web` are
left to CI; the change is std-only sequencer code.

No SDK boundary change (the fingerprint is unchanged).

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

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

A plugin whose ABI fingerprint differed from the host's while its
`sdk_version` matched was reported as **"SDK versions match, but
optimize mode does not match"**. That was true only half the time. Under
the SDK release train, feature PRs move `recorded_sdk_shape_fingerprint`
and leave `sdk_version` alone. A Debug Fizzy built from `main` after an
`sdk` PR (#442 after 0.2.27, for example) therefore refused Debug
plugins built against `sdk-v0.2.27`, and showed the optimize-mode
message even though both were Debug builds. Building the plugins with
`zig build --fork=<fizzy>/sdk` fixed it.

## What changes

The fingerprint is only `sdk_shape_fingerprint` folded with
`optimize_safety_class`. Arch and OS are not part of it, even though the
loader's old comment said they were. So the host can tell the two causes
apart with no new export: it recomputes the fold for the other class.

- `sdk.dylib.optimizeClassOf(plugin_fp)` returns the class the plugin's
fingerprint was folded with when its shape matches the host's, and null
when the shape differs. It has a unit test.
- `AbiBuildEnvMismatch` is split into two errors, in both the desktop
and the web loader:
- **`AbiOptimizeMismatch`** (same shape, other class). It names both
modes: *"built ReleaseFast or ReleaseSmall, but this Fizzy is a Debug
build — rebuild the plugin in Debug, or run a release build of Fizzy"*,
or the reverse.
- **`AbiShapeMismatch`** (different shape, same version): *"same SDK
version but a different SDK shape — a Fizzy built from main past a
release needs plugins built with zig build --fork=<fizzy>/sdk (or this
Fizzy is older than the plugin's SDK)"*.
  - `AbiMismatch` (different shape and version) is unchanged.
- The optimize class is checked first, so a plugin from another release
in another mode is reported as the mode problem it actually is.
- The store's "Needs release" wording was already correct: it compares
the host's class with the store's ReleaseFast-only builds at comptime.
Only its doc comment changes, to the new error name.
- `plugins/AGENTS.md` gets the new pitfall: a Fizzy built from main past
a release needs `--fork`.

## SDK

This adds a public function to `sdk/src/dylib.zig` but doesn't move the
fingerprint (`test-sdk-version` passes), and `sdk_version` is unchanged.
Plugins see no change.

## Verified

macOS: `check-fmt`, `zig build`, `zig build test`, `test-sdk-version`,
`check-web`, `check-tests-reached`, and `test-integration` (53/53
steps). The two cases are covered by the `optimizeClassOf` unit test. I
didn't load a mismatched dylib by hand.

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

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

`sdk_version` goes from 0.2.27 to 0.2.28. Merging this tags
`sdk-v0.2.28` and publishes its tarball.

**Why now:**
- **Agents:** fizzyedit/agent#16 (an agent's edits don't lock the
document, and a document an agent makes opens beside the person) needs
#442's `OpenOptions.beside`.
- **Pixi's CI:** fizzy `main` dropped `setExplorerNewFilePath` in #449,
so every pixi PR fails the `fizzy-main` check until pixi repins to a
release that has `files.reveal`.
- **Untitled sprites:** #447's `loadUntitledDocument` lets a plugin's
untitled documents come back from a crash. Pixi needs a release carrying
it to implement that.

## SDK impact

- [ ] 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`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**`sdk` PRs since `sdk-v0.2.27`:**
- **#442:** a plugin can open a document beside the person.
`EditorAPI.OpenOptions.beside` (one new defaulted field), and
`Host.openBeside` / `endOpenBeside`, so a command run on someone's
behalf opens what it makes beside them.
- **#447:** an untitled document survives a crash or a restart. A new
optional vtable member, `loadUntitledDocument`, for an owner whose
untitled documents should come back. `Plugin.bytesPath` is the path to
hand `documentBytes` for a document's bytes outside a save.
- **#456:** owners report a document's steps, and apply recorded ones
(`Step`, a vtable `step_format` and `applyStep`, Host events). All
additive: `step_format` defaults to 0 and `applyStep` to null, so an
owner that records nothing changes nothing.
- **#449:** the file tree keeps its own folders, and `files.reveal`
shows new files. `Host.setExplorerNewFilePath(path)` becomes
`host.getServiceTyped(sdk.services.files.Api)` then `.reveal(path)`. The
`files` service is now version 2. `explorerBranchIsOpen` and
`setExplorerBranchOpen` are gone, with no replacement.

**Reaching plugins, no fingerprint move:**
- **#444:** `core`: a canvas the person hasn't moved stays fitted to its
pane, refitting live as the pane changes size. `fitToView` animates
there.
- **#446:** `core`: `core.widgets.glass_button`, round glass buttons and
viewport bubbles.
- **#450:** `core`: a kept frost reads again under a region's swap.
- **#451:** `core`: a row carried out of a tree is the OS's glass, and
stays still.
- **#453:** `tape`: steps of a document's history, and a compressed
binary form. **`tape.binary.encode` takes a third argument, `Options`**
(`.{}` keeps the old output).
- **#457:** `tape`: a live tape with no ops finishes instead of
crashing.
- **#424:** `core`: the glass shader written once in HLSL, its Metal and
GLSL generated.
- **#458:** `sdk/src/dylib.zig`: a refused plugin says whether its build
mode or its SDK shape differs (it blamed the mode for both).
- **#435:** `tape`, `replay`: `builtin.target.os`, `.cpu` and `.abi` in
place of `builtin.os`.

Not in this release: #437 (the Zig 0.17 switch, labelled `sdk`) merged
into the `zig/0.17` integration branch, not `main`. `main` still builds
with Zig 0.16.

## Verified

- [x] macOS:
- On this head, `zig build test-sdk-version` passes, and
`scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.28.tar.gz`.
- Rebased on `main` after #456, #453, #457, #424 and #458. Re-checked on
the new head: `zig build test-sdk-version` passes and
`scripts/pack-sdk.sh` packs.
- fizzyedit/agent `main` doesn't build against it:
`src/Recording.zig:127` calls `tape.binary.encode(gpa, t)`, and #453
added an `Options` argument (its notes say no plugin calls it; the
agent's recording does). The fix is `encode(self.gpa, t, .{})`, in the
agent's repin.
- fizzyedit/pixi `main` doesn't build against it:
`src/dialogs/NewFile.zig:230` calls the removed
`setExplorerNewFilePath`. Expected; see Follow-ups.
- [ ] Windows:
- [ ] Linux: CI.
- [ ] Web:

## Follow-ups

- The repin automation opens `sdk: repin to fizzy SDK 0.2.28` PRs in the
store plugins whose build needs it.
- **pixi's** opens as a draft, since its build breaks. It needs #449's
change: `NewFile.zig` calls `files.reveal`. #449's notes say that change
is ready locally, to push with this repin. **atlas** needs the same, for
New Note.
- **agent's** repin opens as a draft too: it needs
`tape.binary.encode(self.gpa, t, .{})` in `src/Recording.zig` (#453).
- **Then:** once agent repins, fizzyedit/agent#16 can go ready, and pixi
can implement `loadUntitledDocument`.

🤖 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 3, first
half: the tracks, behind a switch.

## What changes

With `FIZZY_TRACKS=1`, the app keeps a **track** for each open document
whose owner records steps (`app.Tracks`, installed as `Host.recorder`;
only text does today):

- **Steps with their moments**: each `sdk.Step` (#456) with the time it
came on the frame clock, its effect and motion appended to the track's
bytes.
- **A base**, the document's state (`captureDocumentState`) when the
track began. It's taken when the document opens, so the first edit is a
step and isn't folded into the base.
- **Checkpoints** after 256 steps, 1 MB of steps, or a 30 s pause, each
with the document's fingerprint.
- **`replayInto(doc_id, target, count)`** puts another document of the
same owner where the track was after `count` steps: the nearest state,
then the steps since, at once. Where a checkpoint stands at `count`, the
replay must match its fingerprint. A replay target records none of it.
- A closed document's track is kept for the session, and a reload
(`restarted`) starts a new one.
- **Which document the person worked in** is noted whenever a step lands
in a different document than the last. Looking at a document without
editing it isn't seen yet; a `fizzy.document.activated` event from the
workbench is the named seam.

**Nothing runs on a clock.** A step costs an append when it comes. A
document's state is never taken from inside an edit, because owners
report steps from inside theirs and the selection may not be settled
yet. Instead a track asks, and the next frame takes it at its top
(`Tracks.tick`, beside `doc_io.tick`), before anything draws. A frame
nothing asked of returns on one branch, and a sleeping app runs nothing.
Profiler scopes: `tracks.step`, `tracks.tick`, `tracks.replay`.

**A text fix this found:**
- Undo, redo and paste set a selection the next draw installs
(`pending_sel`). Text's `captureState` and `fingerprint` read the
widget's caret instead, from before the edit.
- So a checkpoint taken before that draw didn't match its own document,
and a demo snapshot taken then had the same problem.
- They now read `Document.selection()`: the pending selection when there
is one, clamped as the draw clamps it.

**Left for the second half (3b), per the plan's "invisible, or it isn't
done":**
- Sealing steps into chunks deflated off the UI thread, and thinning
checkpoints with age.
- A cap.
- The devtools track view.
- The recording-on vs off frame-time check in `test-integration`, and a
`bench-replay` track case for the budgets (under 20 µs a pixi stroke,
under 2 ms a checkpoint on the UI thread).
- Turning tracks on by default once those numbers hold.

Until then a base per open text document and every step stay in memory,
which is why this is behind a switch.

## 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

(The text fix is inside the bundled plugin; `test-sdk-version` passes.)

## Verified

- [x] macOS:
- `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build
test-sdk-version` and `zig build check-tests-reached` pass.
- `zig build test`: 562/563 (1 skipped). `zig build test-integration`:
53/53 steps, 530/530 tests.
- The new integration test turns tracking on and opens a document, then
types two keystrokes, runs 300 inserts and undoes once. It checks:
- 303 steps with non-decreasing times, a base, and one checkpoint at
step 303;
    - an undone last step, and the focus noted on that document;
- replaying all of it into a fresh document matches its text and the
original's fingerprint, through the checkpoint check;
    - replaying 2 steps from the base gives `ab`;
    - the target recorded nothing;
    - a closed document's track is kept.
  - Not measured yet: that's 3b, before the default flips.
- [ ] Windows: not run.
- [ ] Linux: CI.
- [ ] Web: `check-web` builds. The switch is off there (`envSwitch` is
null on wasm).

## Follow-ups

- 3b as above.
- A `fizzy.document.activated` event for the focus track.
- A base kept as the file on disk (path and hash) rather than a copy,
for documents opened clean: the plan names it, and it halves a big
sprite's memory.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #260 (step 2 of `plans/WORKSPACES_PLAN.md`)

## What changes

Behind `FIZZY_WORKSPACES=1`, the workbench's document panes stop being
leaves of its own split tree and become views the app places,
`fizzy.workspace.<grouping>`, each titled by the document it shows. This
is the ground tearing a tab off into its own window (step 5) is built
on: once a pane is an ordinary view, a float can hold one.

- **Main shows the first workspace** by its keywords, in place of
`workbench.workspaces` (which the workbench doesn't register while the
flag is on).
- **A pane beside another is an app split.** A tab dropped on a pane's
edge, Open to the Side and `showBeside` now split the place holding the
anchor pane (`Region.splitOn`) and assign the new workspace there.
Before, they split the workbench's `DockLayout`.
- **An emptied workspace closes its place** (`ViewDrag.withdraw`, new).
A minted half slides shut. A float closes. When Main itself empties, it
takes the views of the half beside it, as an emptied split does today.
The only workspace never goes, and shows the home page.
- **Saved in `layout.zon`:** the split, which place holds which
workspace, and each pane's documents (`Pane N`, as before). `panes.zon`
is neither read nor written while the flag is on; migrating it is step
4.

How it fits together: the workbench gets one seam, `Workbench.places`
(`beside` / `placed` / `close`), which fizzy's new
`src/editor/Workspaces.zig` fills in before the plugin registers. Fizzy
also keeps one surface per pane in step with `wb.workspaces`, once a
frame after the rebuild. Documents still go into panes through the
workbench exactly as now (tabs, drops, previews, placeholders, close
flow); that moves to the app in step 3.

With the flag off, nothing changes. The new branches are all `places ==
null`.

## SDK impact

- [x] None. `Workbench` is fizzy-internal state shared with its own
bundled dylib; the SDK boundary is untouched (`test-sdk-version`
passes).

## Verified

- [x] macOS: a sandbox profile run with `FIZZY_WORKSPACES=1`, driven
through the agent plugin's `fizzy_act` with screenshots at each step:
  - opened three files;
- dragged a tab onto the pane's right drop → `Main` + `Main/r1`,
workspace 1 holding the tab;
  - closed it → `Main/r1` slid shut;
- split again, then closed Main's last document → Main took workspace 2
and the split closed;
  - quit and relaunched → both workspaces and their tabs came back;
- dragged a tab from the right workspace onto Main's strip → it went in
among Main's tabs and the right half closed.
- `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`); the flag is never on there (no
environment).

## Follow-ups

- Step 3: documents through the app (`openFile` into the active
workspace; the drop rules for documents).
- Step 4: `panes.zon` → `layout.zon`. Until then, a place whose
workspace had no documents at quit comes back empty.
- Step 5: tear a tab off over the desktop, or drop it on its own
workspace's middle, into a float holding a new workspace, and back
again.
- fizzy's restart-into-a-rebuild (`--handover`) doesn't carry `FIZZY_*`
env flags, so it comes back with this flag off. That affects every env
flag, not just this one.

🤖 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 3, second
half. Builds on #459, now merged; `main` is merged in.

## What changes

Tracks (#459) now hold a bounded amount and are measured. They stay
behind `FIZZY_TRACKS` (or `fizzy.tracks.record`) until the default-on
PR.

- **Checkpoints are deltas** (`app/state_delta.zig`). A checkpoint is
kept as the bytes that differ from the latest whole state: the base, or
a checkpoint kept whole because its delta had come to half the document.
It's generic over any owner's `captureDocumentState` bytes, so no plugin
writes anything for it.
  - The common prefix and suffix are trimmed, a 64-byte block at a time.
- A middle the same length in both (nothing shifted, like a canvas) is
kept as the runs of blocks that differ, so strokes far apart are a few
runs.
- Any other middle (text inserted, which shifts what follows) is kept as
it is.
  - Restoring one rebuilds the whole state from its reference first.
- **Checkpoints are weighed.** One is taken only once the steps since
the last weigh as much as the last delta did (`state_share`), and then
after 256 steps or a 30 s pause. At 8,192 steps one is taken whatever it
costs, so a seek never replays more than that.
- Before this, a checkpoint was a whole copy every 256 steps by count
alone. A 256 KB document held 78 copies of itself after 20,000 steps,
and an 8 MB one hit the cap.
- **Old checkpoints thin.** Those older than an hour keep one per 4,096
steps. Only deltas go; a whole state is what the deltas after it are
made from.
- **A cap**, 256 MB in memory. Past it, the oldest ended tracks go
first, then all but each open track's newest checkpoint and the whole
state it's made from. Steps and bases stay, because undo will walk them;
making those smaller is compaction (plan step 12).
- **`fizzy.tracks`** reports whether tracks record, what they hold
against the cap, how many frames did any track work, and per track its
document, owner, steps, checkpoints and bytes. It's devtools' and an
agent's view of tracks.
- **`fizzy.tracks.record { on }`** turns tracks on or off.
- A track keeps its document's handle (stable for its life) instead of
asking the host for it, so `Tracks` needs no host, and the frame clock
is injectable for tests and the bench.

**Measured**, `zig build bench-tracks -Doptimize=ReleaseFast`. The owner
is a stand-in whose state is a buffer and whose steps write at scattered
offsets, so this is the tracks' own work around an owner's. Text's state
is a copy of its text plus a small header, so these stand in fairly for
it. Pixi's real costs come with plan step 4.

The machine was at load 7–8 from other sessions, so treat the timings as
upper bounds; the sizes and counts don't depend on load.

| Steps | Step | Document | ns/step* | Checkpoints | Held | Worst replay
|
|---:|---:|---:|---:|---:|---:|---:|
| 2,000 | 24 B | 64 KB | 134 | 7 | 220 KB | 0.011 ms |
| 2,000 | 200 B | 256 KB | 262 | 7 | 756 KB | 0.036 ms |
| 20,000 | 200 B | 256 KB | 225 | 78 | 5.1 MB | 0.029 ms |
| 20,000 | 200 B | 8 MB | 4,650 | 78 | 12.9 MB | 0.6 ms |

\* each step with its share of the checkpoints it asks for.

**Whole copies against deltas,** for 20,000 strokes on the 8 MB sprite:
- **Whole copies:** 29.6 MB held, 2 checkpoints.
- **Deltas:** 12.9 MB held, 78 checkpoints. That's the 8 MB base, 4 MB
of steps, and about 1 MB of checkpoints.
- On the 256 KB sprite: 9.1 MB and 15 checkpoints, now 5.1 MB and 78.

**At a frame's top:**
- **One checkpoint:** 0.021 ms for a 64 KB document, 0.12 ms for 1 MB,
1.2 ms for 8 MB. The budget is 2 ms; the whole copy cost about the same.
- A first version of the codec compared byte by byte and twice over, and
took 3 ms for 8 MB. It now compares a block at a time, in one pass.
- **Nothing asked:** about 1 ns.

The "nothing per frame" claim is also a test rather than a timing. With
tracks on in the real app (headless), 120 idle frames do no tracks work
(`Tracks.worked` doesn't move). Wall-clock comparisons in Debug CI would
be flaky.

## 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 check-web`, `zig build
test-sdk-version`, `zig build check-tests-reached` and `zig build
check-bench-replay` pass.
- `zig build test`: 568/569 (1 skipped). `zig build test-integration`:
53/53 steps, 540/540 tests.
- `fizzy-state-delta-tests` (unit, std-only): one edit is a delta about
its size; inserted text; changes far apart on a canvas are a few runs;
growing, shrinking and emptying; 500 random edits round-trip; truncation
at every length and 4,000 bit flips are errors, never crashes.
  - New `Tracks` unit tests:
    - a frame's top works only when asked;
- a checkpoint as a delta, kept whole past half the document, then
deltas against that;
- none until the steps outweigh the last delta, and one at the most a
seek replays;
    - a replay through a delta to its bytes and fingerprint;
    - thinning after an hour, with an injected clock;
    - the cap's order: ended tracks first, then older checkpoints.
  - The integration test now also checks idle frames and `fizzy.tracks`.
- Not measured in a running fizzy yet. The shared sandbox fizzy belongs
to another session, so that measurement comes with the default-on PR,
with pixi's real strokes.
- [ ] Windows: not run.
- [ ] Linux: CI.
- [ ] Web: `check-web` builds.

## Follow-ups

- **Default on:**
- a measurement in a running fizzy, for text now and pixi after step 4;
  - the flip, with a setting rather than the switch.
- **A memory decision for that PR:** checkpoints are now small, so a
track is mostly its base and its steps, held beside the owner's own undo
history. That's about what undo holds plus a base. The plan's "undo
history plus 10%" target needs plan step 11, where owners page their
undo from the track.
- **No worker thread for checkpoints.** A checkpoint is taken and diffed
on the frame that takes it, on every target: the web has no threads, and
a worker would save only the diff (about half of 1.2 ms for 8 MB, on a
frame every few hundred strokes), since the capture's copy reads the
live document anyway.
- If pixi's real numbers ever show that frame running over, the fallback
works on the web too: spread the diff over the frames after, a slice
each within a small budget. The copy is immutable once taken.
- With checkpoints as deltas, there's little left for the plan's
"deflate sealed chunks on a worker" to save. That's dropped for the same
reason.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #260 (step 3 of `plans/WORKSPACES_PLAN.md`).

## What changes

Behind `FIZZY_WORKSPACES=1`, a document is no longer limited to the
workbench's panes: carried onto any place, it lands there inside a new
workspace, the way a view would land.

- **An edge splits the place.** A tab, or a file carried out of the
tree, dropped on the edge of the Panel, the Sidebar or any other place
splits it on that side; the new half is a workspace holding the
document. A file that wasn't open opens straight into it, and what was
carried with it (a selection out of the tree) goes too.
- **The middle lands it as a view.** An empty place takes the new
workspace. A place of tabs (the Panel) adds it as a tab and shows it. A
place that shows one view gives the place to the workspace, and the view
it replaces goes back to wherever its keywords show it. (A view would
trade places with the dropped one, but a document's pane can't take a
view.) The middle bubble shows add/replace as it does for views.
- **A place's tab strip takes it where it's let go**, as another tab.
- **A pane's only tab dropped on its own edge moves there.** Room opens
on the other side, as for a view alone in its place: drop it on the
right and it sits on the right with an empty place on its left. Before,
it moved into a new pane, and the old one closed into it by a round trip
that left a stray empty half (below).
- **A lone tab carries its workspace.** A document that is its
workspace's only tab takes the workspace along: dropped on a plain
place, the workspace itself lands there (`State.Documents.alone`), as a
drag of the workspace would. So a tab moved right and dropped back on
the empty left half's middle joins the halves into one place again, and
nothing is drawn anew. Before, a new workspace was made round the
document and the old one closed behind it; that drew the document anew
twice, and it blinked. A view joined with an empty half now stays the
one view rather than becoming a place of tabs (`ViewDrag.join`).
- **The app seats documents.** A document no pane holds goes into the
workspace it was opened toward. This was the workbench's
`rebuildWorkspaces`; it's now fizzy's `Workspaces.seat`. Grouping 0
means the active workspace once workspace 0 has gone, as the plan says.
- **Fix: a split half made and closed in the same frame is removed.**
`ViewDrag.closeMinted` returned early for a place it couldn't find
drawn, so a half opened and emptied before its first draw stood empty
for good. Now it's dropped outright, as its comment intended.

How: the layout gets one seam, `State.documents` (`house(ids) → view
id`). The app makes a view holding the documents, and the layout places
it like any view it lands (`ViewDrag.house`, `insertView`). Fizzy's
`Workspaces` fills it in and registers the view at once, so the place
can select it in the same frame. The workbench gets `Places.open` for
the lone-tab case. Step 5's tear-off will hand the same view to a float.

With the flag off, `documents` and `places` are null, and the only
change is the `closeMinted` fix.

## SDK impact

- [x] None (`test-sdk-version` passes).

## Verified

- [x] macOS: a sandbox profile run with `FIZZY_WORKSPACES=1`, driven
through the agent plugin's `fizzy_act` with screenshots:
- `b.txt` onto the Panel's right drop → Panel split, the new half a
workspace showing `b.txt`; closing it slid the half shut;
- `b.txt` onto the Panel's middle → a `B.TXT` tab beside Output, shown;
- `c.txt` onto the Panel's strip between Output and Devtools → a `C.TXT`
tab there; closing `c.txt` took that tab away and left the Panel's
others;
- a single tab onto its own pane's right drop → an empty place on the
left, the tab on the right;
- with two tabs, one dropped on its pane's right drop → its own
workspace on the right, as before.
- `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; the flag is never on there.

## Follow-ups

- The tab strip, close flow and active workspace are still the
workbench's; they move with step 6, when its copy goes.
`app/layout/SPLITS.md`'s "a document goes only to a document's slot" is
updated then too, since it stays true with the flag off.
- For the frame a tab is dropped, the pane it left can still draw that
document before it catches up. Seen once in a screenshot; not fixed
here.

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

---------

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

## What changes

The region picker (the corner button's popup) listed every view a place
could show in one flat grid, in registry order, so nothing said which
views were made for the explorer and which for the panel.

Each card now sits under the place its keywords make it for:

1. the place being edited, under its own name (opened from the Panel:
"Panel" first);
2. each other place the shape declared, by its name ("Sidebar", "Main",
…);
3. "Other": views whose keywords no place accepts;
4. the Store, as before.

The fit is `sdk.keywords.strength`, the same rule the layout binds a
surface by, so the header a card sits under is where its keywords would
put it. Ties go to the place being edited. Places are one per keyword
group (the rail and the explorer it chooses for are one place), and
document panes are never a section. A filter ranks within each section
and hides empty ones.

Headers are the shape's region names, not fixed words, so a copied shape
(studio's "Stack"/"Strip") gets its own without any change here.

The Split submenu's Vertical and Horizontal rows, which left their icon
column blank, now show the split they make: `columns-2` (side by side)
and `rows-2` (stacked).

## SDK impact

- [x] None

## Verified

- [x] macOS: unit test for the section rule (`Picker.zig`); integration
test opening the picker headless from the Panel and then the Sidebar,
asserting card order by their `picker:<id>` anchors. All gates pass
locally (fmt, build, test, test-integration 53/53 steps, check-web,
test-sdk-version). Not yet looked at on screen: the popup needs the
window focused.
- [ ] Windows:
- [ ] Linux:
- [ ] Web:

## Follow-ups

None planned.

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

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

A tape or an agent can aim at the picker's controls by name, not by
position:

| Name | What |
|---|---|
| `picker.mode:one`, `picker.mode:many` | Single / Multiple |
| `picker.action:split`, `:clear`, `:defaults`, `:remove` | the actions,
each only while it can be pressed (a dimmed Clear has no name) |
| `picker.split:vertical`, `picker.split:horizontal` | the rows of the
Split menu |

These sit beside the cards' existing `picker:<view id>`, which is now in
`docs/AUTOMATION.md`'s anchor table along with the new names.

**Why.** The row's buttons come and go with the place. Remove sits where
Split sat on a place with nothing to Clear, so a point read off one
place's picker can land on a different action in the next. While driving
splits through the agent plugin for #467, a click aimed
at Split hit Remove twice and closed the place.

**How.**
- `modeButton` and `actionButton` draw through `namedButton`:
`dvui.button`'s body with a `core.anchor.mark`, since `dvui.button`
doesn't hand back its widget.
- `core.widgets.menuRow` gets an `anchor` option for a row that runs no
command (a command row is already `fizzy.command:<id>`).
- A dimmed action (Clear with nothing to clear, Defaults with no list)
publishes no name: it swallows the click, so `wait_for` / `expect` on a
name means the action can run.
- `core.anchor.mark` costs nothing unless someone asked for anchors this
frame.

## SDK impact

- [x] Core-only or additive: `MenuRowOptions.anchor` reaches plugins at
the next SDK release (`test-sdk-version` passes; the fingerprint didn't
move).

## Verified

- [x] macOS: integration test `picker: its controls are named for a tape
or an agent to aim at, while they can be pressed` (anchors wanted, the
picker drawn over a place: Single, Multiple and Split are named; a
dimmed Clear and a shape place's absent Remove are not).
- [x] macOS: sandbox fizzy (`--profile`), driven through the agent
plugin's `fizzy_run` by name only:
- opened Main's picker: `fizzy_screen` lists `picker.mode:one`,
`picker.mode:many`, `picker.action:split`, `picker.action:clear` and
`picker.action:defaults`;
- clicked `picker.action:split`, and the menu listed
`picker.split:vertical` and `picker.split:horizontal`;
  - clicked `picker.split:vertical`, and Main split into Main | Main/r1;
- opened Main/r1's picker, `expect`ed `picker.action:remove`, clicked
it, and Main was whole again.
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (93/93 steps),
`zig build test-integration` (53/53 steps), `zig build
check-tests-reached`, `zig build check-web`, `zig build
test-sdk-version`, `scripts/check-examples.sh`.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds

## Follow-ups

- `namedButton` repeats `dvui.button`'s body to reach the widget's data,
because `dvui.button` doesn't hand it back. A `dvui.button` variant in
the dvui-dev fork that exposes it would keep one body
(`docs/DEPENDENCIES.md`).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while chasing the uneven nested split reported in
#464's follow-ups.

## What changes

A split of a carded place now opens two even halves. Before, the new
half was short by the card's padding on both sides. That covers fizzy's
Main and Panel, and every split of their halves. At 2× on macOS:

| | before | after |
|---|---|---|
| Main → Split → Vertical | Main 886 px, Main/r1 854 px | 870, 870 |
| then Main/r1 → Split → Horizontal | Main/r1 494 px, Main/r1/b1 462 px
| 478, 478 |

**Why.** A split's extent is the new half's whole reach, border to
border, padding included (`Region.init`). `splitOn` halved the place's
recorded `size`, which was its *content* rect. `State.setPlaceMetrics`
now records the border box, and it takes the box's `WidgetData`, so all
three call sites measure it the same way. A split is the only thing that
reads `size` (`placeSize`, and a drag's frozen `Target.size`).

**The suspected cause was not it.** #464 suspected the `.branch` arm: it
sizes the branch by `extent(b.origin)`, the same name the kept leaf has.
But the kept origin is always the leftover half of its own branch
(`resize = false`), so it never writes an extent under that name. Inside
one tree, `extent(name)` always means the minted leaf's reach along its
parent's axis, before and after that leaf is split again. Persistence
round-trips too: the nested split came back at the same sizes after a
restart.

**The 1:3 in #464 comes from its own carry-over.** I reproduced it on
#464's head (f927a7f) with its recipe: a `panes.zon` of `Pane 0` |
(`Pane 4` over `Pane 5`), relaunched with `FIZZY_WORKSPACES=1`. Main/r1
came out 248 px over Main/r1/b1 708 px. The stored extent for Main/r1/b1
was 354 pt, which is exactly (718 − 10) × 0.5. 718 pt is Main's content
height *without* the 236 pt Panel under it. So `settle` measures Main on
a frame before the Panel has its room, and hands that height to
`splitOnSized`. That fix belongs in #464.

## SDK impact

- [x] None (`test-sdk-version` passes; `app/` only)

## Verified

- [x] macOS:
- new integration test `a split of a carded place, and of its new half,
opens even halves`. It uses fizzy's shape: a carded Main with
`pane_cards`, in a column, and compares the halves' drawn rects, not the
`size` this PR redefines. Before the fix it failed at 187 vs 171.
- sandbox fizzy (`--profile`, driven through the agent plugin): Main →
Split → Vertical, then Main/r1 → Split → Horizontal, measured as in the
table.
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps),
`zig build test-integration` (53/53 steps), `zig build
check-tests-reached`, `zig build check-web`, `zig build
test-sdk-version`.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds

## Follow-ups

- #464: measure Main once the layout under it has settled (or from the
window), not on the first frame `settle` runs. Its `splitOnSized` gets
the reach fix from this PR for free.
- Removing a half that was split again: fixed in #467,
stacked on this one.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #465 (it reuses that PR's `ColumnCardFrame`
test frame); review after it, or retarget to `main` once it merges. This
is the follow-up #465 named.

## What changes

Take Main | (Main/r1 over Main/r1/b1). Emptying **Main/r1** (carrying
its last view to the trash or elsewhere), or picking **Remove** on it,
now closes just that half. b1's views move into Main/r1, and the column
keeps its name, its width and its link in `layout.zon`. That's the same
rule as emptying the half that was split anywhere else (SPLITS.md,
"Removing a split"). If b1 is split again too, there's no single place
to merge into, and Main/r1 stays, empty.

**Before:** Main/r1 is two places under one name: the half Main's split
minted, and the half its own split kept. `closeEmptied` saw "minted" and
slid shut the branch box registered under that name, which is the whole
column. Then `Forest.collapse("Main/r1")` matched the inner *leaf*, so
b1 was promoted into Main's split. b1 took the column at an extent that
was its height (195 pt in the test). The only link left in `layout.zon`
still named the gone `Main/r1`, and b1 had no link, so it was lost on
reload.

**The model change:**
- **`SplitTree.Forest.collapse`** takes the outermost node that carries
the name: for a place split again, that's the branch its split made,
never the leaf that kept the name. New `isSplit` and `placeLeaves` read
the same rule.
- **`ViewDrag.closeEmptied`** treats a minted half that was split again
as an origin: it merges in its other half, or stays. The **Picker's
Remove** goes through it for such a place.
- **`Region.persistExtent`** counts a column as kept while *any* place
in it holds a view. When the column closes, it forgets every place in
it, not only the one whose name the column carries. Before, closing a
column with an empty Main/r1 and a full b1 would have left b1's views
assigned to a place that no longer existed. It walks the forest only
once the place has shut, not every frame.
- **The picker** offers no Remove on a half that was split again when
its other half is split too: there's nothing to merge, and Remove would
only have cleared it.
- **A use-after-free while drawing.** When a place was forgotten in the
middle of drawing its branch, the tree went on drawing children that
`collapse` had just freed. The old code had the same hazard on a rarer
path. `persistExtent` now returns whether it dropped the place, and
`initTree` and the `.branch` arm draw nothing more of it that frame (a
refresh is already queued). The new column-close test caught this: the
freed b1 leaf re-stored its extent after the column had cleared it.

A leaf that was never split again behaves exactly as before. Every
changed path checks `isSplit` first, and `placeLeaves` of a plain leaf
is that leaf alone.

## SDK impact

- [x] None (`app/` only; `test-sdk-version` passes)

## Verified

- [x] macOS:
  - integration tests (new) on fizzy's forest with a carded Main:
- `split: emptying the half that was split again closes only that half`.
It failed before the fix: Main/r1 was gone and b1 sat in Main's split.
- `split: emptying the half that was split again stays when its other
half is split too`.
- `split: a column shut by its sash goes only once every place in it is
empty`: shut with a view in b1, it stays; emptied, the whole column goes
and leaves no assignment or extent behind.
- unit tests in `SplitTree.zig`: collapsing a place split again drops
its whole branch, and collapsing the minted half of a nested split
leaves its origin, with one link.
  - sandbox fizzy (`--profile`, through the agent plugin, macOS 2×):
    - Main → Split → Vertical gives 870 \| 870 px.
- Main/r1 → Split → Horizontal gives 478 / 478 px, with the column's
extent unchanged at 435 pt.
- Output into b1, then Main/r1 → Remove gives Main 870 \| Main/r1 870
px. Main/r1 holds Output, and `layout.zon` has one link (`Main/r1` from
`Main`, right) and no b1.
    - Remove on b1 itself still closes just b1, as before.
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps),
`zig build test-integration` (53/53 steps), `zig build
check-tests-reached`, `zig build check-web`, `zig build
test-sdk-version`.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds

## Follow-ups

- **The deeper fix is a name of its own for a branch.** A branch and the
half that kept its name share one name, and this PR special-cases that
(`isSplit`) where it bites: `closeEmptied`, the picker's Remove and
`persistExtent`. Anything else that resolves a place by name
(`regionNamed`, which finds the branch box first; `placeSize`) still
sees two places. Naming branches separately would need a `layout.zon`
migration for saved links, so it's its own change.

- Picker buttons named for drivers: #468.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne and others added 11 commits October 16, 2026 16:34
Follow-up to #466, from its review.

## What changes

- **A view reads under the place showing it.** #466 sectioned cards by
keywords alone, so Files dragged onto the Panel was highlighted in the
Panel's picker but listed under "Sidebar" (and the Sidebar's picker
still claimed it). A card now sits under the place holding it
(`Layout.matchingIn`), and falls back to its keywords only when no place
does. Places resolved by name (a split's leaf, a float) are each their
own section instead of being folded into a keyword group, so a view put
in one reads under its name.
- **The edited place always leads.** A split's new leaf accepts no
view's keywords, so its picker opened on other places' headers alone.
Its own header now comes first, with "Nothing here yet." (or "No
matching views here." under a filter).
- Each card's section is computed once per frame rather than once per
section.

## SDK impact

- [x] None

## Verified

- [x] macOS: unit tests for the section rule, including a held view and
a slot leaf; the picker integration test now assigns Files to the Panel
and checks both pickers list it in the Panel's row beside Output (on
`main` the Sidebar picker puts Files in a section of its own, so the row
check would fail). All gates pass locally (fmt, build, test,
test-integration 53/53 steps, check-web, test-sdk-version,
check-tests-reached). Not looked at on screen.
- [ ] Windows:
- [ ] Linux:
- [ ] Web:

## Follow-ups

None planned.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #260 (step 4 of `plans/WORKSPACES_PLAN.md`). Stacked on #462,
which is stacked on #461.

## What changes

Behind `FIZZY_WORKSPACES=1`, a saved layout comes back whole.

- **An old `panes.zon` becomes the app's splits.** The first time fizzy
runs with the flag, the workbench's saved tree is laid out split for
split from the place the first workspace is in (Main). Each pane's
workspace goes in its half, at the share it had. Then `panes.zon` is
deleted: the arrangement is `layout.zon`'s from then on. A subtree whose
panes didn't come back is skipped, so its sibling takes the room.
- **A place whose workspace didn't come back closes.** A workspace that
held no document at quit, or none that could open, isn't restored. Its
place used to come back empty; now it closes, as it would have had it
emptied.
- **`Region.splitOnSized`:** `splitOn` for a place that hasn't been
drawn yet, with a share for the new half. A nested tree splits a half
made in the same frame. `splitOn` now calls it with the drawn size and
half.

What's already kept and needed no change: which place holds which
workspace and each workspace's documents in order (`layout.zon`, since
#461), and each workspace's selected tab (the session,
`KeptDocuments.applySelections`).

Older fizzy reads the same `layout.zon` it always did; nothing new is
written. With the flag off, nothing changes. (The workbench still reads
`panes.zon` as it did; only the carry-over deletes it.)

## SDK impact

- [x] None (`test-sdk-version` passes).

## Verified

- [x] macOS: in a sandbox profile:
  - ran without the flag and split Main (`Pane 0 | Pane 4`);
- hand-edited the saved `panes.zon` into a nested tree (horizontal 0.6:
`Pane 0` | vertical 0.5 of `Pane 4`, `Pane 5`) and gave `Pane 5` a
document in `layout.zon`;
- relaunched with `FIZZY_WORKSPACES=1`: Main came back as `Main` |
(`Main/r1` over `Main/r1/b1`), each half showing its workspace's
document, the horizontal share 60/40, and `panes.zon` gone.
- `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; no `panes.zon` there, and the flag is never on.

## Follow-ups

- **A split of a split's new half comes out uneven.** The vertical 0.5
above settled at about 1:3, and splitting a nested half by hand came out
uneven the same way. A branch sizes itself in its parent by its origin's
extent (`Region`, `.branch`), the same name its kept half reads its own
size from along the other axis. That's the app's split code with or
without this flag, so it's a separate fix.
- Step 5: tear a tab off into a float holding a new workspace
(`State.documents` with a float), and back.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to #470, from its review.

## What changes

- The place being edited, when empty, draws its header and "Nothing here
yet." inside a box anchored `picker.empty`, so a test or a demo tape can
find it.
- The picker integration test checks it: once Files and Output both sit
in the Panel, the Sidebar's picker leads with `picker.empty` above the
Panel's cards, and the Panel's picker has none.
- A comment said the sections were "sorted"; they are computed once.

## SDK impact

- [x] None

## Verified

- [x] macOS: the picker integration test passes, and fails without the
anchor; all gates pass locally (fmt, build, test, test-integration 53/53
steps, check-web, test-sdk-version).
- [ ] Windows:
- [ ] Linux:
- [ ] Web:

## Follow-ups

None planned.

🤖 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 5, first
half: the transport. The hot zone and pixi's history line follow.

## What changes

**Undo and redo show a document's transport** (`app.HistoryTransport`):
a bar of glass at the bottom centre of that document's own pane.
- **What's on it:** its history as a line with a tick per step (while
they stand 6 points apart) and a knob where the document is; undo and
redo bubbles either side; the step's title with how far along it is
("Edit 2 / 3").
- **The look:** three bubbles of one liquid glass
(`core.dialogs.glassBubbles`), as the demo player's transport is.
- **The line:** a press on the line's band goes to the step under it,
and a drag follows the pointer. That's at most one `historyGoTo` a
frame, so a drag across a thousand steps costs a jump a frame. A press
on the title does nothing. A drag lasts only while the bar holds the
mouse: if the bar stopped drawing mid-drag (a claim came), the drag is
over. The bubbles step one at a time.
- **Coming and going:** the bar fades in, stays while the pointer is on
it or a drag holds it, and goes 3 s after it was last touched. While it
shows, it sets one timer for its next change (fully in, starting to go,
gone); once gone it sets none and asks nothing of the owner.
- **A held undo or redo goes further each press:** one step a press for
the first six, then 2, 4, 8, … up to 32. A press the other way starts a
new run. Each step is the owner's own `undo`/`redo` command, so anything
an owner does around an undo still happens. The bar doesn't show on a
claimed document, or when there's nothing to undo or redo.
- **The activity feed stays above the bar,** as it does above a claim's
pill. The bar's place is computed before either draws (`place`), so they
don't overlap even on its first frame. A first version used the bar's
rect from the frame before, and they overlapped on the frame it came in.
- **Anchors** `history.line:<path>` (its `x` is that fraction of the
history), `history.back:<path>` and `history.forward:<path>`, unprefixed
like `app/`'s others (`region:`, `picker:`), for tapes and tests.
Documented in `docs/AUTOMATION.md`.

**For any owner** with `historyLine` and `historyGoTo`, so text has it
now. Pixi needs both (it has step ids since fizzyedit/pixi#45). That's
in the next half, with the hot zone: the pointer near a view's bottom
centre, no button held, a short dwell, bringing the bar up.

**The rules** are std-only and tested on their own
(`app/history_transport.zig`, `fizzy-history-transport-tests`): when the
bar shows and for how long, how far a held key goes, and which step a
point on the line is.

**Not built yet:**
- **Going back then editing still clears redo,** as undo always has. The
timeline plan's "going back is a step" (`HISTORY_TIMELINE_PLAN.md` step
4) is what keeps it on the line.
- **Authors and times on the line.** Authors come with
`SHARED_HISTORY_PLAN`'s step 2; times are in the tracks already.

## 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 check-web`, `zig build
test-sdk-version` and `zig build check-tests-reached` pass.
- `zig build test`: 572/573 (1 skipped), including the transport rules.
`zig build test-integration`: 53/53 steps, 542/542 tests.
- The new integration test makes 40 steps in a text document and undoes.
It checks:
    - the bar shows, at 39;
- a live tape clicking the title over the line's start leaves it at 39;
- clicking the line's start goes back before every change (0), and
clicking redo goes to 1;
    - a drag along the whole line ends at 40;
    - ten undos in one frame go 14 steps, landing at 26.
- Seen in a running fizzy (Debug, driven through the agent plugin's
bridge): the bar over the document undo was pressed in, its label, ticks
and knob, and the feed's bubbles above it once its place was computed
first.
- [ ] Windows: not run.
- [ ] Linux: CI.
- [ ] Web: `check-web` builds. Not seen there yet.

## Follow-ups

- The hot zone, and `historyLine` / `historyGoTo` for pixi (the second
half of step 5).
- "Going back is a step", so scrubbing back and editing loses nothing.
- **The line is asked for each frame the bar shows.**
`HistoryLine.version` can't save that, because reading the version means
building the line. A `historyVersion` hook (one number, no allocation)
is the seam, for when long histories make it show up in the profiler.

🤖 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 2, as
amended in fizzyedit/plans#21. This is the SDK half; the app half (the
frame's input author, tapes acting, activity bubbles naming others, the
integration check) follows as a PR stacked on this one.

## What changes

The host learns who made each step of a document's history. It keeps
this on the channel owners already report steps through
(`Host.recordStep`, #456), not a second one.

- **`sdk.Author`:** the person, an agent, a tape, or nobody known
(`unattributed`, a missed site). Identity is kind, plugin and session;
the label ("Claude", "the tour") is display only.
- **Who is acting:**
- `act(author)` and `endAct(outer)`: the innermost act wins. An
outermost `act` starts a run (one agent call, one tape op).
- `acted()` and `actAgain(stored)`: work put off (pixi's call that waits
out the person's stroke) runs as the author and run it was asked in.
- `author()`: the innermost act, else the frame's input author
(`setInputAuthor`, for the app to set where input is consumed), else
`unattributed`.
- **`recordStep`:**
- keeps who made every step that isn't the person's, per document, by
step id, plus the document's top step;
- counts steps made with nobody acting (`unattributed_steps`, which the
app's tests will assert stays 0);
  - forwards a step to the recorder only while it records, as before.
- Working alone, nothing is kept: a document gets an entry the first
time someone else acts on it.
- **Queries:** `stepAuthor`, `topStep` and `mayMerge` (whether the
acting author may fold an edit into a step; it keeps an agent's
keystrokes from joining the person's run of typing).
`forgetDocumentAuthors` runs when a document closes, beside
`releaseDocumentClaims`.
- **`Step.top`:** the entry on top after the step, so Undo can name
whose step it takes back. An owner that leaves it 0 still gets a
sensible top after `added` and `joined`.
- **`docs/PLUGINS.md`:**
  - owners report every step and gate only the encoding on `recording`;
- an **Authors** entry: `act` around commands run on someone's behalf,
`actAgain` for deferred work, `mayMerge` for runs.

Nothing calls `act` yet. Owners report steps only while recording until
the plan's step 3. So nothing a person sees changes in this PR.

## SDK impact

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

**For plugin authors:** additive. `Step` has a new defaulted field
`top`, and the host has new functions. Nothing existing changes shape.

After the release that carries this:
- agent's `agentCall` acts as its connection;
- chat acts around its ACP writes;
- pixi's waiting calls store `acted()` and run in `actAgain`.

## Verified

- [x] macOS:
  - **Unit tests** in `sdk/src/Host.zig` and `sdk/src/Author.zig`:
- authors and the top through added, person, undone and restarted steps;
    - nesting, runs and `actAgain`;
    - interning and late labels;
    - `mayMerge` both ways;
    - the unattributed count.
- `zig build test`: 568/569 passed (1 skipped). `check-tests-reached`
passes.
- **Builds:** `zig build`, `test-sdk-version`, `check-web` and
`test-integration`. Results are in the comment below once they finish.
  - **Store plugins:** pixi and agent build against this `sdk/`.
- [ ] Windows: CI cross-compile.
- [ ] Linux: CI.
- [ ] Web: `check-web`.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #474 (the SDK half); review that first. Part of
fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 2, as amended
in fizzyedit/plans#21.

## What changes

The app now says who is acting, using #474's `Host.act` and
`setInputAuthor`.

- **The frame's input author.**
- `Editor.tick` sets it where input is consumed: around the active
document's tick, native menu actions, the layout's draw and floats, then
around plugin keybinds, the keymap, overlays and the palette.
- It clears it around plugins' `endFrame`. Plugins' `beginFrame` and
event delivery run with none, as the plan says: they act for themselves,
or are nobody's.
- The author is the person's, or a tape's while one drives
(`Demo.author`). The demo player and the live driver stop for a real
press or key before the frame's input is consumed, so a frame a person
takes over is theirs.
- **Tapes act.** A tape's command ops run inside `act` as the tape
(`Demo`'s stage `command`), and a live tape's as whoever it plays for. A
demo's author is its title; a live tape played by nobody in particular
is "a tape".
- **`automation.Api.playAs(bytes, author)`** (service version 6): a live
tape played for an author. An agent's pointer tape (`fizzy_act`) is then
the agent's. Plain `play` leaves the tape its own author. The service
tells the app through a new `OnAuthor` hook, beside its `Other`.
- **Bubbles name agents.** An edit made while an agent acts shows as
"Claude · Draw". The person's own edits stay plain, as the plan has it,
and so does a demo narrating itself. The plan says "others", but
prefixing every bubble of a playing demo with its title cluttered the
demo; that's a judgement call for review.
- **The integration check.** The whole-editor test asserts the tour adds
no unattributed step. Owners report every step only from the plan's step
3, so today it guards the steps reported while recording.

## SDK impact

- [ ] None
- [x] Core-only or additive: reaches plugins at the next SDK release.
The automation service is versioned on its own (`service_version` 5 →
6), so a plugin built for version 5 gets null from `getServiceTyped` on
this host and degrades. The agent plugin is the one consumer: it moves
to `playAs` with its repin.
- [ ] Fingerprint moved
- [ ] SDK release

## Verified

- [x] macOS:
- **Unit test:** an agent-acting edit's activity title is "Claude ·
Draw", the person's "Draw".
- **Gates:** `zig build`, `zig build test`, `check-fmt`.
`test-integration`, `check-web` and `test-sdk-version` results are in a
comment below once they finish.
- [ ] Not tried by hand: a demo's edits attributed while it plays, and
an agent's pointer tape through `playAs`. The agent plugin calls
`playAs` after the SDK release; that PR will show it end to end.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A crash fix, with no plan behind it.

## What changes

Fizzy crashed on 2026-10-16. It was a Debug build of `main` around #461,
at SDK 0.2.28, and it segfaulted in `mem.eql` inside
`Layout.State.evictIdFromOthers`. The path was `assign` ←
`fizzyAssignSurfaces` ← `Workbench.showBeside` ← `Editor.newFile` ←
pixi's `createDocument`. An agent's `pixi.create` was opening a sprite
beside the person (`host.openBeside`, #442), into a pane that already
had tabs.

**Cause:** `State.assign` called `setAssignment`, which frees the
region's old id strings. It then evicted each new id from every other
region, reading the ids from the **caller's** list. A pane adds a tab by
reading its assignment, appending to it and writing it back
(`Workspace.addTab`; `removeTab`, `insertTab`, `swapTabId` and
`Workspaces.close` work the same way). So the caller's list borrows the
strings that were just freed, and the eviction compared against freed
memory. Most of the time that read only returns garbage. It faults once
the allocator has handed the freed slot's page back to the OS, which is
why it took a long session with tabs opened and closed.

**Fix:** `assign` now evicts by the region's new, owned copy and its
stored key. Nothing it reads after the free can be borrowed from what
was freed. A caller's list may therefore borrow from any assignment, its
own included. The SDK doc for `assignedSurfaces` (Host and EditorAPI)
now says so: "read, edit, write" was already how every caller used it.
It is a comment change only, and the fingerprint does not move.

The ownership is fixed in `State`, not with a guard in
`evictIdFromOthers`, so every caller of the read-edit-write pattern is
safe without each one copying first.

**Found in review, fixed here too:**
- `assign` now takes its own entry's key and list out of the map before
evicting. Rewriting another region runs `getOrPut`, which grows the map
*before* it looks up even an existing key, so a pointer into the entry
could go stale mid-loop. This was a second use-after-free of the same
kind.
- `evictIdFromOthers` kept at most 32 regions and 32 tabs in fixed
buffers and swallowed `setAssignment` errors. A pane of 40 tabs that
gave one up came back with 32, with no error. It now uses lists, and its
errors reach `assign`'s caller.

## SDK impact

- [x] None (doc comments on `assignedSurfaces` only)
- [ ] 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:
- **New test:** "assigning a region a list borrowed from the assignments
it replaces" (`tests/integration.zig`). It uses
`std.heap.page_allocator`, so every free unmaps and a read of a freed id
faults every time. Without the fix it crashes with the same stack as the
report (`mem.eql` ← `evictIdFromOthers` ← `assign`). With the fix it
passes.
  - **Two more tests**, each failing before its fix:
- "assigning a region whose ids other regions hold, whatever the map's
fill" tries every count of regions up to 63, so some count leaves the
map full at the rewrite. On the page allocator it segfaulted at the
stale `key_ptr`.
- "taking one id out of a region of many keeps the rest" expected 39
tabs and found 32.
- **Whole-editor test:** the headless test now creates four documents
with `host.openBeside()` into two panes, closing every other one. It
checks that the person stays in their pane and that the last document is
active beside them. That test runs on `smp_allocator`, so it exercises
the path but would not reliably catch the read on its own.
- **Real run:** a Debug build with `--profile` set to a fresh directory,
with pixi and agent `main` built against this tree (`--fork`). Through
the agent's `fizzy-mcp`, I ran 8 `pixi.create` calls with paths and 3
`fizzy.close` calls. That ended with two panes, holding `s2, s4` and
`s6, s7, s8`, and fizzy kept running. The same sequence run 3 times on a
build without the fix did not crash either, because the freed slots'
pages weren't unmapped; the page-allocator test is the deterministic
proof.
- **Gates:** `check-fmt`, `zig build`, `test` and `test-integration`
(53/53 steps succeeded, 558 tests), plus `check-web`, `test-sdk-version`
and `check-tests-reached`.
- [ ] Windows:
- [ ] Linux: (CI)
- [ ] Web:

## Follow-ups

None.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…g joins (#479)

Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 3
(text's half; pixi's follows in its repo).

## What changes

- **Text reports every step** through `Host.recordStep`, not only while
recording. The host keeps who made each one (#474); working alone, it
keeps nothing. Encoding a step's `effect` still happens only while the
document is recorded, so a document nobody records costs one call per
step.
- **Each step carries `top`:** the last applied entry after it
(`History.lastApplied`), for added, joined, undone and redone alike. The
host knows the top step and its author without asking for the line,
which Undo's "Undo Claude's …" label (step 5) needs.
- **One hand's typing never joins another's run.** `History.end` takes
`may_join`. The editor's edit hook fills it from `Host.mayMerge` on the
step on top. When that step is someone else's, VSCode's grouping rule
doesn't get to fold the new keystroke into it: an agent typing doesn't
extend the person's step, nor the person an agent's.
- **Typing calls `documentEdited`,** as its doc comment always said it
did. A person's typing runs no command, so it still raises no activity
bubble.

## SDK impact

- [x] None. Text is built in, and #474's API is used as is.

## Verified

- [x] macOS:
- **New unit test:** an edit with `may_join = false` starts a step of
its own where VSCode's rule would have joined it. The existing history
tests pass `true` and behave as before.
- **`zig build test`:** 573/574 (1 skipped). `check-tests-reached`
passes.
- **`test-integration`:** 53/53 steps, 556/556 tests. The whole-editor
test's check from #475, that the tour adds no unattributed step, now
covers real steps: the tour's typing and commands are each attributed to
the tape. Two warnings print, and both are on unmodified `main` too (a
demo replay mismatch and a float position).
  - `check-fmt`, `check-web`, `test-sdk-version`.

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

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

`sdk_version` goes from 0.2.28 to 0.2.29. Merging this tags
`sdk-v0.2.29` and publishes its tarball.

**Why now:** authors (#474, #475) need a release before plugins can use
them:
- the agent acts as its connection around each command, and plays its
pointer tapes with `playAs`;
- chat acts around its writes;
- pixi's calls that wait out the person's stroke keep their author
(`plans/SHARED_HISTORY_PLAN.md` step 2's plugin PRs).

## SDK impact

- [ ] 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`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**`sdk` PRs since `sdk-v0.2.28`:**
- **#474:** who made each step.
- `sdk.Author`; `Host.act` / `endAct` / `acted` / `actAgain` / `author`
/ `setInputAuthor`.
  - The host keeps a per-document author map filled in `recordStep`.
- `stepAuthor`, `topStep`, `mayMerge`, `forgetDocumentAuthors`, and
`Step.top`.
- The fingerprint moved. Everything is additive (a defaulted field, new
functions).

**Reaching plugins, no fingerprint move:**
- **#475:** the automation service's `playAs(bytes, author)`
(`service_version` 5 → 6). Activity titles name an acting agent ("Claude
· Draw").
- **#468:** `core`: `MenuRowOptions.anchor`, a stable name for a menu
row's button.

## Verified

- [x] macOS:
- On this head, `zig build test-sdk-version` passes, and
`scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.29.tar.gz`.
- fizzyedit/pixi, fizzyedit/agent and fizzyedit/atlas `main` build
against this `sdk/` (`--fork`) with no changes.
- [ ] Windows:
- [ ] Linux: CI.
- [ ] Web:

## Follow-ups

- The repin automation opens `sdk: repin to fizzy SDK 0.2.29` PRs in the
store plugins whose fingerprint moved.
- The agent has no repin automation: its repin is a hand-made PR,
carrying the agent's `act` around `agentCall` and `playAs` for its
pointer tapes.
- Chat's repin carries its `act` around ACP writes.

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

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

`Tests (Windows)` failed about one run in thirty (4 of the last 140
jobs) with `test runner failed to respond for 1m…` on whichever tiny
std-only test was unlucky: `fizzy-glass-look-tests`,
`backend-titlebar-tests`, `fizzy-profile-causes-tests`,
`fizzy-text-doc-state-tests`, `fizzy-profile-spans-tests`,
`fizzy-settings-plugins-zon-tests`. CI now compiles every unit test
(`zig build check`) in a step of its own before `zig build test` runs
them, so no compiler is alive while a test runs.

**The cause is in Zig 0.16's std, not in fizzy.** `processSpawnWindows`
(`std/Io/Threaded.zig`) creates each child's pipe ends inheritable and
calls `CreateProcess` with handle inheritance on, with no
`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` and no lock around it. So a child
inherits *every* inheritable handle the build runner holds at that
moment, including the pipe ends it is making for its other children. A
compiler spawned while a test's pipes exist keeps a copy of that test's
stdout write end. After the last test, the runner sends `exit` and waits
for stdout EOF under its 60 s response timeout
(`std/Build/Step/Run.zig`, `waitZigTest`/`requestNextTest`). The test
has passed and exited, but EOF comes only when the compiler exits, so
the step fails with "failed to respond" and reports no test as failed.
Every failing run had a test compile that took ≥1 min
(`fizzy-crash-tests`, `fizzy-singleton-unix-tests`,
`fizzy-plugin-store-tests`). After `check`, `zig build test` finds every
compile cached and spawns only test binaries, which live for
milliseconds.

**The transport tests were a red herring.**
`fizzy-native-transport-tests` passed (21/21) in every failing run. In
the PR #460 failure it ran a minute *after* the hung tests had started.
Its `error.Unexpected NTSTATUS=0xc000020d (CONNECTION_RESET)` trace
comes from the cancel test: its holder closed the accepted socket with
the request unread, Windows answers that with a reset, and Zig 0.16's
`netReadWindows` maps every status it doesn't list (this one included,
which should be `error.ConnectionResetByPeer`) to `error.Unexpected`,
dumping a stack trace on stderr. The holder now reads the request first,
so the close is a clean FIN and the trace is gone.

**How it was found** (throwaway workflow on `ci/windows-flake-diag`,
since deleted):
- 40 test-only `zig build test` loops on 4 Windows runners: 0 hangs. 96
loops recompiling every test binary each time (from PowerShell, then
again exactly as CI runs it, through Git Bash and `ci-retry.sh`'s
`tee`): 0 hangs. Those recompiles take ~4 s each and never produce the
minute-long compilers.
- A standalone repro with no fizzy code: 8 empty tests, each run beside
a 75 s `ping`. On `windows-latest`, `instant-2-tests` failed with `test
runner failed to respond for 1m5.202ms`. Without the sleepers it passed,
and on `ubuntu-latest` it passed both ways (posix pipes are
`O_CLOEXEC`).
- Defender real-time protection is off on the runners, so scanning is
ruled out.

Tried and dropped: `--test-timeout` (it raises the response timeout too,
but only hides the wait), `-j1` (slow), and retries (CONTRIBUTING: fix
the cause).

## SDK impact

- [x] None

## Verified

- [x] macOS: `zig build test -Dtest-filter=cancel` passes with the
transport change.
- [x] Windows: [run
38968162178](https://github.com/fizzyedit/fizzy/actions/runs/38968162178)
plus 5 reruns of `Tests (Windows)`, all green: 563/568 passed (5
skipped) each time. `Compile tests` did the compiling, and in `Run
tests` every compile was `cached` (the step takes ~12 s), so no compiler
ran beside a test. No `NTSTATUS`/`failed command` lines in any of the
six logs. At the old ~3% rate, six green runs alone prove little; the
evidence is the repro plus that invariant.
- [x] Linux: `Tests (Linux)` and the rest of CI green.
- [ ] Web: not affected.

## Follow-ups

None. The cause is in Zig 0.16's std; this is fizzy's workaround, and
the pitfall in `docs/PITFALLS.md` keeps the two steps apart.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts were main's builtin.target rename (#435) against the same
rename on zig/0.17, taken from zig/0.17; check-examples.sh,
plugins/text's selection hash and the tape reader taken from main.
rewrite.sh re-run; by hand: a multi-character string repeat (`**`) in
app/state_delta.zig's test as a comptime loop, the tape reader's new
field loop in 0.17's shape, and the recorded fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne foxnne added the sdk Moves the plugin boundary's fingerprint; ships at the next SDK release label Oct 17, 2026
@foxnne
foxnne added this pull request to stack #491 October 17, 2026 17:16
@foxnne
foxnne merged commit 6d94889 into zig/0.17 Oct 17, 2026
13 checks passed
@foxnne
foxnne deleted the zig/merge-main-1017 branch October 17, 2026 17:16
foxnne added a commit that referenced this pull request Oct 17, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2, follow-up
(c): `@typeInfo`, with the fingerprint-equality test). Stacked on #486.

## What changes

- **The settings and command schema builders read 0.17's `@typeInfo`
directly.** `sdk/src/settings.zig` and `sdk/src/Command.zig` loop over
`field_names`, `field_types` and `field_attrs`, and take a cell's
default with `FieldAttributes.defaultValue`.
- This replaces the `ZIG017` stand-in that rebuilt 0.16-shaped field
records. `sdk/src/zig017_fields.zig` is deleted.
- The bare-payload structs (`Plain`) pass `@typeInfo`'s own name slice
to `@Struct` instead of copying it.
- **The proof the plan asked for.** `fingerprint.zig` was translated
loop for loop in #437, and this adds the check: a test hashing a fixed
set of types, one of each kind the hashes look into, at a depth that
reaches them all. Both hashes must equal the values **Zig 0.16**
computed for the same types, with the file as it was on `main`
(7b538aa):
- `hashAllShape`, the target-invariant hash that's recorded:
`0x2ae9f3848d7d3d9f`.
- `hashAll`, on fixed-width types only, since it folds in sizes:
`0xc0b59f8f6303fb84`.
- The 0.16 run used `main`'s file with Zig 0.16.0 and the 0.17 run uses
this branch's with Zig 0.17.0; the values match.

`ZIG017` markers left on the branch: the two per-pointer-capture notes
(dvui#1014) and `check-examples.sh`'s branch pins, which go back to
`main` at the merge.

## SDK impact

- [x] None. `zig build test-sdk-version` passes with the fingerprint
#486 recorded: the schema shapes plugins see are unchanged.

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 95/95 steps
  - `zig build check-tests-reached`: 120/120
  - `zig build check-web`: 41/41
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 77/77 steps
- [ ] Windows, Linux: CI.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne foxnne mentioned this pull request Oct 18, 2026
2 of 3 tasks
foxnne added a commit that referenced this pull request Oct 18, 2026
#486 was squash-merged, so zig/0.17 lost main as an ancestor and the next merge would have gone back to #434 as its base. This commit takes #486's original merge commit (46ddcd8) as a second parent and keeps zig/0.17's tree as it is, so main at #469 is the base again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk Moves the plugin boundary's fingerprint; ships at the next SDK release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant