diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..dc0d5375f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,212 @@ +# Fizzy + +Cross-platform, open-source general editor written in Zig, UI via [DVUI](https://github.com/david-vanderson/dvui). Targets native (macOS/Linux/Windows) and web (wasm32). Layout/UX is IDE-shaped (VSCode-like): sidebar rail + explorer, menubar, center tabs/splits, bottom panel, infobar. + +**Read this file first, then go deeper via the links below — don't re-derive the architecture from scratch.** +`sdk/`, `app/`, `core/`, `backend/`, `plugins/`, `web/` and `tests/` each have a short `AGENTS.md` +of their own: the rules and pitfalls of that directory. Read it before working there. Pitfalls with +no single home are in [`docs/PITFALLS.md`](docs/PITFALLS.md). + +**How we work** — one plan step per PR, claiming work before building it, jj, verifying, the SDK +release train — is [`CONTRIBUTING.md`](CONTRIBUTING.md). **Read it before you change anything.** + +These instructions are `AGENTS.md` files, which agents of any make read. Each sits beside a +one-line `CLAUDE.md` that imports it (`@AGENTS.md`) for Claude Code, whose own `AGENTS.md` +support is a fallback that a `CLAUDE.md` overrides; the root one imports `CONTRIBUTING.md` too. +Edit the `AGENTS.md`; leave the stubs as they are. + +## The core idea: fizzy + plugins + +Fizzy the app is itself a near-empty host (window, frame loop, layout shape, document model) that owns **no editing features**. Everything the user sees — pixel-art editing, the file explorer/tabs/splits, text editing — is contributed by **plugins** that register against a stable SDK. Plugins never import each other; they meet only at the SDK. + +``` +Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register(host) + vtable) +``` + +- **`sdk/src/`** — the entire contract. `Host` (registries + service locator), `Plugin` (identity + vtable of hooks Fizzy calls), `Surface` (the one drawable contribution: keywords say where it may go, the app's regions accept it), `RegionSpec` (a plugin declaring a place of its own inside the one it was given), `DocHandle` (opaque `{ptr, id, owner}` — Fizzy routes every doc op to `owner`, never inspects `ptr`), `EditorAPI` (Fizzy's own read/util surface plugins reach back through), `keywords.zig` (how a region accepts a surface), `dylib.zig`/`dvui_context.zig` (runtime-library C-ABI + dvui injection). +- **`app/`** — the framework an application switches on, never in a dylib: `App` (the host runtime state — host, documents, settings, keymap, layout state, watchers; fizzy's `Editor` embeds one as `editor.app`), `layout` (Layout/Region/State, the picker, view drags, split trees), `settings`, `keymap`, `Recents`, `store`, `update`, `watch`, `window`, `single_instance`. The module root is `app/root.zig`. Everywhere it needed to name fizzy is a `{ctx, vtable}` seam the app fills in. +- **`src/editor/`** — Fizzy itself: `Editor.zig` (frame loop, plugin registration/loading), `layout.zig` (fizzy's shape), `Menu.zig`, `Sidebar.zig`, `Settings.zig`, etc. +- **`core/`** — the shared floor used by Fizzy *and* plugins: `widgets` (Split, DockingWidget, Tabs, Tree, Canvas, BlurBackdrop), `anim`, `dialogs`, `draw`, `icon`, `image`, math, fs, paths, platform detection. Not plugin-owned; don't move it. (`Atlas`/`Sprite` are there only because pixi still loads its packed UI atlas through them — see the note in `core/core.zig`.) `core.fuzzy` is the one matcher behind every filter box in the app (settings tree, file tree, plugin store, LSP completions) — wrap zf through it rather than matching by hand, and remember **lower scores are better**. Draw icons with `core.icon.icon`, not `dvui.icon` — same arguments, cached as a texture. +- **`plugins/`** — bundled built-in plugins. Each is file-for-file the **same shape a third-party plugin would use**: root `plugin.zig` + identity-only `plugin.zig.zon` + `build.zig` + `build.zig.zon` (optional `src/**`), plus fizzy-internal glue in `static/`. No author `root.zig` or `.zig` hub — the build helper generates the dylib entry; files use named imports (`fizzy_sdk`/`dvui`/…). Builds standalone with `cd plugins/ && zig build`. + +**Two link modes, one source:** built-in plugins compile **static** (linked directly, all targets incl. web) or **dynamic** (`.dylib`/`.so`/`.dll`, desktop-only, `dlopen`'d — this is how third-party plugins ship too). `FIZZY_STATIC_=1` env var forces static for a given built-in (useful when debugging dylib loading). + +## Currently bundled plugins (check `ls plugins/` — this list moves) + +- **`workbench`** — file tree and the document panes (each pane is a region the plugin declares; each open document is a surface the app registers); owns no documents. Exposes a `files` service other plugins use to open/close/manage files without importing workbench. +- **`text`** — generic text/code editor; fallback owner for any file extension nothing else claims. +- **`image`** — read-only PNG/JPG/GIF/BMP/TGA viewer with zoom/pan (fallback when pixi is not installed). +- **`markdown`** — `.md` preview utility plugin. +- **`archive`** — opens a `.zip` as a mounted folder (the web's "open a vault" path, too). +- **`devtools`** — the dashboard: where the app's time goes, from `core.profile`, for a person and an agent alike. +- `shared` — build helpers used across plugins' `static/integration.zig` (not a plugin itself). + +**Pixi (pixel-art editor) and Drive (Google Drive as a mount) live outside this repo** as third-party-style plugins ([`fizzyedit/pixi`](https://github.com/fizzyedit/pixi), [`fizzyedit/drive`](https://github.com/fizzyedit/drive)) — they ship and update purely through the plugin store (`docs/PLUGINS.md` §6), with no special treatment in Fizzy itself. **Trust `ls plugins/` and `jj log` over any doc's plugin list.** + +## Writing a plugin + +1. Copy [`fizzyedit/example-plugin`](https://github.com/fizzyedit/example-plugin) (one surface, + nothing else) as your template; `plugins/text/` or `plugins/image/` when the plugin owns + documents. +2. Add identity-only `plugin.zig.zon` (`id`/`name`/`version`/`min_sdk_version`). Implement root `plugin.zig`: `Plugin` + `register(host)` + vtable; call `host.register{Surface,Menu,Command,Service,…}` as needed. A surface's keywords (`sdk.keywords`) say where it may go; the app's regions accept it, and the user can move it with the picker. +3. Plugin prefs: `sdk.settings.Schema(struct { … })` then `.register(host, &plugin, …)` — Fizzy draws them only while the plugin is loaded. User config on disk is ZON (`settings.zon` / `recents.zon`). +4. Editor plugins implement the document vtable cluster; a plugin that lays out documents itself declares its panes with `host.region(spec)` and draws the accepted surfaces where it wants them. +5. User-invoked actions are **`Command`s** — `"."`. +6. `zig build install` drops `{id}/{id}.dylib` (its own directory) into the fizzy plugins dir (no sidecar `.zon`). +7. Memory: `host.allocator` vs `host.arena()`; never touch `dvui.currentWindow().gpa` directly. +8. ABI: structural fingerprint at `dlopen` (`fizzy_plugin_abi_fingerprint`). A change that moves the boundary's shape records the new `recorded_sdk_shape_fingerprint` and leaves `sdk_version` alone; only an SDK release PR bumps the version (`CONTRIBUTING.md`, "Changing the SDK"). + +Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: **[`plans/PLUGIN_MANIFEST_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/PLUGIN_MANIFEST_PLAN.md)**. + +## Bundling plugins into an app + +Which plugins an executable links in is build data: `build/sdk.zig`'s `bundledPluginsModule` +generates `bundled_plugins` (`pub const modules = .{ @import("workbench"), … }`) and the +runtime iterates it — nothing in `app/` or `src/` names a plugin except the workbench (its +state still lives on the Editor). An app built on fizzy adds its own with `defer-app` + +`fizzy.buildApp(fizzy_dep, &.{ .{ .name, .module = dep.module("plugin") } })`; see +[`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles +`fizzyedit/example-plugin`. + +**The example repos move with fizzy.** `fizzyedit/example-app` (fizzy as a library, in every +shape) and `fizzyedit/example-plugin` (the plugin template) live outside this repo, and CI builds +both against each pull request's own tree (`scripts/check-examples.sh`, which you can run +locally): a change here that breaks either fails here. A change that needs them changed too +lands its PR in that repo alongside. + +## Plugin store: built, not forward-looking + +The plugin registry/install flow (author repo → release CI → `fizzyedit/plugins` registry → +in-app store) is fully built and is the canonical publishing path for every third-party +plugin, `pixi` included. It's documented end-to-end in `docs/PLUGINS.md` §6; the registry +repo itself is [`fizzyedit/plugins`](https://github.com/fizzyedit/plugins) and the reusable +release CI is [`fizzyedit/plugin-build-action`](https://github.com/fizzyedit/plugin-build-action). +Don't trust older narrative docs that call this forward-looking/not-yet-built. + +## Shipped shapes, meant to be copied (dvui's methodology) + +Fizzy follows dvui's approach to widgets, one level up: it ships a handful of **layout shapes** +(fizzy's own in `src/editor/layout.zig`; `shapes/{minimal,studio,endless}.zig` in +`fizzyedit/example-app`) rather than a configurable layout engine. An app +either picks one as-is and writes no layout code at all, or **copies** the closest one into its +own source and edits it. + +That constrains how these are written, and the constraint is the point: + +- A shape is ordinary code over the public `Layout` API — a couple of dozen lines. Nothing in it + may be privileged or reach into fizzy internals a copier could not reach, or copying becomes + forking. +- Prefer adding a *new shape* over adding an option to an existing one. A mode flag is declared + policy; a second file the user can read end to end is not. +- Anything a shape needs that only fizzy can provide is a bug in `Layout`, not a reason for a + special case. A plugin that needs something from the shape asks the framework (a region by + its keywords, `host.region`), never a widget the shape happened to create. + +## Where things live + +``` +core/ the shared floor both an app and a plugin dylib draw with — widgets (Split, + DockingWidget, Tabs, Tree, Canvas), anim, dialogs, draw, icon, image, fs, paths, + math, fuzzy, lsp +backend/ fizzy's native backend, a package of its own: SDL3, its GPU renderer and the platform + pieces (title bars, glass, OS windows); pins SDL, zig-objc and zigwin32 +sdk/ the plugin contract: `sdk/src/**` is the SDK itself, the files beside it are its + build surface (this directory ships standalone as `fizzy-sdk-v*.tar.gz`); + `sdk/tape/` is the std-only demo-tape library shipped with it, `sdk/replay/` the + dvui half that plays tapes into a window (dvui and `tape` only) +app/ the framework an application switches on — layout, store, update, watch, window, + single_instance, automation (demos); never compiled into a dylib +plugins/ the bundled plugins, in the exact shape a third-party plugin has +src/ fizzy the application — `Entry`, `editor/`, `backend/` +build/ the app build API +``` + +A plugin reaches for `core/` and `sdk/`; an app additionally for `app/`; `src/` is fizzy's own +source and nothing outside fizzy should need to look in it. + +## File naming: a file is a struct + +Zig files *are* structs, so the repo follows that literally and you should too: + +- **`CapitalName.zig` IS the struct.** Fields are declared at the top level of the file, with + `const Self = @This();`, and consumers write `const Surface = @import("Surface.zig");` — not a + file containing `pub const Surface = struct { ... }`, which nests the type one level deeper for + no reason. Existing examples: `Host.zig`, `Plugin.zig`, `DocHandle.zig`, `Surface.zig`, + `RegionSpec.zig`. +- **`lowercase.zig` is a namespace** of related declarations with no single type at its centre: + `keywords.zig`, `paths.zig`, `document.zig`, `fingerprint.zig`. + +### Case-renaming an existing file is a CI trap + +macOS and Windows filesystems are case-insensitive; Linux CI is not. Renaming a *tracked* file +from `surface.zig` to `Surface.zig` looks like a no-op to git locally, so the rename is never +staged — and then CI fails to resolve `@import("Surface.zig")` on a case-sensitive filesystem. +Use `git mv -f old.zig tmp && git mv -f tmp New.zig` (two steps) when changing only case, and +check `git status --porcelain` actually shows the rename before committing. + +The same case-insensitivity will silently destroy work: `rm sdk/src/surface.zig` deletes +`sdk/src/Surface.zig`, and *writing* `app/App.zig` overwrites `app/app.zig`. Watch for it when +converting a file to the capitalized form, and never keep two names differing only by case in +one directory — that is why the `app` module root is `app/root.zig`, not `app/app.zig`. + +## Build + +```sh +zig build # native exe +zig build check-web # wasm +zig build test # unit tests +zig build test-integration # headless integration tests (CI runs them on Linux) +zig build test-sdk-version # CI lock: the recorded fingerprint matches the live plugin boundary +``` + +Run all of these after touching the SDK boundary (`sdk/src/**`) or a plugin's vtable usage. + +### Keep the plugin build free of app-only dependencies + +Plugins depend on the **`sdk/` package** (its own `build.zig` + `build.zig.zon`), not the repo root. The root zon owns the editor build and may list app-only deps (Velopack, nightwatch, …). A root-zon `.lazy = true` URL dep is **not** enough by itself: Zig eagerly unpacks lazy URL deps that already sit in the global cache into every consumer's `zig-pkg`, so after any app build a plugin depending on the root package would grow a Velopack tree even though `lazyDependency` never runs on the plugin path. + +Pattern: + +- **Plugins** (built-in + third-party): `.fizzy = .{ .path = ".../sdk" }` locally, or the `fizzy-sdk-v*` **release asset** URL from the matching `sdk-v*` tag (not the git archive — that is the monorepo root zon with Velopack). Call `fizzy.plugin.create` / `.install` as before; `b.dependency("fizzy", .{ .plugin_sdk = true })` still works (the option is accepted and ignored — `sdk/` always exports modules). Packing: `scripts/pack-sdk.sh` / `.github/workflows/sdk-tag.yml`. +- **App**: repo-root `zig build` as usual. The app **consumes `sdk/` as a dependency** (`.fizzy_sdk = .{ .path = "sdk/" }`), so build scripts reach `plugin`/`core_module`/`sdk_version` through `@import("fizzy_sdk")` and never by relative path into `sdk/` — a file may belong to only one module, so a path import claims it for the root build module and breaks the dependency outright. The same applies in reverse: nothing under `src/` may relative-import an `sdk/` file. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. +- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error asks for a new recorded fingerprint, which cannot satisfy both. Bump or swap to a local checkout in `sdk/build.zig.zon` only. +- Shared `core` import wiring lives in `sdk/core_module.zig` and is called from the app build *and* `sdk/plugin_sdk.zig`'s `exportModules` so the import set can't drift. Note the `with_tui = false` on the zf dependency: without it, zf's standalone terminal binary drags `libvaxis` into every plugin build. + +Acceptance test after any build-graph change: + +```sh +cd plugins/image && rm -rf .zig-cache zig-out zig-pkg && zig build -Doptimize=ReleaseFast +ls zig-pkg | grep -i velo # must be empty +``` + +CI builds plugins for all 6 host targets by cross-compiling with `-Dtarget=` (see `fizzyedit/plugin-build-action`); pure-Zig + vendored-C plugins don't need per-arch runners. + +## When you need more than this file + +**Plans live in a private repo, [`fizzyedit/plans`](https://github.com/fizzyedit/plans)**, open to +the `fizzyedit` organization. A path written `plans/X.md` — here, in the code's comments, in +PRs — means `plans/X.md` in that repo. Clone it beside the others (`~/dev/fizzyedit/plans`, +`gh repo clone fizzyedit/plans`) and read the plan before working on its step; a session without +access works from what the code and its public docs say. + +- **The library/framework work (merged from bookmark `fizzy-lib` in #194)** → [`plans/LIB_CHECKPOINT.md`](https://github.com/fizzyedit/plans/blob/main/plans/LIB_CHECKPOINT.md): + what was done and the agreed next steps. Its ground rules predate `CONTRIBUTING.md`, which wins + where they differ. + +- Demos that play the real app (tapes, the player, rewind, writing a demo, the anchors widgets + publish) → [`docs/AUTOMATION.md`](docs/AUTOMATION.md); where it is going (recording, seeking, + libraries) → [`plans/AUTOMATION_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/AUTOMATION_PLAN.md). The tape, sequencer and + script are the `tape` library in `sdk/tape/` (std-only); the players, their input and anchors + the `replay` library in `sdk/replay/` (dvui and `tape` only, for any dvui app); fizzy's overlay + and plugin service in `app/automation/`; fizzy's stage and bundled demos in + `src/editor/Demo.zig` + `src/editor/demos/`. +- Floating views (a view dropped on its own place's middle floats into a glass window: the rule, + occlusion, close-home, persistence) → `app/layout/SPLITS.md` "Floating a view"; taking floats out + into OS windows (ImGui-style viewports, per-OS dressing, the dvui-dev replay hook) → + [`plans/POPOUT_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/POPOUT_WINDOWS_PLAN.md). +- Full plugin contract + lifecycle/hook tables → `docs/PLUGINS.md` +- The forks and patches fizzy builds on (dvui-dev, SDL's patches in `backend/patches/sdl`, sdl_zig): what each changes, where each is pinned, how to bump → [`docs/DEPENDENCIES.md`](docs/DEPENDENCIES.md) +- Native windows and glass: what Liquid Glass can do (measured), the drag's glass as one native + overlay on macOS 26, and fizzy's backend owning its windows while taking SDL's releases → + [`plans/NATIVE_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/NATIVE_WINDOWS_PLAN.md) +- Living reshape plan (identity `plugin.zig.zon`, comptime `settings.Schema`, ZON user config, no sidecars) → [`plans/PLUGIN_MANIFEST_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/PLUGIN_MANIFEST_PLAN.md) diff --git a/CLAUDE.md b/CLAUDE.md index 83c07a845..af1247017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,209 +1,2 @@ -# Fizzy - -Cross-platform, open-source general editor written in Zig, UI via [DVUI](https://github.com/david-vanderson/dvui). Targets native (macOS/Linux/Windows) and web (wasm32). Layout/UX is IDE-shaped (VSCode-like): sidebar rail + explorer, menubar, center tabs/splits, bottom panel, infobar. - -**Read this file first, then go deeper via the links below — don't re-derive the architecture from scratch.** -`sdk/`, `app/`, `core/`, `backend/`, `plugins/`, `web/` and `tests/` each have a short `CLAUDE.md` -of their own: the rules and pitfalls of that directory, loaded when you work there. Pitfalls with -no single home are in [`docs/PITFALLS.md`](docs/PITFALLS.md). - -**How we work** — one plan step per PR, claiming work before building it, jj, verifying, the SDK -release train — is [`CONTRIBUTING.md`](CONTRIBUTING.md), imported here so every session reads it: - +@AGENTS.md @CONTRIBUTING.md - -## The core idea: fizzy + plugins - -Fizzy the app is itself a near-empty host (window, frame loop, layout shape, document model) that owns **no editing features**. Everything the user sees — pixel-art editing, the file explorer/tabs/splits, text editing — is contributed by **plugins** that register against a stable SDK. Plugins never import each other; they meet only at the SDK. - -``` -Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register(host) + vtable) -``` - -- **`sdk/src/`** — the entire contract. `Host` (registries + service locator), `Plugin` (identity + vtable of hooks Fizzy calls), `Surface` (the one drawable contribution: keywords say where it may go, the app's regions accept it), `RegionSpec` (a plugin declaring a place of its own inside the one it was given), `DocHandle` (opaque `{ptr, id, owner}` — Fizzy routes every doc op to `owner`, never inspects `ptr`), `EditorAPI` (Fizzy's own read/util surface plugins reach back through), `keywords.zig` (how a region accepts a surface), `dylib.zig`/`dvui_context.zig` (runtime-library C-ABI + dvui injection). -- **`app/`** — the framework an application switches on, never in a dylib: `App` (the host runtime state — host, documents, settings, keymap, layout state, watchers; fizzy's `Editor` embeds one as `editor.app`), `layout` (Layout/Region/State, the picker, view drags, split trees), `settings`, `keymap`, `Recents`, `store`, `update`, `watch`, `window`, `single_instance`. The module root is `app/root.zig`. Everywhere it needed to name fizzy is a `{ctx, vtable}` seam the app fills in. -- **`src/editor/`** — Fizzy itself: `Editor.zig` (frame loop, plugin registration/loading), `layout.zig` (fizzy's shape), `Menu.zig`, `Sidebar.zig`, `Settings.zig`, etc. -- **`core/`** — the shared floor used by Fizzy *and* plugins: `widgets` (Split, DockingWidget, Tabs, Tree, Canvas, BlurBackdrop), `anim`, `dialogs`, `draw`, `icon`, `image`, math, fs, paths, platform detection. Not plugin-owned; don't move it. (`Atlas`/`Sprite` are there only because pixi still loads its packed UI atlas through them — see the note in `core/core.zig`.) `core.fuzzy` is the one matcher behind every filter box in the app (settings tree, file tree, plugin store, LSP completions) — wrap zf through it rather than matching by hand, and remember **lower scores are better**. Draw icons with `core.icon.icon`, not `dvui.icon` — same arguments, cached as a texture. -- **`plugins/`** — bundled built-in plugins. Each is file-for-file the **same shape a third-party plugin would use**: root `plugin.zig` + identity-only `plugin.zig.zon` + `build.zig` + `build.zig.zon` (optional `src/**`), plus fizzy-internal glue in `static/`. No author `root.zig` or `.zig` hub — the build helper generates the dylib entry; files use named imports (`fizzy_sdk`/`dvui`/…). Builds standalone with `cd plugins/ && zig build`. - -**Two link modes, one source:** built-in plugins compile **static** (linked directly, all targets incl. web) or **dynamic** (`.dylib`/`.so`/`.dll`, desktop-only, `dlopen`'d — this is how third-party plugins ship too). `FIZZY_STATIC_=1` env var forces static for a given built-in (useful when debugging dylib loading). - -## Currently bundled plugins (check `ls plugins/` — this list moves) - -- **`workbench`** — file tree and the document panes (each pane is a region the plugin declares; each open document is a surface the app registers); owns no documents. Exposes a `files` service other plugins use to open/close/manage files without importing workbench. -- **`text`** — generic text/code editor; fallback owner for any file extension nothing else claims. -- **`image`** — read-only PNG/JPG/GIF/BMP/TGA viewer with zoom/pan (fallback when pixi is not installed). -- **`markdown`** — `.md` preview utility plugin. -- **`archive`** — opens a `.zip` as a mounted folder (the web's "open a vault" path, too). -- **`devtools`** — the dashboard: where the app's time goes, from `core.profile`, for a person and an agent alike. -- `shared` — build helpers used across plugins' `static/integration.zig` (not a plugin itself). - -**Pixi (pixel-art editor) and Drive (Google Drive as a mount) live outside this repo** as third-party-style plugins ([`fizzyedit/pixi`](https://github.com/fizzyedit/pixi), [`fizzyedit/drive`](https://github.com/fizzyedit/drive)) — they ship and update purely through the plugin store (`docs/PLUGINS.md` §6), with no special treatment in Fizzy itself. **Trust `ls plugins/` and `jj log` over any doc's plugin list.** - -## Writing a plugin - -1. Copy [`fizzyedit/example-plugin`](https://github.com/fizzyedit/example-plugin) (one surface, - nothing else) as your template; `plugins/text/` or `plugins/image/` when the plugin owns - documents. -2. Add identity-only `plugin.zig.zon` (`id`/`name`/`version`/`min_sdk_version`). Implement root `plugin.zig`: `Plugin` + `register(host)` + vtable; call `host.register{Surface,Menu,Command,Service,…}` as needed. A surface's keywords (`sdk.keywords`) say where it may go; the app's regions accept it, and the user can move it with the picker. -3. Plugin prefs: `sdk.settings.Schema(struct { … })` then `.register(host, &plugin, …)` — Fizzy draws them only while the plugin is loaded. User config on disk is ZON (`settings.zon` / `recents.zon`). -4. Editor plugins implement the document vtable cluster; a plugin that lays out documents itself declares its panes with `host.region(spec)` and draws the accepted surfaces where it wants them. -5. User-invoked actions are **`Command`s** — `"."`. -6. `zig build install` drops `{id}/{id}.dylib` (its own directory) into the fizzy plugins dir (no sidecar `.zon`). -7. Memory: `host.allocator` vs `host.arena()`; never touch `dvui.currentWindow().gpa` directly. -8. ABI: structural fingerprint at `dlopen` (`fizzy_plugin_abi_fingerprint`). A change that moves the boundary's shape records the new `recorded_sdk_shape_fingerprint` and leaves `sdk_version` alone; only an SDK release PR bumps the version (`CONTRIBUTING.md`, "Changing the SDK"). - -Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: **[`plans/PLUGIN_MANIFEST_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/PLUGIN_MANIFEST_PLAN.md)**. - -## Bundling plugins into an app - -Which plugins an executable links in is build data: `build/sdk.zig`'s `bundledPluginsModule` -generates `bundled_plugins` (`pub const modules = .{ @import("workbench"), … }`) and the -runtime iterates it — nothing in `app/` or `src/` names a plugin except the workbench (its -state still lives on the Editor). An app built on fizzy adds its own with `defer-app` + -`fizzy.buildApp(fizzy_dep, &.{ .{ .name, .module = dep.module("plugin") } })`; see -[`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles -`fizzyedit/example-plugin`. - -**The example repos move with fizzy.** `fizzyedit/example-app` (fizzy as a library, in every -shape) and `fizzyedit/example-plugin` (the plugin template) live outside this repo, and CI builds -both against each pull request's own tree (`scripts/check-examples.sh`, which you can run -locally): a change here that breaks either fails here. A change that needs them changed too -lands its PR in that repo alongside. - -## Plugin store: built, not forward-looking - -The plugin registry/install flow (author repo → release CI → `fizzyedit/plugins` registry → -in-app store) is fully built and is the canonical publishing path for every third-party -plugin, `pixi` included. It's documented end-to-end in `docs/PLUGINS.md` §6; the registry -repo itself is [`fizzyedit/plugins`](https://github.com/fizzyedit/plugins) and the reusable -release CI is [`fizzyedit/plugin-build-action`](https://github.com/fizzyedit/plugin-build-action). -Don't trust older narrative docs that call this forward-looking/not-yet-built. - -## Shipped shapes, meant to be copied (dvui's methodology) - -Fizzy follows dvui's approach to widgets, one level up: it ships a handful of **layout shapes** -(fizzy's own in `src/editor/layout.zig`; `shapes/{minimal,studio,endless}.zig` in -`fizzyedit/example-app`) rather than a configurable layout engine. An app -either picks one as-is and writes no layout code at all, or **copies** the closest one into its -own source and edits it. - -That constrains how these are written, and the constraint is the point: - -- A shape is ordinary code over the public `Layout` API — a couple of dozen lines. Nothing in it - may be privileged or reach into fizzy internals a copier could not reach, or copying becomes - forking. -- Prefer adding a *new shape* over adding an option to an existing one. A mode flag is declared - policy; a second file the user can read end to end is not. -- Anything a shape needs that only fizzy can provide is a bug in `Layout`, not a reason for a - special case. A plugin that needs something from the shape asks the framework (a region by - its keywords, `host.region`), never a widget the shape happened to create. - -## Where things live - -``` -core/ the shared floor both an app and a plugin dylib draw with — widgets (Split, - DockingWidget, Tabs, Tree, Canvas), anim, dialogs, draw, icon, image, fs, paths, - math, fuzzy, lsp -backend/ fizzy's native backend, a package of its own: SDL3, its GPU renderer and the platform - pieces (title bars, glass, OS windows); pins SDL, zig-objc and zigwin32 -sdk/ the plugin contract: `sdk/src/**` is the SDK itself, the files beside it are its - build surface (this directory ships standalone as `fizzy-sdk-v*.tar.gz`); - `sdk/tape/` is the std-only demo-tape library shipped with it, `sdk/replay/` the - dvui half that plays tapes into a window (dvui and `tape` only) -app/ the framework an application switches on — layout, store, update, watch, window, - single_instance, automation (demos); never compiled into a dylib -plugins/ the bundled plugins, in the exact shape a third-party plugin has -src/ fizzy the application — `Entry`, `editor/`, `backend/` -build/ the app build API -``` - -A plugin reaches for `core/` and `sdk/`; an app additionally for `app/`; `src/` is fizzy's own -source and nothing outside fizzy should need to look in it. - -## File naming: a file is a struct - -Zig files *are* structs, so the repo follows that literally and you should too: - -- **`CapitalName.zig` IS the struct.** Fields are declared at the top level of the file, with - `const Self = @This();`, and consumers write `const Surface = @import("Surface.zig");` — not a - file containing `pub const Surface = struct { ... }`, which nests the type one level deeper for - no reason. Existing examples: `Host.zig`, `Plugin.zig`, `DocHandle.zig`, `Surface.zig`, - `RegionSpec.zig`. -- **`lowercase.zig` is a namespace** of related declarations with no single type at its centre: - `keywords.zig`, `paths.zig`, `document.zig`, `fingerprint.zig`. - -### Case-renaming an existing file is a CI trap - -macOS and Windows filesystems are case-insensitive; Linux CI is not. Renaming a *tracked* file -from `surface.zig` to `Surface.zig` looks like a no-op to git locally, so the rename is never -staged — and then CI fails to resolve `@import("Surface.zig")` on a case-sensitive filesystem. -Use `git mv -f old.zig tmp && git mv -f tmp New.zig` (two steps) when changing only case, and -check `git status --porcelain` actually shows the rename before committing. - -The same case-insensitivity will silently destroy work: `rm sdk/src/surface.zig` deletes -`sdk/src/Surface.zig`, and *writing* `app/App.zig` overwrites `app/app.zig`. Watch for it when -converting a file to the capitalized form, and never keep two names differing only by case in -one directory — that is why the `app` module root is `app/root.zig`, not `app/app.zig`. - -## Build - -```sh -zig build # native exe -zig build check-web # wasm -zig build test # unit tests -zig build test-integration # headless integration tests (CI runs them on Linux) -zig build test-sdk-version # CI lock: the recorded fingerprint matches the live plugin boundary -``` - -Run all of these after touching the SDK boundary (`sdk/src/**`) or a plugin's vtable usage. - -### Keep the plugin build free of app-only dependencies - -Plugins depend on the **`sdk/` package** (its own `build.zig` + `build.zig.zon`), not the repo root. The root zon owns the editor build and may list app-only deps (Velopack, nightwatch, …). A root-zon `.lazy = true` URL dep is **not** enough by itself: Zig eagerly unpacks lazy URL deps that already sit in the global cache into every consumer's `zig-pkg`, so after any app build a plugin depending on the root package would grow a Velopack tree even though `lazyDependency` never runs on the plugin path. - -Pattern: - -- **Plugins** (built-in + third-party): `.fizzy = .{ .path = ".../sdk" }` locally, or the `fizzy-sdk-v*` **release asset** URL from the matching `sdk-v*` tag (not the git archive — that is the monorepo root zon with Velopack). Call `fizzy.plugin.create` / `.install` as before; `b.dependency("fizzy", .{ .plugin_sdk = true })` still works (the option is accepted and ignored — `sdk/` always exports modules). Packing: `scripts/pack-sdk.sh` / `.github/workflows/sdk-tag.yml`. -- **App**: repo-root `zig build` as usual. The app **consumes `sdk/` as a dependency** (`.fizzy_sdk = .{ .path = "sdk/" }`), so build scripts reach `plugin`/`core_module`/`sdk_version` through `@import("fizzy_sdk")` and never by relative path into `sdk/` — a file may belong to only one module, so a path import claims it for the root build module and breaks the dependency outright. The same applies in reverse: nothing under `src/` may relative-import an `sdk/` file. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. -- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error asks for a new recorded fingerprint, which cannot satisfy both. Bump or swap to a local checkout in `sdk/build.zig.zon` only. -- Shared `core` import wiring lives in `sdk/core_module.zig` and is called from the app build *and* `sdk/plugin_sdk.zig`'s `exportModules` so the import set can't drift. Note the `with_tui = false` on the zf dependency: without it, zf's standalone terminal binary drags `libvaxis` into every plugin build. - -Acceptance test after any build-graph change: - -```sh -cd plugins/image && rm -rf .zig-cache zig-out zig-pkg && zig build -Doptimize=ReleaseFast -ls zig-pkg | grep -i velo # must be empty -``` - -CI builds plugins for all 6 host targets by cross-compiling with `-Dtarget=` (see `fizzyedit/plugin-build-action`); pure-Zig + vendored-C plugins don't need per-arch runners. - -## When you need more than this file - -**Plans live in a private repo, [`fizzyedit/plans`](https://github.com/fizzyedit/plans)**, open to -the `fizzyedit` organization. A path written `plans/X.md` — here, in the code's comments, in -PRs — means `plans/X.md` in that repo. Clone it beside the others (`~/dev/fizzyedit/plans`, -`gh repo clone fizzyedit/plans`) and read the plan before working on its step; a session without -access works from what the code and its public docs say. - -- **The library/framework work (merged from bookmark `fizzy-lib` in #194)** → [`plans/LIB_CHECKPOINT.md`](https://github.com/fizzyedit/plans/blob/main/plans/LIB_CHECKPOINT.md): - what was done and the agreed next steps. Its ground rules predate `CONTRIBUTING.md`, which wins - where they differ. - -- Demos that play the real app (tapes, the player, rewind, writing a demo, the anchors widgets - publish) → [`docs/AUTOMATION.md`](docs/AUTOMATION.md); where it is going (recording, seeking, - libraries) → [`plans/AUTOMATION_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/AUTOMATION_PLAN.md). The tape, sequencer and - script are the `tape` library in `sdk/tape/` (std-only); the players, their input and anchors - the `replay` library in `sdk/replay/` (dvui and `tape` only, for any dvui app); fizzy's overlay - and plugin service in `app/automation/`; fizzy's stage and bundled demos in - `src/editor/Demo.zig` + `src/editor/demos/`. -- Floating views (a view dropped on its own place's middle floats into a glass window: the rule, - occlusion, close-home, persistence) → `app/layout/SPLITS.md` "Floating a view"; taking floats out - into OS windows (ImGui-style viewports, per-OS dressing, the dvui-dev replay hook) → - [`plans/POPOUT_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/POPOUT_WINDOWS_PLAN.md). -- Full plugin contract + lifecycle/hook tables → `docs/PLUGINS.md` -- The forks and patches fizzy builds on (dvui-dev, SDL's patches in `backend/patches/sdl`, sdl_zig): what each changes, where each is pinned, how to bump → [`docs/DEPENDENCIES.md`](docs/DEPENDENCIES.md) -- Native windows and glass: what Liquid Glass can do (measured), the drag's glass as one native - overlay on macOS 26, and fizzy's backend owning its windows while taking SDL's releases → - [`plans/NATIVE_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/NATIVE_WINDOWS_PLAN.md) -- Living reshape plan (identity `plugin.zig.zon`, comptime `settings.Schema`, ZON user config, no sidecars) → [`plans/PLUGIN_MANIFEST_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/PLUGIN_MANIFEST_PLAN.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c508cd932..1e6993ed8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Working on fizzy -`CLAUDE.md` says what fizzy is. This file says how a change gets from an idea to `main`, for +`AGENTS.md` says what fizzy is. This file says how a change gets from an idea to `main`, for people and agents alike, on a laptop or in a cloud session. Several agents work on this repo at once, and these rules are what keep their work from colliding. @@ -136,7 +136,7 @@ tarball is pinned by hash, so it is never replaced. | What | Where | |---|---| -| What fizzy is, and its architecture rules | `CLAUDE.md` | +| What fizzy is, and its architecture rules | `AGENTS.md` | | How we work | this file | | The plugin contract | `docs/PLUGINS.md` | | Plans | private, in [`fizzyedit/plans`](https://github.com/fizzyedit/plans): issues, and a long design as `plans/_PLAN.md` opening with a status line (`Status: proposed`, `in progress, fizzyedit/fizzy#N`, or `done`) | @@ -160,7 +160,7 @@ tarball is pinned by hash, so it is never replaced. plugin (a per-frame poll, a slice borrowed from plugin memory). Moving the fingerprint for the clearer shape is fine. - **Pitfalls go where they bite.** A trap that cost real time is written down in the - `CLAUDE.md` of the directory it lives in, or `docs/PITFALLS.md` if it has none. + `AGENTS.md` of the directory it lives in, or `docs/PITFALLS.md` if it has none. ## The gate on `main` diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 000000000..535c3dd90 --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,35 @@ +# app/: the framework an application switches on + +Compiled into an application, never into a plugin dylib, so it may use what a plugin must not +link: the network, the filesystem, an installer, `dlopen`. `root.zig`'s doc comment lists what +each directory is. + +- **The module root is `root.zig`, not `app.zig`.** On a case-insensitive filesystem `app.zig` and + `App.zig` are one file (root `AGENTS.md`, "Case-renaming"). +- **Nothing here names fizzy.** Where the framework needs the application it asks through a + `{ctx, vtable}` seam the app fills in. Never import `src/`; an app built on fizzy has no `src/`. +- **Layout** (`layout/`): read `layout/SPLITS.md` before changing splits, moving or floating + views. It is the contract, and it lists the rules a change must keep. `layout/LAYERS.md` is an + agreed design that isn't built yet, with the rejected alternatives. Shapes are copied, not + configured (root `AGENTS.md`, "Shipped shapes"): a shape needing something only fizzy has is + a bug in `Layout`. +- **Tests:** the std-only rule files (`layout/float_rules.zig`, `watch/folder_events.zig`, + `RecentCommands.zig`, …) each have a root in `build/app.zig`'s unit list. Pure decisions go in + such a file, so they test without a Window. Everything else is reached from `root.zig`'s + `test` block (`fizzy-app-tests`, under `zig build test-integration`). + +## Pitfalls + +- **macOS charges ~110–140 ms for the first `dlopen` of a newly written file,** whatever its size, + and caches the result per file. The loader therefore maps a stable per-build copy + (`.load-copy/{mtime}-{size}-{name}`) and copies again only on a genuine re-open. It never maps + the installed file: Windows couldn't rename an update into place, and on POSIX a rebuild would + rewrite bytes under a live mapping (SIGBUS). Startup that looks like N × 120 ms is this. +- **Motion handing over to an OS window must be at rest on the window's rect when it shows.** An + OS window can't animate its size per frame cheaply, so an overshoot still swinging then gets + cut. Use `core.motion.arrive(t, swing)`, and size any overshoot in points, not as a share of the + window. +- **The dragged view's glass moves only as the hand does** (`ViewDrag`'s `holdHand`, 3 pt). No + springs, easing or trails driven by the pointer: merged Liquid Glass necks re-form visibly + under tremor. A one-shot, time-driven curve started by a discrete event (the aimed bubble + changing) is fine. diff --git a/app/CLAUDE.md b/app/CLAUDE.md index d79a20b20..43c994c2d 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -1,35 +1 @@ -# app/: the framework an application switches on - -Compiled into an application, never into a plugin dylib, so it may use what a plugin must not -link: the network, the filesystem, an installer, `dlopen`. `root.zig`'s doc comment lists what -each directory is. - -- **The module root is `root.zig`, not `app.zig`.** On a case-insensitive filesystem `app.zig` and - `App.zig` are one file (root `CLAUDE.md`, "Case-renaming"). -- **Nothing here names fizzy.** Where the framework needs the application it asks through a - `{ctx, vtable}` seam the app fills in. Never import `src/`; an app built on fizzy has no `src/`. -- **Layout** (`layout/`): read `layout/SPLITS.md` before changing splits, moving or floating - views. It is the contract, and it lists the rules a change must keep. `layout/LAYERS.md` is an - agreed design that isn't built yet, with the rejected alternatives. Shapes are copied, not - configured (root `CLAUDE.md`, "Shipped shapes"): a shape needing something only fizzy has is - a bug in `Layout`. -- **Tests:** the std-only rule files (`layout/float_rules.zig`, `watch/folder_events.zig`, - `RecentCommands.zig`, …) each have a root in `build/app.zig`'s unit list. Pure decisions go in - such a file, so they test without a Window. Everything else is reached from `root.zig`'s - `test` block (`fizzy-app-tests`, under `zig build test-integration`). - -## Pitfalls - -- **macOS charges ~110–140 ms for the first `dlopen` of a newly written file,** whatever its size, - and caches the result per file. The loader therefore maps a stable per-build copy - (`.load-copy/{mtime}-{size}-{name}`) and copies again only on a genuine re-open. It never maps - the installed file: Windows couldn't rename an update into place, and on POSIX a rebuild would - rewrite bytes under a live mapping (SIGBUS). Startup that looks like N × 120 ms is this. -- **Motion handing over to an OS window must be at rest on the window's rect when it shows.** An - OS window can't animate its size per frame cheaply, so an overshoot still swinging then gets - cut. Use `core.motion.arrive(t, swing)`, and size any overshoot in points, not as a share of the - window. -- **The dragged view's glass moves only as the hand does** (`ViewDrag`'s `holdHand`, 3 pt). No - springs, easing or trails driven by the pointer: merged Liquid Glass necks re-form visibly - under tremor. A one-shot, time-driven curve started by a discrete event (the aimed bubble - changing) is fine. +@AGENTS.md diff --git a/app/layout/Chooser.zig b/app/layout/Chooser.zig index b56d632bc..6240f55bc 100644 --- a/app/layout/Chooser.zig +++ b/app/layout/Chooser.zig @@ -37,7 +37,7 @@ //! picking the already-selected item means (`reselected`). //! //! Nothing here reaches past the public `Layout` API: a shape that wants a chooser this file -//! cannot draw copies it, per the shipped-shapes methodology in CLAUDE.md. +//! cannot draw copies it, per the shipped-shapes methodology in AGENTS.md. const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); diff --git a/app/layout/LAYERS.md b/app/layout/LAYERS.md index 8c4cd5c7d..16056aa8a 100644 --- a/app/layout/LAYERS.md +++ b/app/layout/LAYERS.md @@ -85,7 +85,7 @@ put a switch in the public API that the framework does not yet honour, which is adding it a version later. Sequence stands as written. **The dvui bump is the risk item, and it should land on its own.** `sdk/build.zig.zon` is the -repo's only dvui pin and is deliberately absent from the root zon (CLAUDE.md explains why adding +repo's only dvui pin and is deliberately absent from the root zon (AGENTS.md explains why adding a second pin cannot work). Fizzy has also diverged `PanedWidget`, and the layered work diverges it further — so bumping dvui *and* changing the widget in one change makes a regression unbisectable. Land the bump against a green tree first. diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index a2eaf13b4..db18ba16a 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -114,7 +114,7 @@ //! //! Note the core pieces come through the named `core` module, never by relative path: a file may //! belong to only one module, and `@import("../../core/...")` here would claim it for the root -//! build module and break `core` as a dependency outright (CLAUDE.md). +//! build module and break `core` as a dependency outright (AGENTS.md). const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 6474f3450..a8febf4cc 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -219,7 +219,7 @@ pub fn isPeeking(self: Region) bool { /// /// This replaced a five-value `Chooser` enum, two of whose values (`explorer_chrome`, /// `panel_chrome`) named *fizzy's own* furniture from inside the generic layer. That is the -/// case CLAUDE.md calls a bug in `Layout` rather than a special case: a shape is supposed to be +/// case AGENTS.md calls a bug in `Layout` rather than a special case: a shape is supposed to be /// ordinary code over this API, and an app copying `src/editor/layout.zig` could not have written those two /// values itself. As a function pointer it is just `explorerPane` in `src/editor/layout.zig` — /// app code, passed in, replaceable by the app's own loop over `matching` / `selected` / `draw`, diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 000000000..988a5ab8a --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,52 @@ +# backend/: fizzy's native backend + +SDL3, the SDL_GPU renderer (`GpuRenderer.zig`) and the platform pieces (`platform/`: title bars, +the OS's glass, OS windows for floats, menus, dialogs). A package of its own (`build.zig` says +how an app wires it). + +- **dvui is the caller's.** It is never pinned here; the backend compiles against the app's dvui. +- **SDL is patched here,** not in a fork: `patches/sdl/*.patch`, applied by zig-patch. How to + change, add or bump them is `docs/DEPENDENCIES.md` ("SDL", "Bumping"). Touching a patch or this + `build.zig` runs CI's `sdl` job, the suites for the patches (`zig build test-sdl` here). +- **std-only tests are listed in `build.zig`'s `unit_tests`,** and fizzy's `zig build test` runs + them. Window rules that can be stated without a window go in such a file + (`platform/window_layout.zig`, `viewport_map.zig`). +- **Verify on the platform you changed.** CI's Windows cross-compile proves fizzy links, not that + it works. `docs/MACOS_LIVE_RESIZE.md` and `docs/WINDOWS_LIVE_RESIZE.md` show how a resize + was measured. + +## macOS pitfalls + +The `.m` files in `src/platform/macos/` are built **without ARC**, under a frame loop that is Zig. + +- **Autorelease pools:** each SDL callback (`appInit`, `appIterate`, `appEvent`, `appQuit` in + `SDLBackend.zig`) runs in a pool, so Objective-C called from the frame needs none of its own. + A thread fizzy starts, or a callback AppKit makes on its own schedule, does. Release + `alloc`/`copy` results by hand. A closed window that lingers: run with + `OBJC_DEBUG_MISSING_POOLS=YES` before suspecting the view hierarchy. +- **Secondary windows** (floats, menus, dialogs): + - AppKit's show and close animations never finish under this loop and leave ghost windows. Set + `NSWindowAnimationBehaviorNone`. + - SDL shows a non-activating window *below* the key window, so order it explicitly. + - `orderedIndex` is stale right after `setLevel:`: order after a level change, don't ask first. + - A child window (`addChildWindow:`) drops to its parent's level. Set the level after attaching, + and attach only once it is shown. + - Borderless windows join the Window menu only through `changeWindowsItem:`. +- **A press reaches SDL3View but not SDL:** SDL3View inherits NSView's `-mouseDown:`, which keeps + the press in a full-screen window. `visual_effect_view.m` adds a forwarding one. Add the + forward; don't rebuild the responder chain. +- **Full screen** (`window_monitor.m`): + - `toggleFullScreen:` called inside an open frame nests `appIterate` in it (dvui logs "window is + already the current_window"), so defer it. + - AppKit pictures the after-state synchronously *inside* `toggleFullScreen:`. + - Whoever moves a window's frame sets its drawable size, because SDL hears of the new size late. + - Take frame jumps only inside the app's frame transaction. + - Read the pixel density from `backingScaleFactor`. + - A sandbox fizzy launched from a shell isn't the active app, and AppKit stalls or refuses its + transitions. +- **Per-frame presents are what cost.** A display-sized overlay, or an extra window presented + every frame, halved drag fps (the main thread waited in `nextDrawable`), while the blur made no + difference. Size overlays to their content. Measure idle against drag fps, and `sample` for + `nextDrawable`, before blaming shaders. +- **Glass:** what each `NSGlassEffectView` variant and material actually does, measured, is in + `docs/MACOS_GLASS.md`. Don't add SwiftUI or a `swiftc` step for transitions AppKit lacks. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 988a5ab8a..43c994c2d 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -1,52 +1 @@ -# backend/: fizzy's native backend - -SDL3, the SDL_GPU renderer (`GpuRenderer.zig`) and the platform pieces (`platform/`: title bars, -the OS's glass, OS windows for floats, menus, dialogs). A package of its own (`build.zig` says -how an app wires it). - -- **dvui is the caller's.** It is never pinned here; the backend compiles against the app's dvui. -- **SDL is patched here,** not in a fork: `patches/sdl/*.patch`, applied by zig-patch. How to - change, add or bump them is `docs/DEPENDENCIES.md` ("SDL", "Bumping"). Touching a patch or this - `build.zig` runs CI's `sdl` job, the suites for the patches (`zig build test-sdl` here). -- **std-only tests are listed in `build.zig`'s `unit_tests`,** and fizzy's `zig build test` runs - them. Window rules that can be stated without a window go in such a file - (`platform/window_layout.zig`, `viewport_map.zig`). -- **Verify on the platform you changed.** CI's Windows cross-compile proves fizzy links, not that - it works. `docs/MACOS_LIVE_RESIZE.md` and `docs/WINDOWS_LIVE_RESIZE.md` show how a resize - was measured. - -## macOS pitfalls - -The `.m` files in `src/platform/macos/` are built **without ARC**, under a frame loop that is Zig. - -- **Autorelease pools:** each SDL callback (`appInit`, `appIterate`, `appEvent`, `appQuit` in - `SDLBackend.zig`) runs in a pool, so Objective-C called from the frame needs none of its own. - A thread fizzy starts, or a callback AppKit makes on its own schedule, does. Release - `alloc`/`copy` results by hand. A closed window that lingers: run with - `OBJC_DEBUG_MISSING_POOLS=YES` before suspecting the view hierarchy. -- **Secondary windows** (floats, menus, dialogs): - - AppKit's show and close animations never finish under this loop and leave ghost windows. Set - `NSWindowAnimationBehaviorNone`. - - SDL shows a non-activating window *below* the key window, so order it explicitly. - - `orderedIndex` is stale right after `setLevel:`: order after a level change, don't ask first. - - A child window (`addChildWindow:`) drops to its parent's level. Set the level after attaching, - and attach only once it is shown. - - Borderless windows join the Window menu only through `changeWindowsItem:`. -- **A press reaches SDL3View but not SDL:** SDL3View inherits NSView's `-mouseDown:`, which keeps - the press in a full-screen window. `visual_effect_view.m` adds a forwarding one. Add the - forward; don't rebuild the responder chain. -- **Full screen** (`window_monitor.m`): - - `toggleFullScreen:` called inside an open frame nests `appIterate` in it (dvui logs "window is - already the current_window"), so defer it. - - AppKit pictures the after-state synchronously *inside* `toggleFullScreen:`. - - Whoever moves a window's frame sets its drawable size, because SDL hears of the new size late. - - Take frame jumps only inside the app's frame transaction. - - Read the pixel density from `backingScaleFactor`. - - A sandbox fizzy launched from a shell isn't the active app, and AppKit stalls or refuses its - transitions. -- **Per-frame presents are what cost.** A display-sized overlay, or an extra window presented - every frame, halved drag fps (the main thread waited in `nextDrawable`), while the blur made no - difference. Size overlays to their content. Measure idle against drag fps, and `sample` for - `nextDrawable`, before blaming shaders. -- **Glass:** what each `NSGlassEffectView` variant and material actually does, measured, is in - `docs/MACOS_GLASS.md`. Don't add SwiftUI or a `swiftc` step for transitions AppKit lacks. +@AGENTS.md diff --git a/build.zig b/build.zig index 1f072f17d..d030c88be 100644 --- a/build.zig +++ b/build.zig @@ -1,7 +1,7 @@ const std = @import("std"); /// App-side re-export of the plugin build API (lives in `sdk/`). Plugins should depend on -/// the `sdk/` package directly — see CLAUDE.md — not this root package. +/// the `sdk/` package directly — see AGENTS.md — not this root package. /// /// Reached through the dependency rather than by path (`sdk/plugin_sdk.zig`): the app consumes /// `sdk/` as a package so the two can share one dvui pin, and a file may belong to only one module, diff --git a/build.zig.zon b/build.zig.zon index 0e409ad5a..06b0c79a4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -38,7 +38,7 @@ .hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x", }, // App-only. Plugins depend on `sdk/` (not this root package) so they never see - // Velopack — see CLAUDE.md. + // Velopack — see AGENTS.md. .velopack_zig = .{ .url = "https://github.com/graphl-tech/velopack-zig/archive/0c2f20635a97fde38cc8000e9fb1a75f891cf37d.tar.gz", .hash = "velopack_zig-0.0.1-LzeGcengAACU1f33uDAuLKlWXtek0KCC6i_b3XWeJMjd", diff --git a/build/app.zig b/build/app.zig index c2dce46f2..037a0eb87 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1176,7 +1176,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // benchmark. bench_module.addAnonymousImport("sample_huge", .{ .root_source_file = b.path("docs/PLUGINS.md") }); bench_module.addAnonymousImport("sample_prose", .{ .root_source_file = b.path("tests/data/markdown_sample_tables.md") }); - bench_module.addAnonymousImport("sample_medium", .{ .root_source_file = b.path("CLAUDE.md") }); + bench_module.addAnonymousImport("sample_medium", .{ .root_source_file = b.path("AGENTS.md") }); bench_module.addAnonymousImport("sample_small", .{ .root_source_file = b.path("docs/MODULARIZATION_RELEASE_NOTES.md") }); const bench_markdown = b.addTest(.{ .name = "fizzy-bench-markdown", .root_module = bench_module }); diff --git a/core/AGENTS.md b/core/AGENTS.md new file mode 100644 index 000000000..e9d0574f1 --- /dev/null +++ b/core/AGENTS.md @@ -0,0 +1,45 @@ +# core/: the shared floor + +What fizzy *and* every plugin dylib draw with: widgets, animation, dialogs, drawing, icons, math, +paths, fuzzy matching, the LSP client, the profiler. + +- **App-agnostic.** Nothing here imports `app/`, `src/` or `AppInfo`; what differs per app comes + in as a parameter (`paths.configFolder` takes the directory name). +- **Its imports are wired in one place,** `sdk/core_module.zig`, called by both the app build and + the plugin SDK so the two can't drift. A new dependency of `core` goes there. +- **A change here reaches store plugins only at the next SDK release,** even when the fingerprint + doesn't move, because they build `core` from the released SDK tarball. +- **Use the shared pieces:** `core.fuzzy` for any filter box (lower scores are better); + `core.icon.icon` rather than `dvui.icon` (cached as a texture). +- **Tests:** std-only files (`math/*`, `reveal.zig`, `crossfade.zig`, `FrameClock.zig`, `fuzzy.zig`, + …) have roots in `build/app.zig`'s unit list. Put pure logic in such a file. Tests that need a + real `dvui.Window` are reached from `sizing_tests.zig`, a root one directory up so the whole of + `core/` is inside its module. + +## Pitfalls + +- **On macOS, spawn through `core.darwin_spawn`, never `std.process.run`.** Zig 0.16's start-up + code captures `environ`'s length once. One `unsetenv` anywhere in the process (SDL, a plugin, a + system framework) shrinks the array in place, and the next std spawn reads past its end and + crashes. `darwin_spawn` builds the child's environment from `getenv` and needs an absolute + `argv[0]`. Linux and Windows call sites still use std and carry the same latent hazard. +- **dvui stamps every mouse event with the modifiers of the last *key* event.** A keyup lost to + another app (⌘-Tab, an OAuth popup) then turns every click into a modified click: a tree + selects instead of expanding, until you type. Fizzy's backends set `win.modifiers` from the OS + before each mouse event (`backend/src/SDLBackend.zig`, `src/backend/WebBackend.zig`); a new + backend must too. +- **Very long lists:** dvui clamps every min size at `max_float_safe` (2e6, about 93k rows at + 21.5 pt), so the scroll range silently ends early. A widget not drawn on a frame loses its + min size, so a row scrolled back into view is zero-height for a frame. A virtualized list must + overscan, size its spacers from `count × pitch` rather than from what was drawn, and measure + pitch as the *largest* gap between drawn rows. +- **dvui draws dialogs and toasts in `Window.endRendering`,** after the app's frame and after + floats have taken their subwindows. Anything that must see a dialog's subwindow that frame + draws it earlier itself. dvui also stops its widget capture at `drawRetained`, so snapshots + capture dialogs through a capture scope (`dialogs.zig`). +- **A blur costs its passes, not its taps.** Each pass is a render-target switch; on SDL's Metal + renderer that's a new command encoder and buffer. Cut passes, not samples. +- **Judge performance by main-thread CPU, not fps.** At 120 Hz a frame over 8.33 ms takes + 16.7 ms, so "half the fps" means the frame crossed the line. Log `CLOCK_THREAD_CPUTIME_ID` per + frame, or `sample` the process and read the `nextDrawable` wait as headroom. + `SDL_RENDER_VSYNC=0` stops holding once the window is frontmost. diff --git a/core/CLAUDE.md b/core/CLAUDE.md index e9d0574f1..43c994c2d 100644 --- a/core/CLAUDE.md +++ b/core/CLAUDE.md @@ -1,45 +1 @@ -# core/: the shared floor - -What fizzy *and* every plugin dylib draw with: widgets, animation, dialogs, drawing, icons, math, -paths, fuzzy matching, the LSP client, the profiler. - -- **App-agnostic.** Nothing here imports `app/`, `src/` or `AppInfo`; what differs per app comes - in as a parameter (`paths.configFolder` takes the directory name). -- **Its imports are wired in one place,** `sdk/core_module.zig`, called by both the app build and - the plugin SDK so the two can't drift. A new dependency of `core` goes there. -- **A change here reaches store plugins only at the next SDK release,** even when the fingerprint - doesn't move, because they build `core` from the released SDK tarball. -- **Use the shared pieces:** `core.fuzzy` for any filter box (lower scores are better); - `core.icon.icon` rather than `dvui.icon` (cached as a texture). -- **Tests:** std-only files (`math/*`, `reveal.zig`, `crossfade.zig`, `FrameClock.zig`, `fuzzy.zig`, - …) have roots in `build/app.zig`'s unit list. Put pure logic in such a file. Tests that need a - real `dvui.Window` are reached from `sizing_tests.zig`, a root one directory up so the whole of - `core/` is inside its module. - -## Pitfalls - -- **On macOS, spawn through `core.darwin_spawn`, never `std.process.run`.** Zig 0.16's start-up - code captures `environ`'s length once. One `unsetenv` anywhere in the process (SDL, a plugin, a - system framework) shrinks the array in place, and the next std spawn reads past its end and - crashes. `darwin_spawn` builds the child's environment from `getenv` and needs an absolute - `argv[0]`. Linux and Windows call sites still use std and carry the same latent hazard. -- **dvui stamps every mouse event with the modifiers of the last *key* event.** A keyup lost to - another app (⌘-Tab, an OAuth popup) then turns every click into a modified click: a tree - selects instead of expanding, until you type. Fizzy's backends set `win.modifiers` from the OS - before each mouse event (`backend/src/SDLBackend.zig`, `src/backend/WebBackend.zig`); a new - backend must too. -- **Very long lists:** dvui clamps every min size at `max_float_safe` (2e6, about 93k rows at - 21.5 pt), so the scroll range silently ends early. A widget not drawn on a frame loses its - min size, so a row scrolled back into view is zero-height for a frame. A virtualized list must - overscan, size its spacers from `count × pitch` rather than from what was drawn, and measure - pitch as the *largest* gap between drawn rows. -- **dvui draws dialogs and toasts in `Window.endRendering`,** after the app's frame and after - floats have taken their subwindows. Anything that must see a dialog's subwindow that frame - draws it earlier itself. dvui also stops its widget capture at `drawRetained`, so snapshots - capture dialogs through a capture scope (`dialogs.zig`). -- **A blur costs its passes, not its taps.** Each pass is a render-target switch; on SDL's Metal - renderer that's a new command encoder and buffer. Cut passes, not samples. -- **Judge performance by main-thread CPU, not fps.** At 120 Hz a frame over 8.33 ms takes - 16.7 ms, so "half the fps" means the frame crossed the line. Log `CLOCK_THREAD_CPUTIME_ID` per - frame, or `sample` the process and read the `nextDrawable` wait as headroom. - `SDL_RENDER_VSYNC=0` stops holding once the window is frontmost. +@AGENTS.md diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 9736333e5..7f01b1c27 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -1,7 +1,7 @@ # Pitfalls Things that cost someone a day, written down so they cost the next person a minute. Most live -beside the code they bite, in each area's `CLAUDE.md`: `sdk/`, `app/`, `core/`, `backend/` +beside the code they bite, in each area's `AGENTS.md`: `sdk/`, `app/`, `core/`, `backend/` (macOS windows, full screen, autorelease pools), `plugins/`, `web/`, `tests/`. This file holds the ones that belong to no single area. A fix that settles a pitfall updates its entry; a new one goes beside the code if it has an area, here if not. @@ -13,7 +13,7 @@ goes beside the code if it has an area, here if not. them. `Popout.envSwitch` read the first `FIZZY_*` switch for every name this way (#235). Fix: name the parameter inside the struct (`const variable = name;`) and use it. In review, look for function-local structs with `var`s inside generic functions. -- **Tests run only from files their root module analyzes** (`tests/CLAUDE.md`). +- **Tests run only from files their root module analyzes** (`tests/AGENTS.md`). `zig build check-tests-reached` names any that don't. ## Running a second fizzy @@ -37,7 +37,7 @@ goes beside the code if it has an area, here if not. - **Frames happen only on input or a refresh.** To profile a still scene, add a temporary hook that calls `dvui.refresh` every frame, then `sample 6`. Read inclusive samples of the - frame function, not fps (`core/CLAUDE.md`, "Judge performance by main-thread CPU"). Revert the + frame function, not fps (`core/AGENTS.md`, "Judge performance by main-thread CPU"). Revert the hook. - **Debug builds SDL's C at `-O0`** with UBSan, which inflates renderer time in a Debug profile. - **A stripped release crash can still be placed.** In the `.ips` report (line 1 is a JSON diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md new file mode 100644 index 000000000..5177de2c5 --- /dev/null +++ b/plugins/AGENTS.md @@ -0,0 +1,43 @@ +# plugins/: the bundled plugins + +Each directory is file for file the shape a third-party plugin has, and the template for one: +`plugin.zig`, an identity-only `plugin.zig.zon`, `build.zig` and `build.zig.zon`, an optional +`src/`. The one fizzy-specific part is `static/`, the glue that links a plugin into the app. +`shared/` is build helpers, not a plugin. The contract is `docs/PLUGINS.md`. + +- **A plugin never imports another.** They meet at the SDK: a service, a command, an event. When + one plugin needs something from fizzy, build the seam another plugin would reuse, not a path + for this one. +- **Each one compiles twice:** static into the executable (every target, web included) and as the + dylib that actually runs on the desktop. Code must work in both modes. +- **`cd plugins/ && zig build` also installs** into the real plugins directory. For a + sandbox, set `FIZZY_PROFILE` to a directory of your own for the build and for the fizzy you run. +- **Keep app-only dependencies out.** A plugin depends on `sdk/`, never the repo root. After any + build-graph change, run the Velopack acceptance test in the root `AGENTS.md`. +- **Tests:** a std-only file (`text/src/textcore/`, `markdown/src/md/block_heights.zig`, …) gets a + root in `build/app.zig`'s unit list. A test that needs the plugin's module runs from + `test-integration` (`fizzy-text-plugin-tests`, `fizzy-md-parser-tests`). + +## Pitfalls + +- **Two copies, two sets of globals.** The static copy and the dylib each have their own module + variables. Host code that writes a plugin's global by symbol writes the static copy, which the + running dylib never sees. Reach plugin state through a service or a vtable hook. +- **`sdk.host()` panics until the host installs the runtime,** which happens only on the plugin + load path. Code that also compiles into `test-integration`, where the plugin is a plain module + and nothing registers it, must take the host explicitly. See `render_ast.highlight_host`, set + from `register`. +- **`Host.treeSitterHighlightFor` skips a provider with no `owner`.** A test that registers a fake + `LanguageSupport` must give it a `sdk.Plugin`, or the lookup silently returns null. +- **A test build that overwrote a user's installed plugin is theirs to restore.** Say so. Don't + copy anything back from the loader's `.load-copy/`. +- **An installed dylib refuses to load if its optimize mode differs from the app's** + (`AbiBuildEnvMismatch`). `zig build` defaults to Debug, and store plugins are built + ReleaseFast. +- **A plugin built from the released SDK tarball ignores local `sdk/` and `core/` edits.** To + test an SDK change end to end, point its `build.zig.zon` `.fizzy` dependency at a *relative* + path (`../../fizzy/sdk`; absolute paths are rejected). +- **On the web,** plugins are wasm side modules: `zig build -Dtarget=wasm32-freestanding + -Doptimize=ReleaseSmall`, loaded with `?plugin=` (`web/AGENTS.md`). The host must be the + same optimize class, and dvui on the web draws only on request, so a plugin with work in + flight calls `sdk.refresh()`. diff --git a/plugins/CLAUDE.md b/plugins/CLAUDE.md index 70033e731..43c994c2d 100644 --- a/plugins/CLAUDE.md +++ b/plugins/CLAUDE.md @@ -1,43 +1 @@ -# plugins/: the bundled plugins - -Each directory is file for file the shape a third-party plugin has, and the template for one: -`plugin.zig`, an identity-only `plugin.zig.zon`, `build.zig` and `build.zig.zon`, an optional -`src/`. The one fizzy-specific part is `static/`, the glue that links a plugin into the app. -`shared/` is build helpers, not a plugin. The contract is `docs/PLUGINS.md`. - -- **A plugin never imports another.** They meet at the SDK: a service, a command, an event. When - one plugin needs something from fizzy, build the seam another plugin would reuse, not a path - for this one. -- **Each one compiles twice:** static into the executable (every target, web included) and as the - dylib that actually runs on the desktop. Code must work in both modes. -- **`cd plugins/ && zig build` also installs** into the real plugins directory. For a - sandbox, set `FIZZY_PROFILE` to a directory of your own for the build and for the fizzy you run. -- **Keep app-only dependencies out.** A plugin depends on `sdk/`, never the repo root. After any - build-graph change, run the Velopack acceptance test in the root `CLAUDE.md`. -- **Tests:** a std-only file (`text/src/textcore/`, `markdown/src/md/block_heights.zig`, …) gets a - root in `build/app.zig`'s unit list. A test that needs the plugin's module runs from - `test-integration` (`fizzy-text-plugin-tests`, `fizzy-md-parser-tests`). - -## Pitfalls - -- **Two copies, two sets of globals.** The static copy and the dylib each have their own module - variables. Host code that writes a plugin's global by symbol writes the static copy, which the - running dylib never sees. Reach plugin state through a service or a vtable hook. -- **`sdk.host()` panics until the host installs the runtime,** which happens only on the plugin - load path. Code that also compiles into `test-integration`, where the plugin is a plain module - and nothing registers it, must take the host explicitly. See `render_ast.highlight_host`, set - from `register`. -- **`Host.treeSitterHighlightFor` skips a provider with no `owner`.** A test that registers a fake - `LanguageSupport` must give it a `sdk.Plugin`, or the lookup silently returns null. -- **A test build that overwrote a user's installed plugin is theirs to restore.** Say so. Don't - copy anything back from the loader's `.load-copy/`. -- **An installed dylib refuses to load if its optimize mode differs from the app's** - (`AbiBuildEnvMismatch`). `zig build` defaults to Debug, and store plugins are built - ReleaseFast. -- **A plugin built from the released SDK tarball ignores local `sdk/` and `core/` edits.** To - test an SDK change end to end, point its `build.zig.zon` `.fizzy` dependency at a *relative* - path (`../../fizzy/sdk`; absolute paths are rejected). -- **On the web,** plugins are wasm side modules: `zig build -Dtarget=wasm32-freestanding - -Doptimize=ReleaseSmall`, loaded with `?plugin=` (`web/CLAUDE.md`). The host must be the - same optimize class, and dvui on the web draws only on request, so a plugin with work in - flight calls `sdk.refresh()`. +@AGENTS.md diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md new file mode 100644 index 000000000..cdb9df63b --- /dev/null +++ b/sdk/AGENTS.md @@ -0,0 +1,26 @@ +# sdk/: the plugin contract + +Everything a plugin compiles against. What the contract *is* lives in `docs/PLUGINS.md`; this +file is the rules for changing it. + +- **The boundary has a fingerprint.** Changing the shape of a type a plugin sees (fields, enum + tags, fn signatures) fails the build with the new value. Record it as + `recorded_sdk_shape_fingerprint` in `src/version.zig` and label the PR `sdk`. Never touch + `sdk_version.zig` outside a release PR (`CONTRIBUTING.md`, "Changing the SDK"). +- **A new boundary type goes in `sdk_boundary_types`** (`src/dylib.zig`), or the fingerprint never + sees it change. +- **This directory ships on its own** as `fizzy-sdk-v*.tar.gz` (`scripts/pack-sdk.sh`), holding + only what `build.zig.zon`'s `.paths` lists. A new top-level file here goes in that list too; + `scripts/check-examples.sh` builds the example plugin from the packed tarball and catches it. +- **dvui is pinned here and nowhere else** (`build.zig.zon`). See the root `AGENTS.md` for why + re-adding it to the root zon breaks the fingerprint. +- **One module per file.** Nothing outside reaches in by relative path; the app and build scripts + go through `@import("fizzy_sdk")`. Nothing here imports `app/` or `src/`. +- **`tape/` is std-only, `replay/` is dvui and `tape` only.** A reach into `core` or `app` from + either fails to compile on purpose: they are libraries any dvui app can take. +- **Tests** in `src/` run in `fizzy-sdk-tests` (`zig build test-integration`), rooted at + `src/sdk.zig`. A file reached only through a `pub const x = @import(…)` nothing uses is never + analyzed; `zig build check-tests-reached` names its tests. A std-only file can take a root of + its own in `build/app.zig` instead (`Manifest.zig`, `services/wikilink.zig`). +- After touching `src/**`, run every gate: `zig build`, `test`, `test-integration`, `check-web`, + `test-sdk-version`. diff --git a/sdk/CLAUDE.md b/sdk/CLAUDE.md index a24a88041..43c994c2d 100644 --- a/sdk/CLAUDE.md +++ b/sdk/CLAUDE.md @@ -1,26 +1 @@ -# sdk/: the plugin contract - -Everything a plugin compiles against. What the contract *is* lives in `docs/PLUGINS.md`; this -file is the rules for changing it. - -- **The boundary has a fingerprint.** Changing the shape of a type a plugin sees (fields, enum - tags, fn signatures) fails the build with the new value. Record it as - `recorded_sdk_shape_fingerprint` in `src/version.zig` and label the PR `sdk`. Never touch - `sdk_version.zig` outside a release PR (`CONTRIBUTING.md`, "Changing the SDK"). -- **A new boundary type goes in `sdk_boundary_types`** (`src/dylib.zig`), or the fingerprint never - sees it change. -- **This directory ships on its own** as `fizzy-sdk-v*.tar.gz` (`scripts/pack-sdk.sh`), holding - only what `build.zig.zon`'s `.paths` lists. A new top-level file here goes in that list too; - `scripts/check-examples.sh` builds the example plugin from the packed tarball and catches it. -- **dvui is pinned here and nowhere else** (`build.zig.zon`). See the root `CLAUDE.md` for why - re-adding it to the root zon breaks the fingerprint. -- **One module per file.** Nothing outside reaches in by relative path; the app and build scripts - go through `@import("fizzy_sdk")`. Nothing here imports `app/` or `src/`. -- **`tape/` is std-only, `replay/` is dvui and `tape` only.** A reach into `core` or `app` from - either fails to compile on purpose: they are libraries any dvui app can take. -- **Tests** in `src/` run in `fizzy-sdk-tests` (`zig build test-integration`), rooted at - `src/sdk.zig`. A file reached only through a `pub const x = @import(…)` nothing uses is never - analyzed; `zig build check-tests-reached` names its tests. A std-only file can take a root of - its own in `build/app.zig` instead (`Manifest.zig`, `services/wikilink.zig`). -- After touching `src/**`, run every gate: `zig build`, `test`, `test-integration`, `check-web`, - `test-sdk-version`. +@AGENTS.md diff --git a/sdk/build.zig b/sdk/build.zig index 2648ce63f..cb697e20a 100644 --- a/sdk/build.zig +++ b/sdk/build.zig @@ -1,5 +1,5 @@ //! Plugin-facing Fizzy package. Plugins depend on this directory (not the repo root) so -//! app-only deps like Velopack never enter their zon graph — see CLAUDE.md. +//! app-only deps like Velopack never enter their zon graph — see AGENTS.md. const std = @import("std"); // Build-time surface of this package, for the app as well as third-party plugins. The app consumes diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index b48e2e3b2..d2d07f997 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -4,7 +4,7 @@ .fingerprint = 0x3311781b63706048, .minimum_zig_version = "0.16.0", // Plugin SDK package — no app-only deps (Velopack, nightwatch, …). Plugins depend on - // this directory; the repo-root zon owns the editor build. See CLAUDE.md. + // this directory; the repo-root zon owns the editor build. See AGENTS.md. .paths = .{ "build.zig", "build.zig.zon", diff --git a/sdk/core_module.zig b/sdk/core_module.zig index d5425ae39..ded8dca2f 100644 --- a/sdk/core_module.zig +++ b/sdk/core_module.zig @@ -9,7 +9,7 @@ //! called from all five. //! //! Pure `std.Build` glue with no app-only dependencies — lives in the `sdk/` package so plugin -//! builds never open the repo-root zon (see `CLAUDE.md`). +//! builds never open the repo-root zon (see `AGENTS.md`). const std = @import("std"); /// Add every import `core` needs to `mod`, and return the `icons` module when that lazy diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d8077c773..22e5dcbf7 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -731,7 +731,7 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { // static/dylib path (see `postInit` above) and may *also* have its dylib sitting in // this same shared plugins directory — built-ins compile "the same third-party // shape" a real third-party plugin would, and `zig build install` drops every - // plugin's dylib here alike (see CLAUDE.md). Rediscovering a built-in's own binary + // plugin's dylib here alike (see AGENTS.md). Rediscovering a built-in's own binary // here is expected, not a failure: recording it as one would surface the same id // twice in the settings pane (once as the loaded built-in, once as a "failed" user // plugin), which collides on the shared heading widget id. Only a genuine third-party diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 000000000..4e0972960 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,16 @@ +# tests/ + +`README.md` here is the guide: running, filtering (`-Dtest-filter`), and how Zig collects tests. +The rules that bite: + +- **A `test` block runs only if a test root's module analyzes its file.** `zig build + check-tests-reached` fails naming any block that is compiled nowhere. Run it after adding a + test file. +- **Read the Build Summary, not the test count.** `test-integration` prints `failed command:` + when a test logs a warning, even on a pass; only `N/N steps succeeded` is a pass. +- **`integration.zig` drives the real app headless** against dvui's testing backend. Add a test + beside the ones for the same feature rather than at the end. +- **Demo tapes** (`tapes/`, `docs/demos/`) replay real input through the app. How to write one, + and the anchors widgets publish: `docs/AUTOMATION.md`. +- **Benchmarks** (`bench/`) are test binaries run by `zig build bench-*`. They print timings and + never gate CI. diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 4e0972960..43c994c2d 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -1,16 +1 @@ -# tests/ - -`README.md` here is the guide: running, filtering (`-Dtest-filter`), and how Zig collects tests. -The rules that bite: - -- **A `test` block runs only if a test root's module analyzes its file.** `zig build - check-tests-reached` fails naming any block that is compiled nowhere. Run it after adding a - test file. -- **Read the Build Summary, not the test count.** `test-integration` prints `failed command:` - when a test logs a warning, even on a pass; only `N/N steps succeeded` is a pass. -- **`integration.zig` drives the real app headless** against dvui's testing backend. Add a test - beside the ones for the same feature rather than at the end. -- **Demo tapes** (`tapes/`, `docs/demos/`) replay real input through the app. How to write one, - and the anchors widgets publish: `docs/AUTOMATION.md`. -- **Benchmarks** (`bench/`) are test binaries run by `zig build bench-*`. They print timings and - never gate CI. +@AGENTS.md diff --git a/tests/bench/bench_markdown.zig b/tests/bench/bench_markdown.zig index f626e4284..9c5e2a8aa 100644 --- a/tests/bench/bench_markdown.zig +++ b/tests/bench/bench_markdown.zig @@ -34,7 +34,7 @@ const sample_huge = @embedFile("sample_huge"); // docs/PLUGINS.md /// integration tests' table sample); it stands in for a plan doc that stayed slow after block /// virtualization, which is exactly why a document of this shape is in here. const sample_prose = @embedFile("sample_prose"); // tests/data/markdown_sample_tables.md -const sample_medium = @embedFile("sample_medium"); // CLAUDE.md +const sample_medium = @embedFile("sample_medium"); // AGENTS.md const sample_small = @embedFile("sample_small"); // docs/MODULARIZATION_RELEASE_NOTES.md /// Live document + preview state for the frame function, which `dvui.App.frameFunction` @@ -285,7 +285,7 @@ test "bench: markdown preview frame cost" { const cases = [_]struct { name: []const u8, text: []const u8 }{ .{ .name = "huge (docs/PLUGINS.md)", .text = sample_huge }, .{ .name = "prose (tests/data/markdown_sample_tables.md)", .text = sample_prose }, - .{ .name = "medium (CLAUDE.md)", .text = sample_medium }, + .{ .name = "medium (AGENTS.md)", .text = sample_medium }, .{ .name = "small (release notes)", .text = sample_small }, }; diff --git a/web/AGENTS.md b/web/AGENTS.md new file mode 100644 index 000000000..061c89759 --- /dev/null +++ b/web/AGENTS.md @@ -0,0 +1,29 @@ +# web/: fizzy in a browser + +The page and its JavaScript (`index.html`, `web.js`, workers). The Zig half is +`src/backend/WebBackend.zig` and `src/web_main.zig`. `zig build web` builds into `zig-out/web`, and +`scripts/serve-web.sh` serves it on :8765. `main` deploys to fizzyed.it/app. + +- **The web backend is fizzy's,** begun from dvui's `web.zig`/`web.js` and linked through dvui's + `custom` backend with dvui's web options passed explicitly (`build/web.zig`). A dvui bump that + touches dvui's own web backend is merged into these by hand. +- **One upload per frame where possible.** In Firefox (WebGL runs out of process) `bufferData` + costs ~15–20 µs, about 100× any other GL call. The backend records a frame as one stream and + flushes only at pixel reads, frame end, or a change to a texture a pending draw uses. Keep it + that way; call count barely matters, uploads do. +- **Plugins load at runtime as wasm side modules** (`index.html`'s `loadPlugin` → + `app/store/PluginLoader_web.zig`). To test one: build it with + `-Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`, copy it to + `zig-out/web/plugins//.wasm`, and open `/?plugin=&open=`. `?open=` opens a file + as an upload, and a zip becomes the root. The page remembers loaded plugins in `localStorage` + (`fizzy.web_plugins`), so clear that key to reset. The host and plugin must be the same + optimize class, and an optimized plugin imports libm by name (`ldexpf`), which the host + exports from `web_main.zig`. +- **The heap never shrinks.** The wasm allocator rounds blocks over 64 KB up to a power-of-two + class and reuses a freed block only within its class, so a large image can step memory from + tens to hundreds of MB. Before blaming memory for a failed open, read the logged error. +- **The browser pane hides itself.** Behind the chat it is `visibilityState: hidden`, so + `requestAnimationFrame` runs at ~1 fps and clicks collapse into one frame. Front the tab, or + dispatch `MouseEvent`s at the canvas ~1 s apart. dvui on the web draws only on request. +- After `zig build web`, a hand-edited copy of `index.html` keeps a stale cache-buster hash and + the browser serves the old `web.js`. Regenerate the copy. diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 061c89759..43c994c2d 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -1,29 +1 @@ -# web/: fizzy in a browser - -The page and its JavaScript (`index.html`, `web.js`, workers). The Zig half is -`src/backend/WebBackend.zig` and `src/web_main.zig`. `zig build web` builds into `zig-out/web`, and -`scripts/serve-web.sh` serves it on :8765. `main` deploys to fizzyed.it/app. - -- **The web backend is fizzy's,** begun from dvui's `web.zig`/`web.js` and linked through dvui's - `custom` backend with dvui's web options passed explicitly (`build/web.zig`). A dvui bump that - touches dvui's own web backend is merged into these by hand. -- **One upload per frame where possible.** In Firefox (WebGL runs out of process) `bufferData` - costs ~15–20 µs, about 100× any other GL call. The backend records a frame as one stream and - flushes only at pixel reads, frame end, or a change to a texture a pending draw uses. Keep it - that way; call count barely matters, uploads do. -- **Plugins load at runtime as wasm side modules** (`index.html`'s `loadPlugin` → - `app/store/PluginLoader_web.zig`). To test one: build it with - `-Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`, copy it to - `zig-out/web/plugins//.wasm`, and open `/?plugin=&open=`. `?open=` opens a file - as an upload, and a zip becomes the root. The page remembers loaded plugins in `localStorage` - (`fizzy.web_plugins`), so clear that key to reset. The host and plugin must be the same - optimize class, and an optimized plugin imports libm by name (`ldexpf`), which the host - exports from `web_main.zig`. -- **The heap never shrinks.** The wasm allocator rounds blocks over 64 KB up to a power-of-two - class and reuses a freed block only within its class, so a large image can step memory from - tens to hundreds of MB. Before blaming memory for a failed open, read the logged error. -- **The browser pane hides itself.** Behind the chat it is `visibilityState: hidden`, so - `requestAnimationFrame` runs at ~1 fps and clicks collapse into one frame. Front the tab, or - dispatch `MouseEvent`s at the canvas ~1 s apart. dvui on the web draws only on request. -- After `zig build web`, a hand-edited copy of `index.html` keeps a stale cache-buster hash and - the browser serves the old `web.js`. Regenerate the copy. +@AGENTS.md