Skip to content

zig: fizzy builds on Zig 0.17 - #575

Merged
foxnne merged 31 commits into
mainfrom
zig/0.17
Oct 18, 2026
Merged

foxnne merged 31 commits into
mainfrom
zig/0.17

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Part of fizzyedit/plans#5 (plans/ZIG_0_17_PLAN.md, step 2): 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:

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.

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). revive build system integration 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

  • 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. zig: build on Zig 0.17 example-app#8 and zig: build on Zig 0.17 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: zig: build on Zig 0.17 pixi#51, zig#15, atlas#16, drive#13, ghostty#14, agent#23, chat#16.

Verified

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

foxnne and others added 30 commits October 16, 2026 07:52
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2; the spike's
findings are in fizzyedit/plans#12)

The first PR into the `zig/0.17` integration branch. It opens the branch
**green on 0.17**, so each PR after it is a reviewable migration with CI
on every platform, not a long red stretch.

## What changes

- **Toolchain:** `minimum_zig_version = "0.17.0"` everywhere;
`ZIG_VERSION` 0.17.0 in `ci.yml`, `web.yml`, `release.yml`,
`plugin-loop.yml`.
- **Forks, pinned by commit on a `zig-0.17` branch in each** (tagged
when `zig/0.17` merges to `main`, per `docs/DEPENDENCIES.md` rule 2):
- `foxnne/dvui-dev` `2c899d18`: `fizzy-dev`'s 8 patches rebased onto
dvui's `main` (`41f6703`), one-line conflict; passes dvui's tests on
0.17.
  - `foxnne/zig-objc` `f90394b`: `@typeInfo`, `**`; passes its tests.
- `fizzyedit/md4zig` `eb5241c`: `@cImport` → translate-c module; passes
its tests.
  - `foxnne/assetpack` `91eb632`: one line.
- `fizzyedit/sdl_zig` `404f499`: five config values 0.17 rejects (still
SDL 3.4.16).
  - zf and nightwatch repinned to their 0.17 commits.
- **`scripts/zig-0.17/rewrite.sh`**: the mechanical half, re-run after
every merge from `main`, deleted at the end. `zig fmt`'s upgrades, the
optimize-mode, `dupeZ`/`bufPrintZ`, `EnumSet`, `std.meta.Int` renames,
the `@typeInfo` field loops, and the zon stand-in below.
- **Hand edits** from the spike: the build API renames, `**`, `@bitCast`
to extern structs, `fingerprint.zig` translated loop for loop,
Zoir/`Ast.parse` options, dvui's per-pointer capture (dvui#1014), and
others.
- **51 `ZIG017` stand-ins in 33 files**, each holding the place of a
migration a later PR does properly:
- the build system (custom `makeFn` steps, configure-time install paths,
`--libc`, the MSVC shim, Velopack's `addPathDir`);
- `std.zon.parse`: results now come from the caller's arena. The
stand-in parses into a short-lived arena and deep-copies into the old
allocator (`zig017ZonDupe`), with a file-local `zig017ZonFree`, so
ownership is unchanged and nothing leaks;
- the SDK's settings and command schemas reading field records
(`sdk/src/zig017_fields.zig`);
  - Velopack's `@cImport`.
- `recorded_sdk_shape_fingerprint` moved (dvui's shape since
2026-10-02). It is released with the merge to `main`.

Diff size: 156 files, +2165/−870. Most of it is the script's output and
the stand-ins' helpers. Read the script, not its output.

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

## Verified

- [x] macOS (arm64, 0.17.0): `scripts/check-fmt.sh`; `zig build`; `zig
build test` (91/91 steps); `zig build test-integration` (77/77 steps,
523/523 tests); `zig build check-web`; `zig build test-sdk-version`;
`zig build check-tests-reached`. The spike also launched the app (window
1.1 s after `main`).
- [ ] Windows: CI's cross-compile (not run locally).
- [ ] Linux: CI (not run locally).
- [x] Web: `zig build check-web`.

**Example repos:** `scripts/check-examples.sh` now builds the examples'
`zig-0.17` branches (`fizzyedit/example-app` `533c38b`, `example-plugin`
`34c94ec`; back to `main` at the merge, marked `ZIG017`). It also now
gives `example-app` this tree's SDK everywhere it pins one: its nested
`plugins/shader`, and its bundled `example-plugin` (`hello`), whose
pinned commit carried SDK 0.2.18. It stops macOS `tar` from adding
AppleDouble `._*` files, which the backend took for SDL patches when the
script ran on a Mac. All 8 example builds pass locally on macOS. **The
plugin dev install is done for real here** (moved down from #438), since
a stand-in that skipped it broke the plugin rebuild benchmark:
`sdk/plugin_sdk_install.zig` is a program the plugin's build runs,
reading `FIZZY_PROFILE`/`HOME` itself at make time, and
`FIZZY_PLUGIN_USE_LLVM` becomes `-Dplugin-use-llvm` (0.17 caches the
configuration without regard to the environment), which
`scripts/plugin-loop/bench.py` passes.

## Follow-ups

The next PRs into `zig/0.17`: the build system (next), zon arenas,
`@typeInfo` with a fingerprint-equality test, Velopack translate-c, the
example repos.

🤖 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 2: the build
system). **Stacked on #437**: based on `zig/017-scaffold`, so this diff
is only the app's build-system work. The plugin dev install and
`-Dplugin-use-llvm` moved down into #437. It goes to `zig/0.17` once
#437 merges.

## What changes

Zig 0.17 runs `build.zig` in a configurer and the graph in a separate
maker. There are no custom steps (`makeFn`), no install paths at
configure time, and the configuration is cached.

- **"Set aside a running executable"** (Windows hosts) becomes
`build/set_aside.zig`, run before the install. Its directory comes from
a new `common.installDirPath`: a `LazyPath` relative to the install
prefix, resolved when the build runs, in place of `b.getInstallPath`.
The packaging script's `--packDir` uses the same helper.
- **The `update` step and root `update.zig` are deleted.** They pulled
dvui-dev `main` and a zigwin32 0.16 branch, neither of which fizzy pins
any more.
- **Configure-time reads are declared**
(`dependOnFileContents`/`Metadata`/`DirectoryContents`): `VERSION`, each
plugin's `plugin.zig.zon`, `web/index.html`, the web plugin checkouts
and their `web/` dirs, `release.yml`, `patches/sdl`. Without that, 0.17
reuses a stale configuration (checked: a changed `VERSION` now reaches
the binary). Existence checks and directory opens go through `b.root`'s
own `Path` API.
- **A path that may not exist** (a web plugin checkout and its `web/`
directory; #437 does the same for a plugin's `release.yml`) is declared
by `dependOnPathExistence`: the file when it exists, otherwise the
nearest directory that does. 0.17's maker cannot load a configuration
that depends on a path inside a missing directory ("configuration
failed: FileSystemFailure"); a plugin without `.github/` hit it. Checked
by building `plugins/image` copied without its `.github/`.

**Left for the release-packaging PR**, each still marked `ZIG017`, since
they run only in release builds and need the release workflow to verify:
- the global `--libc` (→ `-Dwindows-libc`) and the MSVC translate-c
shim;
- Velopack's `addPathDir` for `mksquashfs`;
- the `FIZZY_MACOS_*` signing fallbacks and `SDKROOT`, which are
environment reads at configure.

## SDK impact

- [x] None: no `sdk/` file changes here (the plugin install moved to
#437).
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved
- [ ] SDK release

## Verified

- [x] macOS (arm64, 0.17.0):
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps),
`zig build test-integration` (77/77 steps), `zig build check-web`, `zig
build test-sdk-version`, `zig build check-tests-reached`.
- `scripts/check-examples.sh` against the examples' `zig-0.17` branches:
all 8 builds pass.
- Standalone `cd plugins/image && FIZZY_PROFILE=<sandbox> zig build`
installs into `<sandbox>/plugins/image/`, again on a rebuild with no
change. A clean `-Doptimize=ReleaseFast` plugin build fetches no
Velopack (AGENTS.md's acceptance test).
- The set-aside step, forced on for a macOS run: nothing to do on a
first build (install dir absent), checks the installed exe on the next.
- [x] Windows: `zig build -Dtarget=x86_64-windows-gnu
-Dnative-backend=fizzy -Dno-emit` configures and compiles. Not run on a
Windows host.
- [ ] Linux: CI.
- [x] Web: `zig build check-web`.

🤖 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 2: the build
system). **Stacked on #438** (and #437): based on `zig/017-build-steps`,
so this diff is only release packaging.

## What changes

**Velopack 1.2.161.** `velopack_zig` moves to graphl-tech/velopack-zig#9
(`a80b037`, its Zig 0.17 port), and with it Velopack's runtime goes from
0.0.1589 to 1.2.161. `vpk` in `.config/dotnet-tools.json` goes from
0.0.1298 to 1.2.161; `vpk` 1.2 targets .NET 10, so `release.yml`
installs 10.0, not 8.0. What users gain is in velopack-zig#9: a code
signature that survives updates on macOS, atomic macOS updates, the
Linux locator fix, delta fixes, and 64-bit Windows bootstrappers.
`Velopack.h` only gained declarations; fizzy's updater is unchanged.

**The rest of the build system's 0.17 stand-ins:**
- **`Velopack.h` as a module.** `@cImport` is gone.
`velopack.linkVelopack(.c_modules = …)` gives each module that calls the
C API a `velopack_c` import (translate-c), once per module: the
integration, demo and bench tests share one `app` module. The updater
imports it only when Velopack is enabled.
- **`vpk pack` runs through velopack-zig's `tools/run_env.zig`**
(`velopack.addVpkRun`), which sets `DOTNET_ROLL_FORWARD` and puts the
bundled `mksquashfs` on `PATH` when vpk runs. So does the MSVC setup
script. In 0.17, `Run.setEnvironmentVariable` freezes the configure-time
environment into the cached configuration, and `Run.addPathDir` is gone.
`MksquashfsBuild.bin_dir` is now `mksquashfs`'s own output directory,
with no install step.
- **Signing and the macOS SDK are options only.** `-Dmacos-sign-app`,
`-Dmacos-sign-installer` and `-Dmacos-notary-profile` lose their
`FIZZY_MACOS_*` environment fallbacks, and `SDKROOT` becomes
`-Dmacos-sdk`: 0.17 caches the configuration without regard to the
environment. `scripts/release.sh` and `release.yml`'s macOS job turn the
same variables into the options, and `packageall` forwards them to its
macOS children.
- **No global `--libc`:** `-Dwindows-msvc-libc` or the managed
`.velopack-msvc/` only. The managed manifest's existence and an explicit
manifest's contents are declared to the configure cache.
- **The MSVC translate-c shim works again.** It finds each module's
translate-c step through the generated-file index and `Step.cast`, as
`std.Build` does, in place of the removed `Step.id`.
- **`docs/PITFALLS.md`, "The build (Zig 0.17)":** the four traps
(environment reads, `setEnvironmentVariable`, undeclared configure-time
reads, declared paths in missing directories), each with what fizzy does
instead.

After this, 36 `ZIG017` markers remain on the branch, none in the build
system: the zon stand-ins, the `@typeInfo` helpers, the example-branch
switch.

## SDK impact

- [x] None: no `sdk/` file changes here.
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved
- [ ] SDK release

## Verified

- [x] macOS (arm64, 0.17.0):
- `zig build package` packs a release with vpk 1.2.161:
`fizzy-arm64-macos-Setup.pkg`, `-Portable.zip`,
`fizzy-0.2.0-arm64-macos-full.nupkg`. The bundle carries
`Contents/Resources/sq.version`, 1.x's signature-preserving layout.
Unsigned: no identities on this machine.
- `zig build -Dvelopack=true` links Velopack (36 `vpkc_` symbols); `zig
build test-integration -Dvelopack=true` (79/79 steps).
- `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
test-integration` (77/77 steps), `zig build check-web`, `zig build
test-sdk-version`.
- `scripts/check-examples.sh` against the examples' `zig-0.17` branches:
all 8 builds pass.
- `zig build -Dtarget=x86_64-windows-msvc` configures;
`-Dtarget=x86_64-windows-gnu -Dno-emit` builds.
- [ ] Windows: not run. The MSVC path (`.velopack-msvc/`, the shim)
needs a packaging run.
- [ ] Linux: not run. The AppImage path is velopack-zig#9's `run_env
--path`, which its CI passes on `ubuntu-latest`.
- [ ] **A test release.** `release.yml` packaging all six targets,
signed, is the real check for this PR. It has no dry-run mode: it
releases an existing tag and stops at a *draft* GitHub release, which
nothing auto-updates to until it is published. So the check is a test
tag on this branch (e.g. `v0.2.0-zig017.1`), the workflow's draft
release, then deleting both. Tagging is the maintainer's call.
- [ ] An update round trip, from an installed 0.0.1589-runtime release
to one packed with this, per OS. It waits for that dry run's artifacts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#9

## What changes

The backend stops following the main window through Spaces
(`macos_monitor.unwatch`) before it destroys it. Quitting a dvui app on
macOS crashed in `objc_msgSend` from `window_in_fullscreen_space`, under
`SDL_VideoQuit`: the full-screen monitor's display-link pump, and a
block it queued as a way out of full screen ended, touched the window
after SDL had destroyed it. Floats' windows were already unwatched as
they closed (`viewportClose`); the main window never was.

fizzy's own quit goes through the same `deinit`, so it had the same
exposure.

## SDK impact

- [x] None

## Verified

- [x] macOS: two crash reports (`dvui-app`, quitting after full-screen
use) before; none in the many quits since.
- [ ] Windows: not affected (macOS only).
- [ ] Linux: not affected (macOS only).
- [ ] Web: not affected.

## Follow-ups

None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Part of fizzyedit/plans#9

## What changes

On fizzy's backend, every dvui floating window that is not modal,
`dvui.osWindow`'s included, is shown in an OS window of its own: the
OS's title bar and buttons, moving, resizing, maximize, full screen and
close. Its content sits below a title bar in the theme's colour. Modal
dialogs stay in the window they are over. An app writes plain dvui; on a
backend without OS windows, or past the backend's limit, the same
floating window stays in the main window with dvui's own header. fizzy
opts out (`floating_os_windows = false`, set in `Entry`): its floats
keep `Popout`.

How:
- **dvui asks** (dvui-dev `bd7736d`, pinned here):
`Backend.floatingWindowOs(id, open_flag, min, max)` as a floating window
begins, and `floatingWindowTitle(id, title)` from `windowHeader`. True:
dvui draws no frame of its own and the header draws nothing; the title
goes on the OS window.
- **The OS owns each window's place and size** (`os_windows.zig`). Each
frame the floating window takes its window's frame, and is never grown
to fit its content (`_auto_size`). Its `min_size_content` /
`max_size_content` become the OS window's limits: dvui's demo, fixed at
600 wide, can't be stretched or maximized. Following the content's own
minimum instead fought the person as a pane collapsed.
- **The keyboard follows the OS's key window**: a new window opens key,
and dvui focuses whichever window the OS makes key.
- **Windows**: owned windows, the DWM caption in the theme's colour; no
maximize button where a window has a maximum size (Windows ignores the
maximum when maximizing).
- **Linux**: the non-callback frame loop runs the same steps as
`appIterate` (it ran none of them: no OS windows at all).
- **Shared with Popout**: `viewports.keptTarget` (Popout's `keepTarget`,
moved), `viewports.publishScreens`.
- **dvui pin**: as `git+https`. Zig 0.17's build-time fetch left this
commit's GitHub tarball unstripped (`docs/PITFALLS.md`).

## SDK impact

- [x] Core-only or additive: reaches plugins at the next SDK release
(the dvui pin; the fingerprint is unchanged)

## Verified

- [x] macOS: `fizzyedit/example-app`'s `run-dvui` (zig-0.17 branch) on
this tree: the demo and two child windows as OS windows, combo boxes and
dialogs in their own window, theme changes, size limits. Screenshots and
a pixel measurement of the title bars.
- [x] Windows: Windows 11 ARM VM: owned windows with the system caption;
children stay above the main window after a click on it; the demo has no
maximize button, the others do.
- [x] Linux: Ubuntu 26.04 VM, XWayland: each child its own window,
transient for the main window, GNOME's decorations; the demo can't be
maximized (fixed width), the others can. On Wayland floating windows
stay in the main window (a window can't be placed there), as before.
- [x] Web: `zig build check-web`.

## Follow-ups

- macOS window shape and full screen: the next PR in this stack.
- Popout's float ↔ OS window loop on the same core as `os_windows`,
leaving Popout fizzy's own parts (drag, drop zones, glass): its own PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Part of fizzyedit/plans#9

## What changes

A plain dvui app's main window and its floating windows' OS windows take
the shape fizzy's own windows have on macOS, so they go into and out of
full screen the way fizzy's do: smoothly, the window's own content
growing, not AppKit's pictures.

- **Content under a clear title bar, with the title showing.** The
backend keeps a band under the title bar clear and gives dvui only what
is below it (`main_picture`, `macos_monitor.titlebarStrip`, the same
strip fizzy's header follows), so the band shrinks to nothing in full
screen and comes back on the way out.
- **A backdrop in the theme's colour**
(`fizzy_macos_window_plain_backdrop`) under the drawing, with the
window's corners, in an otherwise clear window. On the way into or out
of full screen it follows the picture as fizzy's glass does
(`glass_follow`), and draws the shadow and outline the OS draws for no
full-screen window. It's refreshed however a window gets into or out of
full screen (Split View too).
- **The full-screen monitor** is installed by the backend for an app
that hasn't installed it itself (fizzy has), with the window standing
still at its full-screen size while its picture grows or shrinks
(`still`). The renderer lays the main window's frame down at the picture
(`window_offset`), which fizzy's `FrameTarget` does for fizzy.
- **Shadow and outline**: taken again once a picture is up and as the
size changes, as for fizzy's app-framed floats; SDL turns the shadow off
on a transparent window.
- **One frame rate fix that helps fizzy's floats too**: while one
float's window moves itself into or out of full screen, the others are
held as the main window is (`mainHeld`).
- `titlebarStrip` serves fizzy's header and the plain band
(`backend_native.titlebarStripHeight` calls it).

Tried and dropped: keeping a plain window's own titled style through the
monitor (the content landed a title bar off), and stepping the window's
size through the animation (the demo laid out at each size in a new
drawable and ran choppy).

## SDK impact

- [x] None

## Verified

- [x] macOS: `run-dvui` (example-app, zig-0.17): full screen in and out
on the main window and children, smooth by eye; title band, main content
and child bands measured at the same colour (`#2b2b2b`); shadow and
outline back in a window capture. Child windows' transitions measured at
18 ms a frame against the main window's 5.5 (work under 1.5 ms): still
to explain.
- [ ] Windows: not affected (macOS only).
- [ ] Linux: not affected (macOS only).
- [x] Web: `zig build check-web`.

## Follow-ups

- Why a child window's full-screen transition runs at ~55 fps (sample it
while it runs).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Part of fizzyedit/plans#9

## What changes

On macOS a plain dvui app's floating windows' OS windows stay above the
main window, as owned windows do on Windows and transient windows on
X11, by being child windows of it (`addChildWindow:`). The window server
then never puts the main window over them: clicking or raising the main
window no longer flashes it over them. A child becomes key and comes in
front of the other children.

Child windows otherwise move with their parent; owned windows don't. So
while the person moves or live-resizes the main window, its children let
go (put back where they were as the press began, and kept above it), and
attach again the moment the press is let go. A plain click on the title
bar lets nothing go: AppKit posts will-move for a click too, and letting
go and attaching around it let the main window's raise land between.

Full screen: children stay on the desktop. A window going into or out of
full screen lets go of its children and its parent before AppKit takes
child windows along, and nothing attaches to it until it has landed.
Attaching only happens where the main window is on the Space being
looked at and not in full screen.

Tried and dropped, each with a log of what AppKit did: children
following the main window into its full-screen Space. A window can't be
moved between Spaces through public API; attached from another Space, a
child stayed the child of a window it couldn't reach, and both were
lost. Inside the main window's Space, every way a child could take part
in it (a Space of its own, tiling, Split View) left the Space black or
out of reach, even as an auxiliary window.

## SDK impact

- [x] None

## Verified

- [x] macOS: `run-dvui` (example-app, zig-0.17), with a temporary attach
log read after each run: clicks and drags on the main window, full
screen in and out on the main window and children, clicking between
children.
- [ ] Windows: not affected (macOS only).
- [ ] Linux: not affected (macOS only).
- [x] Web: `zig build check-web`.

## Follow-ups

None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
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 (#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

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

## Verified

- [x] 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](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 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>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2, follow-up
(f): dvui API drift). Stacked on #488.

Two places that hand a lifted view's drag on to the mouse's capture read
dvui's internal `Window.captures[0]`. dvui 0.17 made capture per-pointer
(dvui#1014), and the switch PR left these as `ZIG017` stand-ins that
assumed the mouse:
- the view-drag lift in `app/layout/Chooser.zig`;
- the workbench tab drag in `plugins/workbench/src/Workspace.zig`.

Both now call `dvui.captureMouseGet()`. That's dvui's public call
(CONTRIBUTING: dvui's public API over its state), and it returns the
capture of the pointer that last moved, so a touch drag keeps its own
capture instead of the mouse's.

## SDK impact

- [x] None

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 95/95 steps
  - `zig build check-web`: 41/41
  - `zig build test-integration`: 77/77 steps
- [ ] A view or tab dragged by hand: not done, because the screen is
locked.

🤖 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 2). Writes
down the three Windows traps the 0.17 release work (#448) cost time on,
so they cost the next person a minute.

## What changes

`docs/PITFALLS.md`, under "The build (Zig 0.17)", gets a "Windows, on
0.17" subsection:

- **An MSVC exe must link `oldnames` itself.** 0.16 did it for every
`*-windows-msvc` exe and 0.17 doesn't, so `getcwd`/`chdir` stop
resolving. Now in `build/exe.zig`.
- **`translate-c` takes `--libc=<ini>` before its `--`.** After the
`--`, the flag goes to the C front end and is lost. Now in
`build/msvc.zig`.
- **sdl_zig must filter SDL's `core/linux/` for Windows.** `SDL_dbus.c`
includes `<unistd.h>` before its guard. Done in fizzyedit/sdl_zig
`ef7eb36`; a move to upstream sdl_zig must keep the filter.

## SDK impact

- [x] None. Docs only.

## Verified

- [x] Docs only: checked each entry against the code it names
(`build/exe.zig`, `build/msvc.zig`, sdl_zig `build.zig` at `ef7eb36`).

🤖 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 3: pixi on
0.17). Found by building pixi against `zig/0.17`'s `sdk/`.

## What changes

- **`core.image.blitData` compiles on 0.17.** Its transparent path
converted a `dvui.Color.PMA` to `[4]u8` and back with `@bitCast`, which
0.17 refuses for an `extern struct` (the plan's `@bitCast` audit). It
now uses `std.mem.toBytes` and `std.mem.bytesToValue`.
- **A test compiles it.** Nothing in fizzy calls `blitData`; pixi does,
so the switch PR's build never reached it. The new test blits a
transparent pixel over an opaque one, and is reached from
`core/sizing_tests.zig`, the one root carrying dvui.

Other `@bitCast`s that fizzy itself never instantiates may be hiding the
same way. The store-plugin builds are what find them, and that's this
step.

## SDK impact

- [x] None for the shape: `blitData`'s signature is unchanged. Plugins
build this `core/` file in, and with it pixi builds on 0.17.

## Verified

- [x] macOS (arm64, Zig 0.17.0): `scripts/check-fmt.sh`, `zig build
test`, `zig build check-tests-reached` (914 tests in 113 files), `zig
build test-integration` (77/77 steps, 561/561; the sizing tests' 115
include the new one).
- [x] pixi builds against this tree's `sdk/` by path, and installs into
a sandbox `FIZZY_PROFILE`.

🤖 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 3: store
plugins on 0.17). The agent plugin's port needs it (fizzyedit/agent#23).

## What changes

- **`fizzy.plugin.installBeside(b, exe, .{ .plugin = "<id>" })`.** On
`zig build install`, it copies a second artifact into the plugin's own
`{plugins}/{id}/` directory, beside the library. The agent plugin ships
`fizzy-mcp` there, and the chat plugin looks for it. On 0.16 agent did
this with a custom `makeFn` step; 0.17 has no custom steps.
- **The install helper keeps the source's permissions,** so a program
stays executable. It's the same helper `install` already runs
(`sdk/plugin_sdk_install.zig`), now shared through one `addDevInstall`
function.
- `docs/PLUGINS.md` §2.4 says how to use it.

## SDK impact

- [x] Build surface only, shape unchanged: `zig build test-sdk-version`
passes with the recorded fingerprint. Plugin authors get the new
function with the 0.17 SDK release.

## Verified

- [x] macOS (arm64, Zig 0.17.0): `scripts/check-fmt.sh`, `zig build`,
`zig build test-sdk-version`, and `plugins/image`'s standalone `zig
build` (installs through the reworked helper).
- [x] fizzyedit/agent#23 builds against this `sdk/` by path, and
`fizzy-mcp` lands in `<profile>/plugins/agent/` as `-rwxr-xr-x`.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#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>
A clean merge; rewrite.sh re-run over the new code. By hand in #490's app/control: a test's string repeat becomes a splatted array, Ast.parse takes .{ .mode = .zon }, Zoir is read through a pointer, and a test's zon parse takes 0.17's options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): the last
stand-ins before `zig/0.17` goes to `main`. Stacked on #503; retarget to
`zig/0.17` once it lands.

## What changes

- **The tape reader drops its 0.16 workaround.** `sdk/tape/binary.zig`
fed std's inflate an `Endless` input, a stream's bytes followed by zeros
without end. That kept 0.16's inflate off a bug: `tossBitsShort` crashed
on a stream that ended inside its last four bytes. 0.17's
`tossBitsShort` checks the bits it has before tossing them, so the
reader is a plain `std.Io.Reader.fixed` again, and a stream cut short
comes back as `Corrupt`. The Adler-32 check stays: 0.17 still leaves the
checksum unchecked.
- **The readme and `docs/PLUGINS.md` say Zig 0.17.0.** That covers the
plugin template's `minimum_zig_version` and the release workflow's
`zig-version`.

Left for later, on purpose:
- `scripts/zig-0.17/` stays until the switch, in case `main` moves again
and needs another catch-up. It's deleted in the last PR before the
switch.
- `check-examples.sh` keeps pointing at the examples' `zig-0.17`
branches through the switch. It flips to `main` in a one-line PR right
after their 0.17 PRs land, so `main`'s CI is never red in between.

## SDK impact

- [x] None: no shape change, and the tape format is unchanged.

## Verified

- [x] macOS (arm64, Zig 0.17.0): `zig build test`, 99/99 steps, 583/584
(1 skipped). That includes `fizzy-tape-tests` 62/62, whose "not a whole
one" test reads a tape cut short at every byte, deflated and not.

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

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

Part of fizzyedit/plans#9 (`plans/DVUI_UPSTREAM_PLAN.md`), the
fizzy-backend side. This replaces #483's child-window model, after you
reported on `run-dvui` that:
- moving the main window sometimes moved the floats;
- a window going full screen took the others into its Space;
- the main window came in front of them afterwards.

## Why

#483 made each OS-framed float (`Viewport.native`) a child window of the
main window, so it would stay above it. **macOS has no window that both
stays above another and goes full screen on its own:**

| | Stays above the main window | Own full screen |
|---|---|---|
| Child window | yes, but it moves with its parent and goes into full
screen with it | no |
| Above the normal level | yes, but it's taken into the app's
full-screen Space as a palette, on the way there and when you swipe to
it | no |
| Normal window | no, it comes forward when clicked | yes |

This PR tried two of those in turn: a window level, then the level
dropped around full screen, then reordering windows when the main window
came forward. Each patch covered one moment, and the next test found
another.

## What it does now

The floats become **ordinary document windows**. That's how macOS apps'
torn-out windows work (VS Code, Xcode), and it's what an OS window of
dvui's is.
- Each goes full screen alone.
- Moving the main window moves only the main window.
- You decide which window is in front.

**Gone:**
- `attachNatives` and its per-frame checks;
- the press monitor, the frame restore and the reattach timer;
- the key-raise observer;
- the let-go/way-done marks;
- `macos_monitor.inTransition`.

2 lines added, 265 removed.

**Given up:** the floats are no longer held above the main window on
macOS. On Windows and Linux they still are, as owned and transient
windows, where that's the standard behaviour.

**A pitfall noted** in `backend/AGENTS.md`, under secondary windows: no
macOS window both stays above another and has a full screen of its own.

**A named follow-up:** a palette kind of window, an `NSPanel` that
floats over the app's windows and follows its full screen but can't go
full screen itself, offered through the backend's floating-window hook
for an app that wants tool windows.

## Not in this PR

A float's full screen is choppier than the main window's, and an outline
shows 30–50 px outside it on the way. Both need measuring on screen.

## SDK impact

- [x] None

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 91/91 steps
  - `zig build check-web`: 41/41
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 77/77 steps
  - `run-dvui` built and launched.
- [ ] By hand on `run-dvui`: a float and the main window into and out of
full screen, and moving the main window.

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

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

Part of fizzyedit/plans#9 and the multi-window plan (fizzyedit/plans#26,
step 3). Stacked on #492.

**What changes.** A plain dvui app on the fizzy backend now gets an OS
window only for a floating window that asks for one. By default that's
`dvui.osWindow`'s. Everything else, dvui's demo included, stays in the
main window. That's what David wants for dvui's demo: in-app floating
windows, plus an "I'm an OS window" demo.

- **dvui-dev `zig-0.17` (98a0e07):**
- `FloatingWindowWidget.InitOptions.os_window`, set by `dvui.osWindow`'s
fallback floating window.
  - `Backend.floatingWindowOs` gains `asked: bool`.
- This is a commit on top of the one fizzy pinned (bd7736d), so the old
pin stays reachable.
- **Backend:** `floating_os_windows` becomes `.asked` (the default),
`.all` (every non-modal floating window, as before) or `.none`.
- **Fizzy:** sets `.none`, since it takes its floats out through Popout.
- **`sdk/build.zig.zon`:** dvui pinned at 98a0e07, as `git+https` like
before.

`docs/DEPENDENCIES.md` now says what `zig/0.17` pins from dvui-dev:
patches 9 (the hook reshaped to
`floatingWindowOs`/`floatingWindowTitle`) and 10 (this one), on top of
the eight rebased onto upstream `main`.

## SDK impact

- [x] None. `test-sdk-version` passes with the recorded fingerprint.

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 91/91 steps
  - `zig build check-web`: 41/41
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 77/77 steps, 523/523 tests
  - dvui-dev: `zig build -Dbackend=testing test`, 37/37.
  - `run-dvui` built and launched. Not looked at on screen.

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

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

Part of the multi-window plan (fizzyedit/plans#26, step 2: a float's
full screen as smooth as the main window's). Stacked on #501.

On its way into or out of full screen, a float's window jumps to the
screen's size and stands still there, while its picture grows or shrinks
inside it. The main window does the same and runs smoothly. You reported
two problems on `run-dvui`: a float's animation is choppier, and the
space beyond it fills with black, with a band outside the window on the
way back.

**The frame rate, measured on `run-dvui`:** a float's animation ran at
**≈ 56 fps**, a steady 18 ms a frame, while the main window's presented
at ≈ 230 per second.
- The present itself took 0.03 ms, and turning vsync off didn't help, so
the wait was elsewhere.
- The frame loop sleeps each frame out to 1/60 s while the app is unseen
(`unseen()`, `hidden_frame_ns`). That stops a hidden app spinning a
core.
- But it counted only the main window. While a float goes into a
full-screen Space of its own, the main window is on another Space, so it
counted as unseen, and every frame of the float's animation slept: 16.7
ms of sleep plus about 1.3 ms of work.
- Now a frame presented into a viewport window that is itself visible
counts as seen (`viewport_seen`).

**Hangs mid-animation**, where the animation sticks, then jumps to its
end. A temporary frame trace (not committed) caught frames of exactly
1006–1009 ms, four in about 700 frames.
- That's Metal's one-second drawable timeout: a frame asked for the main
window's drawable while the main window sat on a Space nobody was
looking at.
- The main window was held back only while a float was mid-animation,
and SDL's occlusion flag lags AppKit's.
- Now it's also held whenever it sits on a Space nobody is looking at
(`fizzy_macos_window_off_active_space`). The next traced session had no
such frames in 1,288.
- On purpose, not "every way of being unseen" (`windowUnseen`): fizzy
shows its main window undrawn for its first frame (`showUndrawn`), and
held then, it would never draw that frame. Checked: fizzy starts and
reveals its window as before (on screen, opaque, 1130 ms after `main`).
- Left: one gap of 915 ms in that session. Only 105 ms of it was our
loop waiting for an event; the rest was on the main thread outside our
frame, so most likely macOS's Space switch.

**Four more problems, also fixed here:**

1. **The shadow was redone every frame.** A float re-asked the window
server for its shadow whenever its *picture* changed size, which during
the animation is every frame. The window server works a shadow out from
the window's pixels, here those of a full-screen window. The trigger is
now the *window's* size, as for the main window, so it happens once, at
the jump.
2. **The background filled the screen around the picture.** A float's
backdrop view supplies its colour and follows the picture, and the
window behind it should be clear. But `skin` set the window's background
to the theme colour, and `setBackground` applies `skin` to every float's
window again. That painted over the clear background the backdrop had
set, filling the full-screen-size window around the picture: dark in a
dark theme. Now a window with the backdrop gets its colour on the
backdrop and stays clear (`fizzy_macos_window_has_plain_backdrop`).

3. **Square corners inside rounded ones.** In your screenshot the
picture's square bottom corners stuck out past the backdrop's rounded
ones. The OS rounds a window's content to its corners, but during the
animation the window is full-screen size and the picture only part of
it, so nothing rounded the picture. Now SDL's view is clipped to the
picture's rounded rect for the whole animation (`clip_content`). The
corner radius eases from the window's radius to square as the picture
grows, and back on the way out, instead of switching at one end.

4. **A band around the float when frames are slow.** Your screenshot,
taken while recording the screen, shows a second rounded outline about
40 px outside the picture. The picture moves only in the app's frames,
but the monitor's pump (a display-link tick) also stepped the animation
between frames, moving the backdrop with it. With frames slowed down,
the backdrop ran ahead of the picture. Now a still window's animation
steps only in the app's frames (`fizzy_macos_window_space_step`), so the
backdrop and the picture always move together.

Also in `backend/AGENTS.md`: step a still window's way only in the app's
frames.

## SDK impact

- [x] None

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 91/91 steps
  - `zig build check-web`: 41/41
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 77/77 steps
  - `run-dvui` built and launched.
- [x] Measured on `run-dvui` with a temporary trace (not committed): ≈
56 fps for a float, ≈ 230 presents/s for the main window, the present
itself 0.03 ms.
- [ ] By hand: a float into and out of full screen, as smooth as the
main window, with no black around it and no band on the way back.

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

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

Part of the multi-window plan (fizzyedit/plans#26). Stacked on #504.

**The bug** (you, on `run-dvui`): hovering a float's window lit up the
main window's widgets underneath it.

**Why:** AppKit sends mouse-moved events to the *key* window, wherever
the pointer is. SDL's `mouseMoved:` reports them as that window's, with
no check of what's under the pointer. So with the main window key,
motion over a float's window in front of it arrived as the main
window's, at a spot that window covers. Clicks weren't affected, because
AppKit sends those to the window under the pointer.

**The fix** extends `heldAcrossWindows`, which already handled a held
pointer:
- With a viewport open, a motion event is placed by where the pointer
actually is (`heldPoint`) when the app's window in front under the
pointer belongs to a viewport and isn't the window the event came from.
That window is found with `windowNumberAtPoint`, through
`fizzy_macos_window_at_pointer`.
- Over another app's window, nothing changes, so hover doesn't leak
through someone else's window either.
- The cost is one window-server query per mouse move, only while a
viewport is open and no button is held.

**A second bug, the same kind** (you, on `run-dvui`): with the main
window full screen, dragging dvui's demo window around made it vanish
over certain zones.
- While a button is held, `heldPoint` decides which of the app's windows
the pointer is over by comparing it with each window's rect on the
desktop. It never asked whether that window is on the Space being shown.
- The floats left on the desktop still matched where they lie there, so
the drag was read as over one of them. The demo was dragged into that
window's band, out of sight.
- Now a window nobody sees (`windowUnseen`, which covers being on
another Space) is skipped, both for the window that took the press and
in the scan.

## SDK impact

- [x] None

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 91/91 steps
  - `zig build check-web`: 41/41
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 77/77 steps
- [ ] By hand on `run-dvui`:
  - hover a float's window over the demo: nothing lights up beneath it;
- main window full screen, drag the demo window over where the floats
lie on the desktop: it stays.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): catching
`zig/0.17` up with `main` again, through #509.

## Please merge by fast-forward, not squash

This is one merge commit, with `main` (#509) as its second parent.
Squashing it would drop that parent again, and the next catch-up would
re-conflict. To fast-forward:

```
jj bookmark set zig/0.17 -r 44849f2 && jj git push --bookmark zig/0.17
```

## What changed beyond the merge

- **`build/package.zig`** takes `main`'s restructure (#537), with the
0.17 port reapplied by hand:
  - `vpk` runs through `velopack.addVpkRun`.
- The pack directory comes from `installDirPath`, and paths from
`b.root` (no `getInstallPath`, `getPath3` or `pathFromRoot`).
  - `release_mode` is read from `b.graph`.
- User options are read as 0.17's map, whose values are the union
itself.
- The signing options reach the per-target child builds through #537's
forwarding of every `-D` option.
- **`build/app.zig`:** the per-app `VERSION` read is declared to the
configure cache.
- **`app/control/`** (#499, #509): three tests' string repeats (`"x" **
n`, removed in 0.17) are splatted arrays.
- **dvui-dev** moves to its `zig-0.17` branch at `8fa9b9c`. `main`
repinned dvui-dev for #540 and #544, adding five commits to the 0.16
`fizzy-dev` stack. Four of them are ported onto `zig-0.17`. The fifth
(the snapshot port) is already in upstream's 0.17 base. dvui's tests on
0.17: 269/269, and the SDL3 build passes.
- **Fingerprint re-recorded:** `0xf20b8afc2f374648`, from #536's SDK
change plus the ported dvui.
- `rewrite.sh` re-run over the new code.

The merge first reached #554, where `main` itself was red: #508's
openapi test clashed with #536. So it goes through #509, which fixed
that test.

## SDK impact

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

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`: 71/71
- `zig build test`: 105/105 steps (629/630 tests on the first full run,
1 skipped)
  - `zig build check-tests-reached`: 981 tests in 119 files
  - `zig build check-web`: 42/42
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 78/78 steps
  - `plugins/image` standalone build: 43/43
- [ ] 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>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2). Fixes
findings 1–3 of the high-effort review of `main`…`zig/0.17`. Stacked on
#558; retarget to `zig/0.17` once that's fast-forwarded.

## What changes

- **A settings payload holding pointers is a compile error.** Zig 0.17's
zon parse allocates into an arena, so `applyZon` and `setZonText`
(`sdk/src/settings.zig`) parse into one and copy strings out. Any other
pointer would be left pointing into the arena after it's freed: a
`[]const []const u8`, or a struct with a slice. On `main` such a payload
parsed into `gpa` and was never freed either, because the schema owns
strings only. So `Schema` now refuses one with a message saying so
(`holdsPointers`). No setting in fizzy or the seven store plugins uses
one; all are bools, numbers, enums and strings. Command arguments are
untouched: they live in the call's arena, so a list argument is still
fine.
- **A failed copy frees the copies before it.** If `applyZon`'s copy-out
runs out of memory partway, it frees the strings it already copied and
leaves the live value as it was. Before, the first copies leaked.
- **Build-time reads respect the build root's `sub_path`.** The 0.17
port read `VERSION` (`build/app.zig`), `web/index.html`
(`build/web.zig`) and plugin manifests (`sdk/plugin_sdk.zig`,
`plugins/shared/build/helpers.zig`) through `b.root.root_dir.handle`,
and found `../core` from `root_dir.path`. Both ignore `b.root.sub_path`.
They now go through `b.root.joinString`, which keeps it. That's the same
result for every build today, and correct for a root that sits in a
subdirectory.

Findings 4 and 5 (one translate-c of `Velopack.h` per module, one
dev-install helper per artifact) are efficiency only and left for later.

## SDK impact

- [x] Shape unchanged (`test-sdk-version` passes as recorded). A plugin
author who declares a pointer-holding setting now gets a compile error
instead of a dangling value.

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`: 71/71
- `zig build test`: 105/105 steps, 630/631 (1 skipped), including the
new `holdsPointers` test
  - `zig build check-tests-reached`: 982 tests
  - `zig build check-web`: 42/42
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 78/78
  - `plugins/image` standalone: 43/43
- [ ] Windows, Linux: CI.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzy's glass material (fizzyedit/plans#27) and the multi-window
plan (fizzyedit/plans#26). Stacked on #510. The shape was agreed with
the glass session (fizzy-1d), which stacks core's glass shapes on this.

**Kept out of dvui**, by your decision: dvui keeps working on stock
backends, and the material is fizzy's. The OS windows' material is the
backend's job, and in-app glass is core's. The earlier version of this
PR added the type to dvui (dvui-dev `window-material-0.17`). That's
dropped: no dvui repin.

**`core/material.zig`:**
- `Material { kind: Kind = .none, opacity: f32 = 1 }`.
- `Kind { none, transparent, blur, frost, liquid }`, plainest first.
- `Kinds`, a struct of bools with `has(kind)`. The offer rule: a backend
offers a kind only if every surface it puts glass on can show it.
- `Material.effective(offered)`: the kind asked for, or the best one
offered below it, in the order liquid → frost → blur → transparent →
none. It has a unit test and a std-only root in `build/app.zig`
(`fizzy-material-tests`).
- `publish` and `get`: the effective material this frame, kept in dvui's
data as `native_glass` does, so a plugin's glass reads the same value as
fizzy's. Only these two reach dvui.

**The backends** answer in a plain `MaterialKinds` struct of their own,
because `backend/` doesn't depend on `core`. The app copies it field by
field (`Entry.materialOffered`):

| Platform | Kinds it offers |
|---|---|
| macOS | transparent, blur, frost (vibrancy); liquid from macOS 26
(`liquidGlassAvailable`) |
| Windows | transparent, frost (Acrylic) |
| Linux | transparent; blur where the compositor blurs behind windows
(`wayland_blur.offered`, a read-only twin of `behind`) |
| Web | every kind, all drawn in the canvas |

**Each frame**, before anything draws, `Entry` publishes
`Material{}.effective(offered)`. That's `.none` until fizzy has a
setting for it, which is fizzy-1d's part. **No behaviour changes.**

**Next:** applying the material to the OS windows (backend), and a plain
dvui app's main window no longer getting Liquid Glass by default.

## SDK impact

- [x] None. `test-sdk-version` passes with the recorded fingerprint. For
plugin authors there's a new `core.material` namespace.

## Verified

- [x] macOS (arm64, Zig 0.17.0):
  - `scripts/check-fmt.sh`
  - `zig build`
  - `zig build test`: 93/93 steps, `fizzy-material-tests` among them
  - `zig build check-web`: 41/41
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 77/77 steps, 501/501 tests
  - `zig build check-tests-reached`: 118/118
- [ ] Windows, Linux: CI.

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

---------

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2, "Nothing
lost": build times). Also review finding 5 of the high-effort review.

## What changes

- **The dev-install helper is compiled in Debug.** It copies a built
plugin into the plugins directory (`sdk/plugin_sdk_install.zig`), and
was compiled ReleaseFast. On 0.16 this was a custom build step, compiled
into the build runner. On 0.17 it's a program the build compiles, and at
ReleaseFast that took about 4 s of every cold plugin build. In Debug
it's about 1 s.
- **One helper per builder.** A plugin that also uses `installBeside`
(agent) compiled the same helper twice; it now shares one.

The other two helpers keep their modes: `sdk-meta` and `check` report an
ABI fingerprint that depends on the optimize mode.

## Measured

On a cold standalone `plugins/image` build, macOS arm64, under load from
other sessions:
- 0.16: 11.6 s
- 0.17 before: 18.4 s
- 0.17 with this: 14.0 s

The rest of the gap is dvui's own 0.17 build: its `translate-c` tool and
`generate-def` runs, since `@cImport` is gone. That's upstream's.

## SDK impact

- [x] Build surface only, shape unchanged (`test-sdk-version` passes).

## Verified

- [x] macOS (arm64, Zig 0.17.0): `scripts/check-fmt.sh`, `zig build`
71/71, `zig build test-sdk-version` 35/35, and `plugins/image` cold
standalone: 43/43 steps, the helper compiled once in Debug (1 s),
`image.dylib` installed into a sandbox profile.
- [ ] Windows, Linux: CI.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/zig-0.17/ re-ran zig fmt's upgrades and the one-answer renames after each merge from main; this is the last one before zig/0.17 reaches main, as its header said. check-examples.sh says when it points back at main.

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 18, 2026
@foxnne
foxnne enabled auto-merge (squash) October 18, 2026 19:15
@foxnne
foxnne merged commit 44ff62a into main Oct 18, 2026
13 checks passed
@foxnne
foxnne deleted the zig/0.17 branch October 18, 2026 19:33
foxnne added a commit that referenced this pull request Oct 18, 2026
## What changes

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

**Why now:** this is the first SDK on Zig 0.17 (#575,
`plans/ZIG_0_17_PLAN.md` step 2), and the plan has the release follow
the switch at once. A 0.17 fizzy refuses every plugin built against
0.2.29, and fizzyed.it/testapp is built from `main`, so the store
plugins can't trail by days. Their 0.17 PRs are ready and only need this
tarball to repin.

## 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.29`:**
- **#575: Zig 0.17.** Plugins need `minimum_zig_version = "0.17.0"` and
Zig 0.17 to build; there's no overlap window with 0.16. Also new:
  - `fizzy.plugin.installBeside`, for a helper program a plugin ships.
- A settings payload holding pointers other than a string is a compile
error.
  - The dev-install helper compiles once, in Debug.
- **#536:** a plugin can replace a command it names, and gets it back on
unload.
- **#538:** a command may answer in a later frame.
- **#540, #544:** dvui's front-to-back blocks (render order of floats,
glass and focus rings).
- **#563:** `dvui.pointerKind()` and `cursorSetPriority`; tabs show the
hand, and a tapped item is left unlit.

The fingerprint is `0x5f9c818a6972efd4`.

## Verified

- [x] macOS (arm64, Zig 0.17.0):
- On this head, `zig build test-sdk-version` passes, and
`scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.30.tar.gz`.
- All seven store plugins build against this `sdk/` by path with no
further changes: pixi, zig, atlas, drive, ghostty, agent and chat (their
0.17 PRs below). All seven loaded together in a 0.17 fizzy earlier on
the branch.
- [ ] Windows, Linux: CI.

## Follow-ups

- The repin automation opens `sdk: repin to fizzy SDK 0.2.30` PRs. The
store plugins' own 0.17 PRs carry the port and only need the pin moved:
  - fizzyedit/pixi#51, fizzyedit/zig#15, fizzyedit/atlas#16
- fizzyedit/drive#13, fizzyedit/ghostty#14, fizzyedit/agent#23,
fizzyedit/chat#16
I'll repin those branches to `sdk-v0.2.30` and close the bot's
duplicates.
- fizzyedit/example-plugin#13 repins to 0.2.30. Then
fizzyedit/example-app#8 pins it, `sdk-v0.2.30` and fizzy's switch
commit. Then `scripts/check-examples.sh` points back at `main`.
- fizzyedit/plugin-build-action's default `zig-version` is still 0.16.0;
every plugin passes its own.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 18, 2026
On Zig 0.17: main (with #575 and the SDK 0.2.30 release, #580) is merged
in, and every gate passes on the 0.17 toolchain.

Part of #530

## What changes

A plugin gets a folder of its own for what it keeps between runs that is
not a setting (recents, an index, a cache):

```zig
pub fn pluginDataFolder(self: *Host, plugin: *const Plugin) ?[]const u8
```

- `<config>/plugin-data/<id>`, the id in lowercase. It follows the
config folder, so a `FIZZY_PROFILE` sandbox gets its own.
- **Plugin ids are unique ignoring ASCII case, enforced before the disk
is touched.**
- `Host.pluginById` stays exact, as do
`isPluginDisabled`/`Undecided`/`AutoUpdate`, because everything keyed by
a loaded id needs exact matching. `registerPlugin` refuses `Graphl`
beside `graphl` (`error.DuplicatePluginId`), logging both names, through
the new `Host.pluginByIdIgnoringCase`.
- `App.pluginCaseClash(id)` checks for a loaded plugin, or a folder
already in `plugins/`, whose id is `id` in another case. It runs before
anything keyed by `id` touches the disk:
- the store's install/update path (`PluginStore.startDownloadUrl`,
before `plugins/<id>` is created or written: on macOS and Windows that
folder *is* the other plugin's), through a new optional
`PluginManager.VTable.refuseCaseClash`, since on main the store talks to
the app only through `PluginManager`;
    - `loadUserPluginById`;
    - the startup scan.
A clash becomes a store failure card through `App.recordCaseClash`: "id
'Graphl' differs only in case from the plugin 'graphl'".
- **Uninstall from a clash card never deletes the loaded plugin's
folder.** Before deleting `plugins/<id>`, `uninstallPlugin` compares it
with the loaded plugin's folder by what each is on disk
(`App.sameFolder`, same inode), not by name. On a case-insensitive
filesystem they are one folder, so only the card goes. On a
case-sensitive one, only that exact-case folder is deleted.
- **The startup scan doesn't let byte order choose.** Two plugin folders
whose names differ only in case (possible on a case-sensitive
filesystem) are one id twice: neither loads, and one card names both and
asks for one to be removed. It's the simplest deterministic rule. Which
build was meant can't be told from the folders alone, and the declared
id would be the same either way once case is folded. A folder whose name
case-clashes with a loaded plugin gets the case-clash card, as before.
- **Thread safety without walking the plugin list, symmetric for any
app.** Two optional `EditorAPI.VTable` hooks, both on the main thread:
- `pluginRegistered(ctx, id)`, fired by `Host.registerPlugin`, where the
app sets up the plugin's entry;
- `pluginUnregistered(ctx, id)`, fired by `Host.unregisterPlugin` before
the plugin's `deinit`. The app marks the entry leaving: it is still
handed out for that `deinit`, and stops at the next frame's top
(`App.releaseRetiredFolders`, which every app built on `App` already
calls). An app without fizzy's `Editor` stays correct.
- `pluginDataFolder` only reads the table: under the lock, then the
folder is made outside it, then `registered` is checked again under the
lock. Entries stay after a plugin goes, so a path a worker holds stays
valid.
- If registration-time setup runs out of memory, the plugin's id is
remembered (`plugin_data_missed`, no allocation) and its entry is made
on its first ask. The out-of-memory is logged.
- One key function (`pluginDataKey`) and one wasm-aware lock pair
(`lockPluginData`/`unlockPluginData`) serve both sides.
- **Ids that aren't plain identifiers:** not all registered ids pass
`isValidPluginId`; the store page registers as `fizzy.store`. So
`registerPlugin` doesn't enforce it. Such a plugin gets null from
`pluginDataFolder`, logged once, and the docs say so.
- **No `mkdir` on every call.** The folder is made on the first ask and
checked again only on an ask a second or more after the last
(`App.plugin_data_recheck_ns`). The contract, documented: a plugin whose
write into a kept path fails with `FileNotFound` asks again, a second
later if it just asked.
- **Failure backoff:** `failed_at` is recorded after `createDirPath`
returns. Asks within 5 s of it return null (`App.plugin_data_retry_ns`),
and each run of failures is logged once. Running out of memory is
logged.
- **`App.deinit` frees what the App owns**: the data-folder table and
the disabled/undecided/auto-update id lists, moved there from
`Editor.deinit`. `Editor.deinit` calls it through a `defer` at its top.
- Not `plugins/<id>`, which an update replaces and an uninstall deletes;
the App, Host and PLUGINS.md docs agree. Always local, whether or not
the open folder is on a mount.
- **Web:** as with `paletteFolder`, the path names no directory. It is
the prefix under which `core.fs.read`/`readZ`/`write`/`remove` keep
files in the page's storage; those four calls do the same on the
desktop, and `std.Io.Dir` calls on the path fail on the web.
- **Uninstall leaves it**, as it leaves the plugin's `settings.zon`
block. The named seam for removing it too is `Editor.uninstallPlugin`
(see Follow-ups).

Graphl asked for it (#530): today it keeps its recents beside
`paletteFolder()`'s parent.

Boundary: `EditorAPI.VTable.pluginDataFolder(ctx, id)` and the optional
`pluginRegistered(ctx, id)`/`pluginUnregistered(ctx, id)`, the
`Host`/`EditorAPI` calls, and `Host.pluginByIdIgnoringCase`. Docs:
`docs/PLUGINS.md` §3.3.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0xba4664870675e13d` after merging main, which released SDK 0.2.30;
whichever of this and #566 merges second records the combined value),
`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` (54/54 steps, 595/595 tests), `zig build
check-web`, `zig build test-sdk-version`, `zig build
check-tests-reached`, `scripts/check-examples.sh`. A new `sdk` unit test
covers `registerPlugin` refusing the same id and the same id in another
case. The headless whole-editor test (fresh profile) checks:
- `text`'s and `markdown`'s folders are `<profile>/plugin-data/<id>`,
exist, are stable across asks, and differ;
- `../escape` gets null and leaves no table entry, and `Text` finds
`text`'s folder;
- a test-registered `CaseTest` gets `plugin-data/casetest`, and
`casetest` is then refused registration;
- once `CaseTest` is unregistered its folder is still handed out (for
its `deinit`) until the next frame's top, then not;
- `loadUserPluginById("Text")` is refused before opening anything, and
its card shows failed with the case reason;
- uninstalling `Text` works, `text` stays loaded, and a file in
`plugins/text` survives (on macOS `plugins/Text` is that folder);
  - `pluginCaseClash("graphl")` finds a `plugins/Graphl` folder.

Not testable on this case-insensitive Mac: two folders differing only in
case (the startup-scan rule), and deleting the exact-case folder on a
case-sensitive filesystem.
  - `dotted.id` gets null;
- a deleted folder is made again by an ask once its trusted second is
wound past;
- a test-registered `blocked` plugin with a file in its folder's way
gets null, and null again right after the way clears (backoff); with the
backoff wound past, the folder is made and the failure cleared.

The warnings these tests cause are muted with `std.testing.log_level`.
The test's one remaining `failed command:` comes from its own warning
already on `main` (a tape needing `no-such-plugin`).
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds (`check-web`); not run in a browser

## Follow-ups

- Removing the data folder on uninstall is not built. It would be an
option of `Editor.uninstallPlugin`, probably ask-first in its dialog,
since the folder can hold work the user would miss.
- The other items in #530 (timed recents, a home surface, the
`scrolls_itself` warning) are left for their own PRs. Hiding a surface
is the next PR (`sdk/hide-surface`).

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

---------

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

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

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

## SDK impact

- [x] None (docs only).

## Verified

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 18, 2026
Now on Zig 0.17: `main` (with #575, the 0.17 switch, and #555) is merged
in, and the gates pass with 0.17.0.

Part of #523

## What changes

Opening a place's view picker (its corner button, or Layout settings) no
longer pays for every view's picture in one or two frames.

- **The picker chooses, one view at a time.** As the picker draws its
cards, it chooses the next view to draw offscreen when none is in flight
(`Picker.nextOffscreen`, `State.offscreen`). The choice is a card the
list laid out in view, whose view may be drawn offscreen
(`Layout.mayDrawOffscreen`, the one rule the capture step checks too):
capture works here, the view has no picture or failed capture, isn't
hidden, and drew nowhere this frame. A card doesn't count on its own
first frame, when every card packs into the top of a list without sizes.
A view scrolled or filtered out of the list before it is chosen is never
drawn for it.
- **The capture step stays where it was**: after the shape, before the
picker, as on `main`. `captureUnplaced` warms the chosen view and then
captures it, one offscreen draw a frame. The warm-up is at least 10
frames and at least a reveal's fade at the interface's speed, so a fast
display doesn't capture mid-fade.
- Running before the picker means whatever the view opens (a popup, a
float) stacks under the picker, and a picture taken there shows on its
card that same frame, with no extra refresh.
- The view is found by its registry index, checked against its id's
hash, and rescanned only if the registry changed. Its offscreen box is
keyed by the id.
- It is dropped once that rule no longer holds, for example when it is
drawn on screen this frame. It is never drawn on screen and offscreen in
one frame.
- If the view's card leaves the list early in the warm-up (filtered out
or scrolled away), the view is dropped, to be chosen again if the card
comes back. Later in the warm-up, it is finished. This is decided by
frame stamps: the card's `card_ns` against `Picker.list_ns`, when the
list last laid its cards out. A picker draw that returns before its list
never drops a view.
- **Failures** are recorded for the picker's session in `State` and
reset when it reopens.
- A view whose capture fails (its picture couldn't start, or gave no
texture) goes in an unbounded set (`recordCaptureFailed`,
`captureFailed`) and is never retried. Nothing else is affected.
- Capture is given up for the session (`State.capture_unavailable`) only
after three offscreen captures in a row couldn't start
(`noteOffscreenStart`). Their target is small and fixed, so that means
the backend has no texture targets. Once given up, nothing more is
captured, on screen or off, and no frame is asked for one.
- An on-screen view that couldn't start (a too-large target, say) is
only that view's failure.
  - No timers, and no retry loop.
- **Frames are asked for** while a view is in flight, and while a view
on screen lacks its picture, for example one that drew before the picker
opened. A failed view, or one in a place with no room, asks for none:
its card shows an empty tile until it has size.
- **On-screen views** are still captured where they draw, as on `main`,
unless capture has failed for them or here at all. They render that
frame anyway, and in the measurements below they never showed in a frame
over 9 ms.
- **Profiler scopes:** the picker's own work shows in the profiler
(devtools dashboard, `devtools.stats`) as `picker`, `picker: capture
unplaced` and `picker: capture`. They cost one branch each while the
profiler is off.

Nothing changes while the picker is closed: `captureUnplaced` still
returns on `snapshots_wanted`, and `drawSolid` adds two checks behind
it.

### What was measured

#523 was filed as a pixel flash: the on-screen views redrawn offscreen
and blitted back for a frame. On macOS I could not reproduce that.
- I read the frame target back for frames N-2..N+15 around a picker
open, with `drawCaptured` on and with it skipped. The frame where the
on-screen views were captured matched the frame before to within one
level out of 255 (65k pixels at ±1, 6 at ±2; the rest identical).
- Settings: default, and with float windows, native glass, menus and
dialogs all off.
- Content: a folder, a text document, and an image with its glass Fit
button.

What was there is a stall. These are profiler numbers per frame over 9
ms, with a temporary probe opening the Sidebar's picker. The machine was
shared and loaded (1-minute load 10–18), Debug build, two runs each,
before and after alternated.

| frame | before (main) | after (this branch, first version) |
|---|---|---|
| picker opens | 19–23 ms: devtools dashboard, store and settings all
drawn for the first time (pie 6 ms, store 2.5–3 ms, settings 1.7 ms)
plus the picker's first draw (3 ms) | 15–20 ms: one of them (the
dashboard: pie 6–8.5 ms) plus the picker (3.7–5 ms) |
| ~10 frames later | 20–26 ms: all three captured at once | 17–29 ms:
the dashboard's capture alone; store and settings (2–4 ms each) on
frames after it |

The "after" column is an earlier version of this branch, so it is not
this branch's final number. Since then:
- The final form draws nothing for an unplaced view on the frame the
picker opens, or on its list's first frame. It then draws one view a
frame instead of warming several at once, so the dashboard's first draw
and its capture each still land in a frame of their own.
- The on-screen copy from the last frame was dropped: it bought little,
since on-screen captures never showed in frames over 9 ms.
- The review rounds: one view in flight chosen by the picker, failures
remembered per session.

None of these were re-measured in the app: no visible sandbox runs.

**Why a capture costs so much:** it is the one draw of an offscreen view
that renders anything. The warm-up draws at its offscreen position are
clipped away and lay out only. So a capture costs a full render of the
view: 8–12 ms each for the devtools dashboard's pie and table in Debug.

Tried and dropped: photographing the on-screen views from the last frame
(`core.FrameTarget.snapshot`, as `ViewDrag.photographFromFrame` does) on
the frame the picker opens. On macOS it removed no visible flash, and it
needed per-frame state.

## SDK impact

- [x] None

## Verified

- [x] macOS: before/after above, profiler read through `devtools.stats`
over the control socket from a sandbox fizzy (`--profile`), picker
opened by a temporary probe, never committed. New integration test,
which fails with the in-view check removed. It uses only views no place
shows, so the first capture is offscreen.
- Nothing is drawn for a card on the picker's opening frame or its
list's first frame.
  - No frame draws two of the views.
- The cards in view are a prefix of the list, some but not all of it,
measured from the cards' tags rather than assumed from the window size.
- The window is checked to be the shim's 600 by 400, so the fold is
fixed.
- The first three cards in view are drawn in turn. No offscreen capture
can start in the testing backend, so after three capture is given up,
and nothing more is drawn.
- Reopened with the views in view already failed, none past the fold is
drawn.
- Taking those views out of the list (hidden; the testing backend's
wheel and typed input don't reach the popup) brings the next card into
view, and its view is drawn.
- Gates, on the merge with `main` under Zig 0.17.0:
`scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build
test-integration`, `zig build check-web`, `zig build
check-tests-reached`.
- [ ] Windows:
- [ ] Linux: the pixel flash #523 describes is unconfirmed (no Linux
run); this PR does not address it, so #523 stays open.
- [ ] Web: builds (`check-web`); not run.

## Follow-ups

- **The devtools dashboard's capture is still one 10+ ms frame** (Debug)
while its card is in view: its pie and table cost that much to render
once. Capturing at a smaller size, or the dashboard drawing less when
not on screen, would cut it.
- **The explorer's filter box costs 6.6–9 ms on one frame shortly after
the picker opens**, before and after this change. The Output view spikes
on that frame too, at about 3 ms. My guess, untested: dvui's font cache
growing for glyphs the picker's text introduced, a one-time cost.
- **The pixel flash on Linux/X11** from #523 is unconfirmed either way.
- **A view lifted out of the picker before its capture lands has no drag
picture.** That is the trade-off of spreading the captures: its card is
still an empty tile.
- **The testing backend can't exercise a successful capture**: it has no
render targets. So the capture path itself, one capture after another,
is covered by reasoning. The test exercises failed views and "capture
unavailable".
- **The test can't scroll or type into the picker:** dvui's testing
backend delivered neither a wheel nor typed text to the popup in
`PickerFrame`, so it takes views out of the list instead. Worth a look
on its own.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 18, 2026
Part of fizzyedit/plans#27 (step 2). Now on `main`: #546 landed with
`zig/0.17` (#575).

**This PR also carries #565**, merged into its branch: plain dvui apps'
windows take the window material in the backend, and `glass_look.zig`
moves to `backend/src/` as a shared module. So the diff is this PR's
three files (`Entry`, `Editor`, `Popout`) plus #565's.

## What changes

Every glass surface in the app and in plugins (dialogs, menus, the
command palette, tooltips, glass buttons, the dragged view) now draws in
one window material. That material is #546's `core.material`: whatever
the backend can show of what the app asks for.

| Material | In-app glass |
|---|---|
| `liquid` | as today: the glass program, with the bent edge and Apple's
lens on macOS; the OS's Liquid Glass for the drag where `native_glass`
is on |
| `frost` | the frosted pane: blur, tint, no bend, no glass program |
| `blur` | the same at half the blur |
| `transparent` | no blur: the window's colour at its opacity over what
is behind (`dialogFill`) |
| `none` | that fill, opaque |

- **Few new calls.** Core and every plugin dylib already read the values
the host publishes each frame: the dialog style, refraction, and whether
the glass program is on (`LiquidField.publishEnabled`). `Editor.tick`
maps the material onto those. `frost` takes the same code path fizzy
took with the glass shader switched off, so it is a path that has
already been exercised.
- **What it asks for.** `src/Entry.zig` asks for `liquid` until there is
a setting (plans#27, step 6), and publishes it before
`Popout.beginFrame`, so the drag's OS glass sees this frame's value.
`FIZZY_GLASS=frost|blur|transparent|none` asks for less, for testing.
- **Per-platform effect.** Per the rule agreed in plans#27 (one look per
OS, matching what its windows can show), this already changes Windows
and Linux, since they offer no liquid:
  - Windows frosts.
- Linux blurs where the compositor has a blur, and is transparent where
it doesn't.
  - macOS before 26 frosts.
  - macOS 26 and the web are unchanged.

## SDK impact

- [x] None. No new boundary: plugins read the published style as before.
- [ ] 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 26:
- `scripts/check-fmt.sh`, `zig build`, `zig build check-web` and `zig
build test-sdk-version` all pass.
  - `zig build test`: 93/93 steps.
  - `zig build test-integration`: 77/77 steps, 523/523 tests.
- On screen: a sandbox fizzy (`--profile`) launched with each
`FIZZY_GLASS`, the command palette opened with ⇧⌘P, and the window
captured. `liquid` shows the bent edge; `frost` the same blur
flat-edged; `transparent` the welcome logo sharp behind the tint; `none`
opaque. `blur` was launched and captured, but its screenshot wasn't
compared side by side with `frost`.
- [ ] Windows: not run. Its default changes (frost); that needs seeing.
- [ ] Linux: not run. Its default changes (blur or transparent).
- [ ] Web: `check-web` only.

## Follow-ups

- The setting itself (plans#27, step 6).
- The OS windows taking the same material: #546's author, next.
- `BlurBackdrop` reads only `render_cmds`, so glass inside a
front-to-back block (a place's corner button) blurs nothing of that
block (plans#27).
- `glass_look`'s forDrops/forText split goes in its own PR.
- `transparent` over busy content is hard to read (the palette over the
welcome screen). It is what the kind is; a later look at a minimum
opacity under `transparent` may be worth it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit to fizzyedit/example-app that referenced this pull request Oct 18, 2026
fizzy pins its Zig 0.17 switch (fizzyedit/fizzy#575), fizzy_sdk and the shader plugin pin sdk-v0.2.30, hello pins example-plugin's 0.17 branch, and the workflow builds with Zig 0.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit to fizzyedit/example-app that referenced this pull request Oct 18, 2026
fizzy pins its Zig 0.17 switch (fizzyedit/fizzy#575), fizzy_sdk and the shader plugin pin sdk-v0.2.30, hello pins example-plugin's main on 0.17 (fizzyedit/example-plugin#13), and the workflow builds with Zig 0.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit to fizzyedit/example-app that referenced this pull request Oct 19, 2026
* Zig 0.17

minimum_zig_version is 0.17.0, and `b.args` is now
`run.addPassthruArgs()`. Built by fizzy's check-examples.sh against
fizzy's zig/0.17 branch, which swaps every fizzy and SDK pin for its
own tree; the pins here move when fizzy's 0.17 SDK is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build on fizzy's Zig 0.17 switch and SDK 0.2.30

fizzy pins its Zig 0.17 switch (fizzyedit/fizzy#575), fizzy_sdk and the shader plugin pin sdk-v0.2.30, hello pins example-plugin's main on 0.17 (fizzyedit/example-plugin#13), and the workflow builds with Zig 0.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 19, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`): the last step of
the Zig 0.17 switch. **Merge after fizzyedit/example-app#8.** Until that
lands, example-app's `main` is still 0.16, and this PR's "Example repos"
check builds `main`.

## What changes

- **`scripts/check-examples.sh` clones each example's default branch
again**, as it did before the move. #597 made it fall back to `main`
when an example's `zig-0.17` branch is gone; with both examples' 0.17
PRs landed (fizzyedit/example-plugin#13, and example-app#8 just before
this), the plain clone is all that's left.
- **The dvui-dev pin is tagged**, per `docs/DEPENDENCIES.md` rule 2
("tag, then pin"): `fizzy-sdk-0.2.30-7` → `1b29a16` on `zig-0.17`, what
SDK 0.2.30 pins. The pin's comment and `docs/DEPENDENCIES.md` say so.
The table names the `zig-0.17` branch, and the `zig/0.17` wording
becomes "since fizzy moved to Zig 0.17 (#575)".

## SDK impact

- [x] None.

## Verified

- [x] `scripts/check-fmt.sh` clean; `bash -n scripts/check-examples.sh`.
- [ ] The example repos at `main`: CI, once example-app#8 is in.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit to fizzyedit/example-app that referenced this pull request Oct 19, 2026
* Zig 0.17

minimum_zig_version is 0.17.0, and `b.args` is now
`run.addPassthruArgs()`. Built by fizzy's check-examples.sh against
fizzy's zig/0.17 branch, which swaps every fizzy and SDK pin for its
own tree; the pins here move when fizzy's 0.17 SDK is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dvui: the demo opens under the app's windows, the main window themed

On fizzy's backend every floating window is now an OS window with the
OS's title bar (fizzyedit/fizzy#480-#483, on zig/0.17). The demo was
drawn last, so its window opened over the app's own and hid them; it is
drawn first now. The main window's content gets the theme's window
background, and the header says what the backend does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: the zig-0.17 branch builds as fizzy's CI builds it

The branch's own CI installed Zig 0.16 and failed at build.zig, and on
0.17 its pins (fizzy's 0.16 releases) would fail as well until fizzy's
first 0.17 SDK is released. So it runs fizzy's check-examples.sh on
fizzy's zig/0.17 with this checkout and example-plugin's zig-0.17 in
place of the pins: every shape, the replay app and the dvui app on both
backends, on Zig 0.17. Back to building the pins at the merge (ZIG017).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dvui: only the dvui.osWindows are OS windows; the demo stays in the main window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dvui: no OS windows to begin with; one opens when asked for or dragged out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dvui: New window and New OS window, and what the windows are made of

"New window" opens an in-app floating window, which on fizzy's backend
drags out into an OS window of its own; "New OS window" opens a
dvui.osWindow. A dropdown and a transparency slider pick what the
windows are made of (opaque, clear, blur, frost, Liquid Glass), the
colour the theme's window colour; the main window draws no fill of its
own, so the material shows through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dvui: the transparency slider keeps its height (and so its knob)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build on fizzy's Zig 0.17 switch and SDK 0.2.30

fizzy pins its Zig 0.17 switch (fizzyedit/fizzy#575), fizzy_sdk and the shader plugin pin sdk-v0.2.30, hello pins example-plugin's 0.17 branch, and the workflow builds with Zig 0.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build on fizzy's Zig 0.17 switch and SDK 0.2.30

fizzy pins its Zig 0.17 switch (fizzyedit/fizzy#575), fizzy_sdk and the shader plugin pin sdk-v0.2.30, hello pins example-plugin's main on 0.17 (fizzyedit/example-plugin#13), and the workflow builds with Zig 0.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dvui: the transparency slider stops short of clear for glass

Blur, frost and Liquid Glass keep a little of the window's colour: the
backend's floor (`material_min_opacity`, where it has one). All the way,
they were no glass at all. Only Clear goes all the way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: t <t@t>
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