diff --git a/build/app.zig b/build/app.zig index e7918fc0f..0a45cab63 100644 --- a/build/app.zig +++ b/build/app.zig @@ -954,8 +954,8 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // subwindows, which a headless frame with a float in it has. integration_module.addImport("workbench", workbench_module_test); integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") }); - // The document with the 45KB table — the case table-row culling exists for, and the one it - // could get wrong. + // The synthetic document with the 48KB table of wrapping cells — the case table-row culling + // exists for, and the one it could get wrong. Generated by `markdown_sample_tables.py` beside it. integration_module.addAnonymousImport("markdown_sample_tables", .{ .root_source_file = b.path("tests/data/markdown_sample_tables.md") }); // The image-heavy fixture. Both docs above are prose and tables, so without this no test ever // laid out an image block — the one block kind whose height nothing in the source predicts diff --git a/plugins/markdown/src/md/block_heights.zig b/plugins/markdown/src/md/block_heights.zig index f9a8ed0a5..ad649e264 100644 --- a/plugins/markdown/src/md/block_heights.zig +++ b/plugins/markdown/src/md/block_heights.zig @@ -89,6 +89,19 @@ pub const Height = struct { /// Laid out once at the current width. dvui sizes a widget from what its children /// reported the frame before, so a first measurement is still settling. measured, + /// Laid out at a column width the document no longer has (`invalidateForWidth`). Far + /// closer to the truth than an estimate, so placement keeps using it, but it describes a + /// column that is gone: the first measurement at the new width, contaminated or not, + /// replaces it. + /// + /// This used to be `.measured` as well, and the two meanings could not be told apart. + /// `record` has to throw a stale height away in favour of even a contaminated measurement + /// (or a table never reflows after a resize), so it threw away every `.measured` height — + /// including a table's first clean measurement at the *current* width. The very next + /// frame that drew a few rows for the first time (dvui lays a new text layout out at an + /// assumed 1000pt width, so those rows come out short) then replaced a correct height with + /// a short one, and the document moved under a reader scrolling into the table. + stale, /// Two consecutive measurements agreed. Trustworthy; don't re-measure. settled, /// Laid out, but the measurement was contaminated — a table inside it still has rows @@ -113,7 +126,7 @@ pub const Height = struct { /// width change resets the count, and scrolling to it draws it anyway. pub fn wantsMeasure(self: Height) bool { return switch (self.state) { - .estimated, .measured => true, + .estimated, .measured, .stale => true, .deferred => self.attempts < deferred_max_attempts, .settled => false, }; @@ -244,9 +257,9 @@ pub const Table = struct { const seeded: ?Height = if (e.hash != 0) self.by_source.get(e.hash) else null; // Carry the *state* across too, not just the number. // - // Flattening everything to `.measured` looked harmless and was not: `record` reads - // `.measured` as "a height from before a width change", and discards it in favour of even a - // contaminated measurement. A re-parse hands every table exactly one contaminated + // Flattening everything to `.stale` looked harmless and was not: `record` reads it as "a + // height from before a width change", and discards it in favour of even a contaminated + // measurement. A re-parse hands every table exactly one contaminated // measurement — the cell sizes the renderer keys by AST node pointer die with the old tree // — so on every keystroke a settled 900pt table was replaced by whatever that frame's // half-culled layout reported. Measured at 42pt in the test below: an 858pt lurch under the @@ -427,16 +440,13 @@ pub const Table = struct { }; if (mm.partial) { - // Keep what we had, but only when it is worth keeping: a height already confirmed at - // *this* width. A `.measured` entry here is one a width change just invalidated, so - // it describes a column the document no longer has — holding onto it would freeze the - // table at its pre-resize size. A contaminated measurement at the right width beats a - // clean one at the wrong width. - // Only `.measured` is excluded, and only because a width change is what produces it - // here: that height describes a column the document no longer has. An `.estimated` - // entry is kept — it is crude, but it is derived from the source and does not lurch - // when the reader scrolls, which a contaminated measurement very much does. - const keep = prev.h > 0 and prev.state != .measured; + // Keep what we had, unless it describes a column the document no longer has: holding + // onto a `.stale` height would freeze the table at its pre-resize size, and a + // contaminated measurement at the right width beats a clean one at the wrong width. + // Everything else is kept. A `.measured` height is a clean layout at this width, and an + // `.estimated` one is crude but derived from the source; neither lurches when the + // reader scrolls, which a contaminated measurement very much does. + const keep = prev.h > 0 and prev.state != .stale; const entry: Height = .{ .h = if (keep) prev.h else mm.h, .state = .deferred, @@ -451,7 +461,7 @@ pub const Table = struct { } const agrees = switch (prev.state) { - .measured, .settled => @abs(prev.h - mm.h) <= settle_epsilon, + .measured, .settled, .stale => @abs(prev.h - mm.h) <= settle_epsilon, .estimated, .deferred => false, }; // A clean measurement clears the strike count: whatever was wrong with this block has @@ -476,7 +486,7 @@ pub const Table = struct { /// /// Every wrapped block reflows, so no height is *current* any more — but a stale measurement is /// still far closer to the truth than an estimate, so heights are kept and merely demoted to - /// re-measurable. They must not be clamped toward the estimate: an image or a table occupies + /// `.stale`. They must not be clamped toward the estimate: an image or a table occupies /// one line of source, so its estimate is a dozen pixels against a real several hundred, and /// clamping collapsed the whole document's height model on every split drag. /// @@ -489,7 +499,10 @@ pub const Table = struct { // could never learn the new one — the pin forced each measurement to equal the pin, so // the block agreed with itself forever and the table never reflowed. for (self.heights.items) |*e| { - e.state = .measured; + // An estimate stays one. It is re-derived from the column on every read, so it is + // already current, and its stored `h` is a placeholder (zero, from `appendExtent`) + // that a `.stale` slot would hand back as the block's height. + if (e.state != .estimated) e.state = .stale; // A new width is a fresh problem — give every block its attempts back. e.attempts = 0; } @@ -932,10 +945,42 @@ test "width change keeps measurements instead of clamping them to estimates" { // The regression this guards: the height used to be clamped to `@min(h, estimate)`, which // collapsed 540 to ~13 on every split drag and took the document's height model with it. try testing.expectEqual(@as(f32, 540), t.heights.items[0].h); - try testing.expectEqual(Height.State.measured, t.stateAt(0)); + try testing.expectEqual(Height.State.stale, t.stateAt(0)); try testing.expect(t.heights.items[0].wantsMeasure()); } +// The table that moved under a reader scrolling into it. Its first clean measurement at this width +// is a real answer; the frame after it draws a few rows for the first time, and dvui lays a new +// text layout out at an assumed width, so those rows come out short and the block's measurement is +// contaminated. That number must not replace the clean one. It did while "clean, at this width" +// and "laid out at a width that is gone" were both `.measured`, because the second must be replaced. +test "a contaminated measurement does not replace a clean one at the same width" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + _ = t.invalidateForWidth(600); + + t.record(gpa, 0, .{ .h = 1789 }, tm, 600); + try testing.expectEqual(Height.State.measured, t.stateAt(0)); + t.record(gpa, 0, .{ .h = 1730, .partial = true }, tm, 600); + try testing.expectEqual(@as(f32, 1789), t.heightAt(0, tm, 600)); + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); +} + +// The first width a pane learns is a width change too, and it used to turn every never-measured +// block into a "measurement" of its placeholder height — zero — so the document's first real frame +// placed every block it had not yet drawn at y=0 instead of by its estimate. +test "a width change leaves a never-measured block on its estimate" { + const gpa = testing.allocator; + var t = testTable(gpa, 3, 4); + defer t.deinit(gpa); + + try testing.expect(t.invalidateForWidth(600)); + try testing.expectEqual(Height.State.estimated, t.stateAt(1)); + try testing.expectEqual(t.estimate(1, tm, 600).?, t.heightAt(1, tm, 600)); + try testing.expect(t.heightAt(1, tm, 600) > 0); +} + test "width change within epsilon is not a change" { const gpa = testing.allocator; var t = testTable(gpa, 1, 2); @@ -964,7 +1009,7 @@ test "a width change invalidates a deferred height too" { // the renderer keep pinning the block to its pre-resize size, and because a pinned block // measures exactly its pin, it agreed with itself forever and never reflowed. _ = t.invalidateForWidth(900); - try testing.expectEqual(Height.State.measured, t.stateAt(0)); + try testing.expectEqual(Height.State.stale, t.stateAt(0)); try testing.expect(t.heights.items[0].wantsMeasure()); // ...and now a contaminated measurement at the new width is preferred over the old-width diff --git a/plugins/markdown/src/md/render_ast.zig b/plugins/markdown/src/md/render_ast.zig index 6bc2e8c17..8a13d0fd7 100644 --- a/plugins/markdown/src/md/render_ast.zig +++ b/plugins/markdown/src/md/render_ast.zig @@ -114,6 +114,10 @@ pub const CellSize = struct { /// frames settles at one line and stays there. col_w: f32, settled: bool, + /// Measured from a text layout on its first frame, which dvui wraps at an assumed 1000pt + /// rather than the column (`Stats.fresh_text_layouts`). Kept only as a placeholder for a cell + /// with nothing better, and never one of the two draws that settle it. + provisional: bool = false, }; /// One table's column widths, and the inputs they were computed from. Recomputed when either @@ -1917,11 +1921,19 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // // The pin is released as soon as the block produces a measurement worth believing, which // for a table means every one of its rows has been measured at least once. - // Scoped to blocks containing a table, and to states where the cached number is one we - // believe. Reacting to *this* frame's contamination would always be a frame late — the - // garbage measurement has already been emitted by then — so the pin goes on as soon as - // there is something worth holding, and comes off only when a width change demotes the - // entry back to `.measured` and the block genuinely has to be re-measured. + // Scoped to states where the cached number is one we believe. Reacting to *this* frame's + // contamination would always be a frame late — the garbage measurement has already been + // emitted by then — so the pin goes on as soon as there is something worth holding, and + // comes off when a new measurement disagrees with it or a width change demotes the entry + // to `.stale` and the block genuinely has to be re-measured. + // + // Any block, not only one holding a table. A paragraph scrolled back into view lays its + // text out for the first time, and dvui wraps a first-frame text layout at an assumed + // width rather than the column, so it emits a line or two short for one frame and the + // scroll total twitches with it. The pin was scoped to tables while a pinned block was + // measured by its pin, which would have held a paragraph at its old height for ever; it + // is measured by its inner box now (`measured` below), so a pin hides a frame's bad + // layout and still learns a real change. const block_state = rs.blocks.stateAt(index); // ...and never while the column width is moving: that is precisely when the block has to // be allowed to relearn its height, and a pin there would hold it at its pre-resize size. @@ -1935,13 +1947,13 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // *up* turns the skip-spacer above the viewport into real widgets; their first-frame // height (dvui sizes from last frame's children, which is 0) is not `known_h`, so // everything below jumps, and next frame's anchor "corrects" it — the flash that - // only happens when scrolling up. Pin the *emitted* height to the table; measure - // the unconstrained inner box so `record` can still learn the real size. + // only happens when scrolling up. Pin the *emitted* height to the table; `record` still + // learns the real size from the unconstrained inner box (see `measured` below). const pin_for_above = known_h > 0 and !width_in_flux and y <= anchor_y; const pin_h: ?f32 = if (known_h > 0 and !width_in_flux and (pin_for_above or (frozen and block_state != .estimated) or - (pinnable and rs.subtree_has_table.contains(@intFromPtr(ch.n))))) known_h else null; + pinnable)) known_h else null; var wrapper = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = index, @@ -2001,11 +2013,18 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { }) catch {}; } - // Wrapper is what the scroll container saw. Inner is unconstrained — used only when - // we pinned for a block above the fold, so `record` can learn the real height - // without letting that height reach the layout this frame. + // Wrapper is what the scroll container saw. Inner is unconstrained, and is what any pinned + // block is measured by, so `record` learns the real height without letting it reach the + // layout this frame. + // + // *Any* pinned block, not only one pinned above the fold. A pinned wrapper emits exactly + // its pin, so measuring the wrapper files the pin back as a measurement. That is how a + // `.deferred` table — pinned at a height nobody believes — came out of the deferral with + // that same height recorded as a clean measurement, as soon as its rows were all measured: + // the guess, laundered into an answer. The synthetic sample's last table had a 46pt + // header-only measurement filed that way on its first frames. const emitted = (dvui.minSizeGet(wrapper_id) orelse dvui.Size{}).h; - const measured = if (pin_for_above) + const measured = if (pin_h != null) (dvui.minSizeGet(inner_id) orelse dvui.Size{}).h else emitted; @@ -2026,10 +2045,9 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // assumed width, not the column's (`Stats.fresh_text_layouts`), so the height is not this // block's at this width, and next frame's will be. const partial = rs.block_rows_pending > 0 or stats.fresh_text_layouts != fresh_before; - // A pinned block measured exactly what it was pinned to, which says nothing about what - // it wants to be. Filing that would promote a first, still-settling measurement to - // `.settled` at the lagged height — the freeze would then *persist* the very error it - // exists to hide from one click. + // Nothing is filed while a click holds the document still: a new height becomes next + // frame's `known_h`, and with it next frame's pin, which would move the document under + // the pointer the freeze exists to hold it still for. if (measured > 0 and (!frozen or pin_h == null)) rs.blocks.record(ctx.gpa, index, .{ .h = measured, .partial = partial }, metrics, ctx.column_width); const after_h = rs.blocks.heightAt(index, metrics, ctx.column_width); @@ -2859,10 +2877,27 @@ fn renderBlock(n: ast.Node, ids: *IdGen, ctx: RenderContext) void { cs.size else .{ .w = 0, .h = dvui.Font.theme(.body).lineHeight() }; + // A drawn cell with a settled size at this width is held to at least + // that height, as a culled one is held to exactly it. Its text may be + // laid out for the first time this frame — always so when the reader + // scrolls a row into view — and dvui wraps that at an assumed width, + // short. The grid sizes the row from what its cells report, so the + // short frame became next frame's row height and the table measured + // short, *cleanly*, the frame after: a settled table lurching by a few + // lines each time the reader scrolled into it. + const held: ?CellSize = if (cached) |cs| + (if (cs.settled and cs.col_w == cell_w) cs else null) + else + null; const cell_box = g.cell( .{ .col = col, .row = body_row }, banded.opts(body_row, cell_padding).override( - if (draw_cell) .{} else .{ .min_size_content = cell_placeholder }, + if (!draw_cell) + .{ .min_size_content = cell_placeholder } + else if (held) |cs| + .{ .min_size_content = .{ .w = 0, .h = cs.size.h } } + else + .{}, ), ); if (row_anchor == null) { @@ -2877,6 +2912,7 @@ fn renderBlock(n: ast.Node, ids: *IdGen, ctx: RenderContext) void { // Ids inside a cell hang off the cell widget, so restarting them // per cell keeps a skipped neighbour from shifting anything. ids.n = 0; + const fresh_before = stats.fresh_text_layouts; renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); // Read before `deinit`, and with the padding taken back off: // `min_size_content` has the padding added to it again, so @@ -2885,13 +2921,36 @@ fn renderBlock(n: ast.Node, ids: *IdGen, ctx: RenderContext) void { .w = @max(0, cell_box.data().min_size.w - cell_padding.x - cell_padding.w), .h = @max(0, cell_box.data().min_size.h - cell_padding.y - cell_padding.h), }; - const agrees = cached != null and cached.?.col_w == cell_w and - cached.?.size.w == measured.w and cached.?.size.h == measured.h; - ctx.rs.cell_sizes.put(ctx.gpa, cell_key, .{ - .size = measured, - .col_w = cell_w, - .settled = agrees, - }) catch {}; + // A cell whose text was laid out for the first time this frame + // measured a wrap at dvui's assumed width, not this column's, so it + // came out short. It must not count as a draw: a row measured only + // on budgeted frames, with gaps between, loses its text layouts in + // every gap and is *always* on its first frame — two such draws + // agree with each other perfectly, and the cell settled short. + // It must not replace a real size either. A table re-entering the + // draw set gets a new grid and new text layouts, and every cell it + // drew on that frame overwrote a settled size with a short one. + // Measured on the synthetic sample's last table: its final row + // settled 59pt short, so the table measured 1789 off screen and + // 1730 on it. + const first_frame = stats.fresh_text_layouts != fresh_before; + if (!first_frame) { + const agrees = cached != null and !cached.?.provisional and + cached.?.col_w == cell_w and + cached.?.size.w == measured.w and cached.?.size.h == measured.h; + ctx.rs.cell_sizes.put(ctx.gpa, cell_key, .{ + .size = measured, + .col_w = cell_w, + .settled = agrees, + }) catch {}; + } else if (cached == null) { + ctx.rs.cell_sizes.put(ctx.gpa, cell_key, .{ + .size = measured, + .col_w = cell_w, + .settled = false, + .provisional = true, + }) catch {}; + } } cell_box.deinit(); col += 1; diff --git a/tests/bench/bench_markdown.zig b/tests/bench/bench_markdown.zig index e4c8b7a48..f626e4284 100644 --- a/tests/bench/bench_markdown.zig +++ b/tests/bench/bench_markdown.zig @@ -29,10 +29,11 @@ const render_ast = markdown.render_ast; /// prompted this benchmark (single-digit fps in Debug), and the smaller ones separate costs /// that scale with document size from those that don't. const sample_huge = @embedFile("sample_huge"); // docs/PLUGINS.md -/// Same size as `sample_huge` but shaped completely differently: very long paragraphs (single -/// blocks of several thousand characters) and several tables. It is the document that stayed slow -/// after block virtualization, which is exactly why it is in here. -const sample_prose = @embedFile("sample_prose"); // plans/PLUGIN_MANIFEST_PLAN.md +/// Shaped completely differently from `sample_huge`: one 48KB table whose cells wrap over many +/// lines, three smaller ones, and a paragraph several thousand characters long. Synthetic (the +/// 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_small = @embedFile("sample_small"); // docs/MODULARIZATION_RELEASE_NOTES.md @@ -283,7 +284,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 (plans/PLUGIN_MANIFEST_PLAN.md)", .text = sample_prose }, + .{ .name = "prose (tests/data/markdown_sample_tables.md)", .text = sample_prose }, .{ .name = "medium (CLAUDE.md)", .text = sample_medium }, .{ .name = "small (release notes)", .text = sample_small }, }; diff --git a/tests/data/markdown_sample_tables.md b/tests/data/markdown_sample_tables.md index 03a93f469..e3e27e356 100644 --- a/tests/data/markdown_sample_tables.md +++ b/tests/data/markdown_sample_tables.md @@ -1,242 +1,181 @@ -# Plugin Manifest — Living Plan (Revised) - -> **Status:** core complete (revision 2026-07-20; R10 landed 2026-07-24, reverting R8's -> one-file-per-plugin settings design in favor of one merged `settings.zon`, plus per-plugin -> install directories; R11 landed 2026-07-24, a settings.zon file watcher + live hand-edit -> reconciliation; R12 landed 2026-07-24, non-default-only persistence + per-plugin `.enabled` + -> nightwatch adoption; R17 landed 2026-07-30, `tags` in `Manifest` alongside R16's `description`, -> plus the registry-side fallback so `fizzyedit/plugins` no longer requires hand-duplicating -> either into `registry/.json`; R19 landed 2026-09-03, replacing numeric `fileTypePriority` -> with explicit, persisted, per-extension user ownership — sdk 0.1.51, a breaking SDK change that -> every plugin claiming file types must be rebuilt against). External plugin repos still need -> pin + reshape. -> R7 (`sdk-v*` tag namespace) landed: `.github/workflows/sdk-tag.yml` tags every `sdk_version` -> on push to `main` and publishes the `fizzy-sdk-v*.tar.gz` release asset plugins pin. -> **Owner:** Fizzy core (this repo). External plugin re-releases are coordinated after the SDK lands. -> **Agents:** update this file as you land work — flip status, amend decisions, log blockers. -> **Related:** [`CLAUDE.md`](../CLAUDE.md), [`PLUGINS.md`](PLUGINS.md), SDK at `sdk/src/`. +# Markdown sample: tables + +A synthetic, table-heavy document for the markdown preview's layout tests: one very large table +whose rows wrap over many lines, a few smaller ones, and prose between them. Its words mean +nothing; its shape is what the tests need (row culling inside a table, block skipping around it). + +## Section 1 + +Culling value block text width reader fizzy editor data value viewport. Wrap value dvui drag fizzy measure measure text paint data paragraph. Field culling editor cell sample value section reader item list reader surface sample settle. Plugin value word text reader entry heading viewport width stable position field data. List data paragraph view wrap drag viewport heading line wrap heading window table. + +## Section 2 + +List record dvui record viewport reader row record span editor data surface word width. Budget dvui scroll glyph data sash row table paragraph offset text sample. Reader frame reflow window sample width line view block. Preview word cell header sash paragraph budget paint paint document span position document glyph. Fizzy data plugin document entry section render line block cell budget word surface entry. ---- +## Section 3 -## How to use this document - -1. Read **Locked decisions** before coding. -2. Work phases in order (R1→R6); do not start mid-phase without its prerequisites. -3. When a phase lands, flip its status and note what changed. -4. Keep `docs/PLUGINS.md` / `CLAUDE.md` updates in R6 (or earlier if docs would otherwise lie). - -| Phase | Status | Notes | -|-------|--------|-------| -| R1 — Rewrite living plan | done | 2026-07-20 — this document | -| R2 — Identity-only zon + no sidecars | done | 2026-07-20 — built-in `plugin.zig.zon`s slimmed to identity; `Manifest`/`readManifest` identity-only; `Host` enforcement (`checkContribution`/`checkCapability`/`pending_manifest`/`plugin_manifests`/`manifest_violation`/`bindManifest`/`manifestFor`/`takeManifestViolation`) removed; `install()`/`installBuiltinPlugin` no longer emit an on-disk `.zon` sidecar. sdk 0.33.0 (fingerprint moved — see version.zig's changelog — because removing `Host` struct fields, unlike 0.32.0 adding data-pointer-valued ones, does change the shape hash). | -| R3 — Fizzy config JSON → ZON | done | 2026-07-20 — `Recents.zig`/`Settings.zig` load/save via `std.zon.parse`/`std.zon.stringify` against `recents.zon`/`settings.zon`; one-shot `RecentsMigration.zig`/`SettingsMigration.zig` (json→zon, delete json, isolate all remaining `std.json` usage on these paths); `Settings.loaded: ?Disk` replaces `parsed: ?std.json.Parsed(Settings)`; `Editor.zig` paths + `settings_last_saved` renamed; `Host.storePluginSettings`/`loadPluginSettings` docs + `Plugin.VTable.settingsChanged` param already spoke of zon blobs (no signature/fingerprint change — `[]const u8` throughout); text plugin `Settings.zig` already zon-based. Fix: `Settings.Disk` (17 fields) blew the default 1000-branch eval quota in `std.zon.parse`'s generic `parseStruct`/`parseExprInner` instantiation — added `@setEvalBranchQuota(10_000)` to `Settings.load`/`loadPluginStore`/`serialize`. Verified: `zig build`, `zig build test`, `zig build test-sdk-version` all clean after `rm -rf .zig-cache`. | -| R4 — Comptime settings API + embed + probe | done | 2026-07-20 — **loaded-only** + **fizzy-drawn UI**. `sdk.settings.make(T)` → `Field`/`Schema`/`Access`; plugins register `.title` + `.value` only (no `draw`). `PluginSettingsPane` renders shared controls. Disabled plugins: Enabled toggle only. `text` migrated (`tab_size` as enum for dropdown). sdk **0.35.0** (fingerprint `0x8f96eaa903ae417c`). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`. | -| R5 — Store + CI SoT | done | 2026-07-20 — PluginLoader probes embedded `fizzy_plugin_manifest_zon` for disabled-plugin name/version (fallback to C exports); PluginStore docs updated; plugin-build-action README/example describe `plugin.zig.zon` as CI SoT (tag == `.version`). Action still takes workflow inputs until a follow-up reads zon directly. | -| R6 — Docs + external migration | done | 2026-07-20 — PLUGINS.md §2/settings/CI + CLAUDE.md updated. External repos zig/json/ghostty/pixi reshaped locally against `../../fizzy` path pin (sdk 0.35); switch back to URL+hash when fizzy 0.35 lands on a publishable commit. | -| R7 — `sdk-v*` tag namespace | done | 2026-07-20 — legible, auto-maintained pin point for the `fizzy` dependency in a plugin's `build.zig.zon`, replacing "pin an arbitrary commit SHA." See below. | -| R9 — Built-ins read identity from `plugin.zig.zon` | done | 2026-07-22 — every built-in's `plugin.zig` (`plugin_id`, `display_name`) now reads `pub const plugin_options = @import("fizzy_plugin_options")` (the build-injected identity module, see `plugins/shared/build/helpers.zig`'s `pluginOptions`) instead of duplicating the id/name as string literals also present in `plugin.zig.zon`. This module was previously wired only for the dylib link mode; `addStaticModule` (all 4 built-ins' `static/integration.zig`) now attaches it too, via `pluginOptionsFor` — a per-manifest-path memoization in `helpers.zig` so a plugin's static and dylib builds, which each call `addStaticModule`/`addDylib` independently in the same `zig build` graph, don't redundantly re-read the manifest. **Build-graph bug found + fixed** (both `helpers.zig` and `plugin_sdk.zig`, the third-party path): once `plugin.zig` actually references `@import("fizzy_plugin_options")` and that same file is compiled into more than one module rooted differently in the same graph, Zig refuses a second attachment of the identity options step to the generated dylib root ("file exists in modules 'fizzy_plugin_options' and 'fizzy_plugin_options0'") — regardless of naming or object sharing. Fixed in both `generatedDylibRoot`s by reaching identity through `plugin_impl`'s own `pub const plugin_options` export instead of importing the module a second time on the generated root. **This makes `pub const plugin_options = @import("fizzy_plugin_options");` a required declaration for every plugin using `fizzy.plugin.create`** (documented in `docs/PLUGINS.md` §2.5) — a deliberate SDK-contract change; `pixi`/`ghostty`/`zig`/`json` (`~/dev/fizzyedit/*`) were updated to add it (mechanical: `.id`/`.display_name` now read `plugin_options.id`/`.name` instead of hardcoded strings, matching the built-ins). **Separately found + fixed a real segfault**, surfaced only once these external plugins could load again for the first time against this session's earlier `Host` changes: `Host.loadPluginSettings` read the settings file directly using `dvui.io` — a `pub var` dvui global, freshly `undefined` in *each separately-compiled dylib* until the host syncs it (`dvui_context.zig`), which only happens around draw/tick, not before `register()`. Since `sdk.settings.make(T).load` calls `loadPluginSettings` from within `register()`, and that call executes using the *calling* dylib's own compiled copy of `Host.loadPluginSettings` (not the host's), any dynamically-loaded plugin reading its settings at register time dereferenced the still-undefined `dvui.io` and crashed — invisible until today because dynamically-loaded plugins had been rejected by an unrelated `AbiMismatch` (stale dylibs) up to this point, so `register()` had never actually run against the new `Host` shape for any of them. Fixed the same way this codebase already fixes this exact class of bug for `drawMenuItem`: added `EditorAPI.VTable.loadPluginSettingsFile` (implemented in `Editor.zig` as `fizzyLoadPluginSettingsFile`, which always executes as the host's own compiled code regardless of which dylib triggered the call) and had `Host.loadPluginSettings` forward to it instead of touching the file directly. `flushPluginSettings` was checked and confirmed safe as-is (only ever called from `Editor.zig`, never from a plugin's own code path, so it always already runs as the host). sdk **0.1.37** (fingerprint `0xbde63b4971975fd5` — `EditorAPI.VTable` gained a field). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` clean on a fully cleared `.zig-cache`; standalone builds of `text`/`image`/`workbench` (root `zig build` too) all clean; all 4 external plugins rebuilt clean against the new pin; a live run confirmed `pixi`/`ghostty`/`zig`/`json` all load successfully with no crash (previously segfaulted in `pixi`'s `register()`). **Known remaining gap, unrelated to any of the above:** the standalone `cd plugins/markdown && zig build` hits a distinct, pre-existing `root.@build`/`root.@dependencies` module-graph conflict — not investigated, not caused by this work. | -| R8 — Per-plugin settings files | **superseded by R10** | 2026-07-22 — replaced the escaped-zon-text-in-a-list-in-settings.zon design with one real `/.settings.zon` file per plugin (`{config}/plugins/{id}.settings.zon`, beside `{id}.{dylib,so,dll}`). `Host.loadPluginSettings` reads the file fresh (no cache — only ever called once, at `register()`); `storePluginSettings` buffers into `Host.plugin_settings_pending` and rides the existing debounced-autosave timer via `markSettingsDirty`; `Host.flushPluginSettings` (called from `Editor.saveSettingsGuarded`/`saveSettingsRaw`) writes the buffer out. `Host.plugins_dir` set once in `Editor.init`, null on wasm/headless (no-op settings load/store, matching "no filesystem" elsewhere). `Settings.zig`'s `Disk`/`Extend`/`PluginEntry` machinery is gone — `settings.zon` now serializes `Settings` directly, no per-plugin blobs at all. One-shot `SettingsMigration.splitEmbeddedPluginsIfNeeded` splits a pre-R8 settings.zon's embedded `plugins` list out to per-id files (skips an id whose file already exists, so it never clobbers a live edit); the legacy-JSON migration path writes straight to per-id files instead of embedding. sdk **0.1.36** (fingerprint `0x9fa106c6800c1fc2` — `Host` field shape changed). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`, standalone `plugins/text` build, and a live run confirming the split + `.settings.zon` files land correctly. **2026-07-24: this whole one-file-per-plugin design was reverted — see R10.** | -| R10 — Merged settings.zon + per-plugin install directories | done | 2026-07-24 — **(1) Merged settings storage**, superseding R8: every plugin's settings now live as a real nested ZON struct literal — `.plugins = .{ . = .{...}, ... }` — inside fizzy's own `/settings.zon`, not one file per plugin. New `src/editor/SettingsPluginsZon.zig` does the ZON text surgery using `std.zig.Ast`/`std.zig.ZonGen`/`std.zig.Zoir` (the same machinery `std.zon.parse` uses internally, all public) to locate a named struct field's exact source byte-span *without* re-serializing the rest of the document: `extractPluginBlob` (single-id lookup, the read path), `listPluginBlocks` (enumerates *every* id currently on disk — including a disabled or even uninstalled plugin's, so its settings ride along unchanged in every save, matching the pre-existing "uninstall only deletes the dylib, settings persist" behavior), `composeMergedText` (rebuild `.plugins` from `listPluginBlocks` + an overlay, for the normal write path), and `upsertOne` (surgical single-id insert into an arbitrary existing document, used only by the legacy-file migration, which runs before a parsed `Settings` value exists to regenerate fizzy fields from). `Host.loadPluginSettings(id) -> ?[]u8` keeps its exact signature/behavior (still routed through `fizzy_api` for the same dylib-`dvui.io`-not-synced-yet reason as before) — only *where* the blob comes from changed, so `sdk/settings.zig`'s `Schema(T).load/applyZon` needed zero changes. Write side: `Host.flushPluginSettings` (per-id disk writes) is gone, replaced by `Host.takePendingPluginSettings` (moves the pending buffer out, no disk IO) + `Host.plugin_settings_last_hash`/`recordSettingsHash` (removed — redundant once one whole-file hash (`Editor.settings_last_saved_hash`) already dedupes the single merged write). `Editor.writeMergedSettings`/`composeSettingsText` collapse the old two independent writes (`Host.flushPluginSettings` + `Settings.save`) into one: writing them independently would let whichever ran second silently drop the other's data, since `Settings.serialize` only ever knows fizzy's own fields. `Settings.save` (the old standalone fizzy-only writer) is deleted — `writeMergedSettings` is now the only writer of `settings.zon`. Migration: `SettingsMigration.zig` rewritten to just `mergeLegacyPerPluginFiles` (upserts each on-disk `/.settings.zon` into `settings.zon` via `upsertOne`, never clobbering an id already merged, then deletes the old file); the pre-R7 JSON/embedded-blob migration paths were deleted outright rather than carried forward (explicit user decision — not worth keeping working for installs that predate R8). **(2) Per-plugin install directories**: `{config}/plugins/{id}.{ext}` → `{config}/plugins/{id}/{id}.{ext}}`, and the same nesting for exe-bundled built-ins (`{exe_dir}/plugins/{id}/{id}.{ext}`, via `plugin_sdk.zig`'s `installBuiltinPlugin`) — every plugin, built-in or third-party, gets its own directory it can use for its own assets/data (new `Host.pluginInstallDir(id) -> ?[]u8`, a pure path join, no filesystem access). `Editor.loadUserPlugins` migrates any pre-R10 flat `{id}.{ext}` into its own directory once at startup (collects the list before mutating, so it never races its own directory iterator); `uninstallPlugin` now deletes the plugin's whole directory (`deleteTree`), not just its dylib — its settings still survive uninstall, per the same precedent as R8. sdk **0.1.40** (fingerprint `0x89466e672ed8dbb5` — `Host` lost `plugin_settings_last_hash`/`pluginSettingsPath`/`flushPluginSettings`, gained `takePendingPluginSettings`/`pluginInstallDir`). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` clean on a fully cleared `.zig-cache`; `SettingsPluginsZon`'s own unit tests (extract/compose/upsert, including round-trips and "never clobbers an existing id"). **Known gap, intentionally deferred — see R11.** | -| R12 — Non-default-only persistence + per-plugin enabled + nightwatch | done | 2026-07-24 — three interlocking follow-ups to R10/R11. **(A) Non-default-only persistence:** `sdk.settings.Schema(T)` now requires every field of `T` to declare a default, diffs against `const default_value: T = .{}`, and `diffSerialize`s only the fields that differ (all-default → `Host.removePluginSettings` / no blob). `Host.PluginSettings` is `StringArrayHashMapUnmanaged(?[]const u8)` (`null` = remove this id's `.settings`); `SettingsPluginsZon.Entry.text` is likewise optional, and `composeMergedText` deletes an id when overlay text is null. `extractPluginBlob` generalized to composable `extractField`. **(B) Per-plugin `.enabled` + nested `.settings`:** on-disk shape is `.plugins. = .{ .enabled = true, .settings = .{…} }` (`.enabled` omitted when false, per non-default-only). Retires fizzy-level `Settings.disabled_plugins` entirely; runtime `disabled_plugin_ids` remains as UI/skip-load bookkeeping only. `Editor.writeMergedSettings` merges two pending buffers (`host.plugin_settings_pending` + `editor.plugin_enabled_pending`) into each id's final block; disabled + no settings → remove the whole id entry. Store installs write `.enabled = true` immediately; a dylib dropped straight into `plugins/` is tracked as disabled with no settings.zon write. One-shot `SettingsMigration.migrateToPerPluginEnabled` rewrites flat pre-R12 blocks + top-level `disabled_plugins`. **(C) Adopt nightwatch:** deleted hand-rolled `SettingsWatcher/{Macos,Linux,Windows}.zig`; `SettingsWatcher.zig` is a thin adapter over `nightwatch.Default` (lazy dep, app-only, same Velopack pattern). One recursive `watch(config_folder)` covers settings.zon + newly-created plugin dirs; ~200ms coalesce moved to main-thread `tick`. sdk fingerprint unchanged (Host field shape already optional-blob from mid-R12; no new boundary members). Verified: `zig build`/`test`/`test-sdk-version`/`check-web`, SettingsPluginsZon + Schema diff-serialize unit tests, cross-compiles `x86_64-linux-gnu`/`x86_64-windows-gnu`. Live macOS sandbox test left for the user (same isolated `HOME`/`TMPDIR` as R11). | -| R11 — File watcher + hand-edit reconciliation | done | 2026-07-24 — closed the gap R10 deliberately left open: `settings.zon` is now watched live and external changes (hand edits, another tool) are reconciled instead of being silently clobbered by the next autosave. New `src/editor/SettingsWatcher.zig` + one backend per OS (`SettingsWatcher/{Macos,Linux,Windows}.zig`), dispatched by `builtin.os.tag` (mirroring `PluginLoader.zig`'s `DynLib`/`WindowsDynLib` split): **macOS** — `std.c.kqueue`/`kevent` with `EVFILT.VNODE` on the parent directory (survives an editor's write-temp+rename replace) + `EVFILT.USER`/`NOTE.TRIGGER` for cross-thread cancellation, no `dlopen` needed (unlike the FSEvents route `std.Build.Watch` — what `zig build --watch` uses — takes for its own whole-project-tree case). **Linux** — `inotify_init1`/`inotify_add_watch` (not fanotify: that needs a 5.17+ kernel and is scoped for whole-filesystem watching) filtered by filename, plus a `pipe2` self-pipe for cancellation, multiplexed via `std.posix.poll`. **Windows** — overlapped `ReadDirectoryChangesW` + a manual-reset completion event + a second manual-reset cancellation event, waited on together via `WaitForMultipleObjects`; `std.os.windows` doesn't wrap `ReadDirectoryChangesW`/`CreateEventW`/`WaitForMultipleObjects`/`GetOverlappedResult`/`SetEvent` in this Zig version (only the lower-level NT-native calls `std.Build.Watch` itself uses, which need an APC-pump loop tied to its own runtime) — declared directly via `extern "kernel32"`, the same way `PluginLoader.zig`'s `WindowsDynLib` already declares `LoadLibraryW`/`GetProcAddress`/`FreeLibrary`. A single OS-agnostic debounce/dispatch loop (`SettingsWatcher.run`, generic over the per-OS `Impl` type so it's unit-testable with a scripted fake and no real filesystem) coalesces a burst of raw events (~200ms) before reacting, and tolerates a backend reporting a spurious/unmatched wake during an otherwise-infinite wait (neither macOS's directory-level nor Windows' unfiltered route can perfectly filter to just this one file) by just continuing to wait rather than treating it as a real change — the hash-gate below makes a false-positive wake harmless regardless. The watcher thread itself never touches file content, `dvui.io`, or an allocator shared with the main thread — it only flips an atomic `dirty` flag and calls `fizzy.app.window.backend.refresh()` (the same thread-safe wake `Editor.zig`'s `fizzyRefresh` already uses) to nudge the app; all real work happens on the main thread from `Editor.tick` (`SettingsWatcher.tick` → `Editor.reconcileExternalSettingsChange`, called once per frame, cheap no-op unless the flag is set). Started in `postInit` (not `init` — needs `editor` at its final heap address, since the thread captures `&editor.settings_watcher`; same reason `postInit` exists as a separate step at all), stopped first in `deinit`, best-effort throughout (a failed start just logs a warning — fizzy must never fail to launch over this).

**Reconciliation** (`Editor.reconcileExternalSettingsChange`): reads `settings.zon` fresh, hashes it, and returns immediately if the hash matches `settings_last_saved_hash` — exactly the seam R10 left, doubling as "was that write ours" with no new state. Parses via new `Settings.parseOnly` (factored out of `Settings.load`), which — unlike `load` — does **not** fall back to defaults on a parse failure (a torn/partial read from mid-write must never reset every fizzy field and every plugin's settings; left unreconciled until another external change retriggers a read, which the debounce already makes rare). Fizzy fields are copied one at a time rather than `editor.settings = parsed` wholesale, since `theme` and `disabled_plugins` are runtime-owned and a whole-struct assign would clobber them; the existing idempotent appliers (`applySettingsTheme`/`applyFontSizesFromSettings`/`applyHoldMenuDuration`) are re-run unconditionally afterward. `disabled_plugins` is diffed against the live `disabled_plugin_ids` and reconciled through the **existing** `setPluginEnabled(id, enabled, force=false)` — reusing its dirty-document guard as-is: a disable blocked by an open unsaved document is skipped (logged, not retried automatically) and a built-in plugin correctly can't be disabled at all (`isBundledPluginId` → `NotUnloadable`, confirmed live in testing below), both exactly matching the Plugins-tab toggle's existing behavior. Per-plugin settings reconciliation needed one new SDK surface: `sdk.settings.Access` gained `applyBlob` (parse a blob into the live value + call `settingsChanged`, the read-direction counterpart to the existing `persist`) and `Host.SettingsSchema` gained `last_applied_hash: u64` (seeded at `register()` from whatever `loadPluginSettings` first returned) — without the latter, any change anywhere in the file would spuriously re-notify *every* loaded plugin, not just the one whose block actually changed; `Plugin.VTable.settingsChanged`'s doc comment now notes it fires from external reconciliation too, not just an in-app settings-pane edit. sdk **0.1.41** (fingerprint `0xefa9e8fc377322f` — `Access` gained a member, `SettingsSchema` gained a field).

**Verified:** `zig build`/`test`/`test-sdk-version`/`check-web` clean on a cleared `.zig-cache`, plus cross-compiles for `x86_64-linux-gnu` and `x86_64-windows-gnu` (build-only — behavior on those two platforms is not yet verified on real hardware). New `SettingsWatcher.run` unit tests (scripted fake `Impl`: settle-then-wake, immediate stop, two separate bursts, a spurious mid-wait timeout that must not be mistaken for a real event). **Live end-to-end test on macOS**, an isolated `HOME`/`TMPDIR` sandbox (avoids touching the real user config or an already-running instance — `TMPDIR` must stay short, a Unix socket path limit): hand-editing `settings.zon` in an external editor while fizzy ran live-applied a plugin's own setting (`text`'s `tab_size`, through the new `applyBlob` path) with a log line per change; a second save with byte-identical content produced no further reconciliation (hash-gate confirmed); attempting to disable a built-in plugin via the `disabled_plugins` list correctly failed loud (`NotUnloadable`) rather than corrupting state; no feedback loop or spurious reconcile storm from fizzy's own quiescent state. **Bug caught during this pass, fixed before landing:** the "enable" half of `reconcileDisabledPlugins` originally logged `id` *after* calling `setPluginEnabled(id, true, false)` — but that call's `setDisabledPersisted` step frees `id`'s backing memory (an entry removed from `disabled_plugin_ids`) regardless of whether the subsequent load then succeeds, so the log statement was a use-after-free; fixed by duping `id` before the call. **Known, accepted limitation, not a bug:** if a plugin's `.plugins.` blob is well-formed ZON but doesn't match that plugin's own settings type (e.g. a wrong enum literal), `Schema(T).applyZon`'s inner parse fails and the value is left unchanged, but `last_applied_hash` is still recorded against that (unapplied) blob — mirrors the same "don't retry a torn write forever, wait for the next real change" acceptance already made for the whole-file parse failure case above, rather than plumbing a success/failure signal back out of the `Access.applyBlob` vtable call. **Not done:** on-device testing on Linux and Windows (cross-compiled clean, not run). | -| R13 — Fizzy fields persist non-default-only too | done | 2026-07-26 — R12's non-default-only rule applied to the *other* half of `settings.zon`: `Settings.serialize` now diffs every fizzy field against `const default_value: Settings = .{}` and emits only what differs (an untouched config serializes to `.{}`, which `composeMergedText` already splices `.plugins` onto — covered by a new unit test). A field hand-written back at its default drops out on the next write, matching plugin blocks. One ownership consequence: with fields now routinely absent from disk, `std.zon.parse` fills them from the struct's declared defaults — and `theme`'s default is a comptime string literal, so the old `std.zon.parse.free(gpa, parsed)` calls would have handed a non-allocated pointer to the allocator. New `Settings.freeParsed` zeroes that field before the whole-struct free (`Allocator.free` early-returns on an empty slice) and is used at all three parse sites; `Settings`' process-lifetime `loaded` snapshot is gone with it, since `load` can now free its parse immediately after duping `theme`. No SDK/fingerprint change (fizzy-only). Verified: `zig build`/`test`/`check-web`, plus a live macOS run in an isolated `HOME`/`TMPDIR` sandbox — an all-defaults-spelled-out file migrated down to just `.content_opacity`, a hand-edited `.font_body_size` at its default was dropped on the reconcile-triggered rewrite while a non-default `.theme` survived, and a clean quit showed no invalid free. | -| R14 — String settings ownership rule | done | 2026-07-26 — the plugin-side half of the same hazard R13 fixed for fizzy, found while doing R13 and fixed before a plugin could hit it. `Schema(T).applyZon` used to `std.zon.parse.free` the whole previous value, so **any plugin with a `[]const u8` setting whose default is non-empty would have invalid-freed a string literal on its very first `load()`** — and again on every reconcile, since R12 omits default fields from disk and `std.zon.parse` fills those from the same literals. Now one explicit rule, stated once and used everywhere: *a string field's bytes are schema-owned (host allocator) unless the slice is exactly `T`'s declared default*, tested by pointer identity (`freeOwned`/`fieldIsOwned`). Persistence comparisons stay content-based, so an allocated copy equal to the default still prunes out of settings.zon. Consequences: new `Schema(T).deinit(value)` for plugins to call on teardown (no-op for scalar-only structs, documented in `docs/PLUGINS.md` §3.1.1); `Access.setString` implemented at last (dupe + release the old value under the same rule), which retires the "read-only until setString owns allocation policy" TODO in `PluginSettingsPane` — string settings now draw an editable text entry (commit on Enter; re-seeds from the live value while unfocused, so an abandoned edit reverts and an externally reconciled change shows up without a pane-side notification). No SDK boundary change — `Access` already had the member, fingerprint/`test-sdk-version` unchanged. Verified: `zig build`/`test-all`/`check-web`/`test-sdk-version`; new `Schema` unit tests under `test-integration` (`testing.allocator` catches both the invalid free and a leak) — note these live in the *integration* layer, `zig build test` alone does not run them; plus a live macOS sandbox run with a temporary string setting added to `text`: loaded from disk at register, hand-edited twice (allocated → allocated → back to the default), no crash, block correctly pruned when the value returned to its default. | -| R13 follow-up — shutdown-write use-after-free | done | 2026-07-26 — R13's first real run panicked (`incorrect alignment` in `std.hash_map`) at quit. Pre-existing latent bug in `Editor.deinit`, surfaced by R13: `deinit` stops the watchers first (correct), but `stop()` *is* the watcher's teardown — `std.HashMapUnmanaged.deinit` leaves the maps `undefined` — and `deinit` then goes on to call `saveSettingsRaw` → `writeMergedSettings` → `notifyPathChanged` → `by_path.get` on that dead map. It only bites when the shutdown write actually happens (`writeMergedSettings` returns early on an unchanged hash), which is why it survived until R13 made "the composed text differs from what's on disk" the common case. Fix: `Editor.deinit` clears `document_watcher`/`settings_watcher` after stopping them (clearing is part of stopping, not tidiness — it also prevents `SettingsWatcher.stop`'s `config_folder` double-free), plus `DocumentWatcher.stop` now resets its maps to `.empty` instead of leaving them undefined, making a late query an empty-map miss rather than garbage. A/B verified in the macOS sandbox: same seeded config + edit-then-quit-inside-the-debounce sequence panics with the fix stashed and quits cleanly with it applied, writing the normalized file either way. | -| R15 — Self-describing setting cells + VSCode-shaped rows | done | 2026-07-28 — a settings struct field is no longer a bare `bool`/`u8`/enum but a `sdk.settings.Value(T, .{ .description = … }) = .init(default)` **cell**: payload type, default, and a **required** description, all comptime (metadata lives in the type, so `@sizeOf(Value(T, …)) == @sizeOf(T)` and only the payload is ever stored). Omitting the description is a compile error at the declaration site — the point being that the pane now has a permanent place for it, and no fizzy-side table of strings can drift from the plugin that owns the setting. `Setting` gained `description` and a real `label` (derived from the field name in sentence case, `insert_spaces_on_tab` → `Insert spaces on tab`, overridable via `Options.name`); `key` stays the field/zon name. **On-disk format is unchanged**: everything persistence touches goes through a new comptime `Plain(T)` mirror (built with 0.16's `@Struct`) that has the same field names but bare payload types and the cells' defaults, so `applyZon`/`diffSerialize`/`fullSerialize` still read and write `.{ .tab_size = 8 }` exactly as before — no migration, and R11 reconciliation / R12 diff-only persistence needed no changes. **Any type is now a legal setting**: `kindFor` no longer `@compileError`s on an unsupported type, it returns the new `TypeTag.other`, and `Access` gained `getZonText`/`setZonText` so the pane can draw such a value as editable zon text (a parse failure simply doesn't commit — the entry re-seeds from the live value). **UI** (`src/editor/SettingRow.zig`, new): one row drawer shared by fizzy and plugin settings so they cannot drift — bold name, dim mono key underneath, wrapped description, then the full-width control (VSCode's order), with booleans the one exception (plain `dvui.checkbox` *before* the description on one line, since a full-width control for two states is all frame and no information). The short-lived full-width toggle button in `core.dvui` was deleted — this replaced it. Search (`SettingsTree.scoreLeaf`) now scores the zon key alongside the label/path/keywords, and both name and key are highlight-rendered, so a row that survived on a key match can show why. Fizzy's own settings (`explorer/settings.zig`) grew required `key`/`description` fields (plus `inline_control`) and all 11 items were filled in, keeping fizzy and plugin rows identical. sdk **0.1.44** (fingerprint `0xf5802c523f9bbc82` — `Setting` gained a field, `Kind`/`TypeTag` gained `other`, `Access` gained two members). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build test-integration` (23 SDK tests, incl. new cases for label derivation, `Options` bound refinement, the `.other` zon round-trip, and a diff-serialize assertion that the on-disk shape has no `.v` in it — confirmed actually executing via `-Dtest-filter`), and the text plugin's standalone `cd plugins/text && zig build`. | -| R18 — publisher/author split + probe consolidation | done | 2026-07-30 — attribution was a single hand-typed `registry/.json` `author` string with **no fallback and no validation**, which had already silently drifted: `pixi`/`ghostty`/`zig` all read `"author": "foxnne"` while their `homepage` pointed at the `fizzyedit` org, and nothing cross-checks the two. Split into the two claims that were being conflated. **`publisher`** — derived at ingest by `ingest.publisherFromUrl` from `manifest_url`'s GitHub owner, i.e. from where the binary is actually served; not writable by any plugin, null for a self-hosted manifest the heuristic can't attribute (the store then shows the author alone rather than inventing one). New `plugins.publisher` column + `summary.json` field + client `SummaryEntry.publisher`. **`author`/`author_url`** — new *cosmetic* `Manifest` fields in `plugin.zig.zon`, self-asserted, with the usual registry→builtin→probe fallback; `author_url` rides the catalog too so an *uninstalled* store plugin's credit is still clickable (no local dylib to probe yet). Rendered by `drawAuthorLine` as `publisher · author`, each linked only where there's somewhere real to go — publisher's link is built from the publisher name itself, never from an author-supplied URL. **Security:** `author_url` reaches `dvui.openURL` → the OS URL handler, so `isSafeExternalUrl` restricts it to `http`/`https`; a `file:`/custom-scheme URL in a store-listed manifest would otherwise launch an arbitrary registered handler. **Probe consolidation (the reason this stayed small):** adding two more fields would have meant a 4th and 5th `probeX` + parallel cache, each re-`dlopen`ing the *same* embedded zon. Replaced `probeDescription`/`probeTags` with one `PluginLoader.probeManifestInfo` → `ProbedManifest`, `builtinDescription`/`builtinTags` with one `Editor.builtinManifest`, and `description_cache`+`tags_cache` with one `manifest_cache` — one dlopen + parse now serves description/tags/author/author_url. **Also fixed, found while doing this:** `sdk.manifest.parse` used strict `std.zon.parse`, so **every** field ever added to `Manifest` was a breaking change in two directions — a plugin declaring a newer field would fail to *build* against an older pinned SDK, and an older fizzy probing a newer plugin's embedded manifest would lose data it could otherwise read. Now `ignore_unknown_fields = true`, making the format forward-compatible by construction (tradeoff: a misspelled field is ignored, not diagnosed). Pipeline: `read_plugin_zon.py`/`assemble_manifest.py`/`build.yml` carry `author`/`author_url` (env-routed, not `${{ }}`-interpolated); `pixi`/`ghostty`/`zig` `plugin.zig.zon`s updated and probe-verified out of their rebuilt dylibs. Verified: fizzy `zig build`/`test`/`test-sdk-version`/`check-web`/`test-integration`, store `zig build`/`test` (new `publisherFromUrl` tests confirmed executing via a deliberate-failure check), all three external plugins rebuilt. **Not verified:** the rendered `publisher · author` line and its links — UI, needs eyes on it. **Registry note:** `registry.db` is committed and `CREATE TABLE IF NOT EXISTS` won't alter it, so `db.migrate` gained best-effort `ALTER TABLE … ADD COLUMN` calls for `publisher`/`author_url`. | -| R19 — Explicit file-type ownership, replacing numeric priority | done | 2026-09-03 — which plugin opens an extension was a silent numeric contest between plugin authors: `Plugin.VTable.fileTypePriority(ext) -> ?u8`, lowest number wins, `text` returning `file_type_fallback_priority` (100) for everything and `image` 99 for flat images. Nothing stopped a third-party plugin from claiming a low number for an extension it had no business monopolizing (pixi's `90` for `.png` silently outranked the builtin `image` plugin's `99`), and the user had no way to see or override the outcome — nor any say when a brand-new plugin claimed a type previously served only by the `text` fallback. **Numeric priority is removed outright, not deprecated.** **(A) SDK:** `fileTypePriority` and `file_type_fallback_priority` deleted; new `fileTypes(state) -> []const []const u8` is a plugin's static, enumerable *offer* of extensions (absent/empty = offers nothing, which is what `workbench`/`markdown`/language-only plugins are). Being the catch-all editor is not vtable or struct data — it is an opt-in `Host.registerFallbackEditor(plugin)` call (mirroring `registerFileIcon`/`registerPluginIcon`), because the concept is meaningless to every plugin author except `text`'s; cleared in `unregisterPlugin` like every other owned registry entry. `Host.pluginForExtension` rewritten to a fully deterministic non-numeric resolution — persisted user assignment (validated against the owner's *current* `fileTypes`, via the new non-optional `EditorAPI.extensionOwnerOverride`) → unique claimant (alphabetical tie-break on plugin id, so the result never depends on dylib load/scan order) → fallback editor; new `Host.ownsExtension` is the shared validity check, and `drawFileIcon`'s "specialized owner" step becomes `p != fallback_editor`. **(B) Persistence:** new fizzy-reserved `extensions: []const []const u8` on `SettingsPluginsZon.Reserved`, beside `enabled`/`auto_update` and *not* under `.settings`. Deliberately per-plugin rather than one central `ext → id` table: every existing use of a plugin id in `settings.zon` is a **key** (`.plugins.`), never a value, so identity stays structural — a central table would introduce a stringly-typed cross-reference nothing validates or keeps in sync. It may legitimately name an extension the plugin does not offer: that is exactly what "keep Plain Text for `.foo`" records on the fallback editor's own block. `writeMergedSettings`' all-default drop rule treats a non-empty `extensions` as "keep this id", so an assignment survives even while its plugin is disabled. **(C) Read-only reconciliation:** `Editor.rebuildExtensionOwnerCache` interprets the persisted lists of *loaded* plugins into an Editor-owned `extension_owner` cache (duped strings only — a raw `plugin.id` slice would dangle through `dlclose`) and **never writes**, because `settings.zon` is watched live (R11) and a self-heal on the load path would race that watcher instead of composing with it. Two on-disk states are interpreted, not corrected, and both are reported as `Editor.ExtensionConflict` (`.stale` — the plugin no longer offers it, so the entry is ignored; `.duplicate` — two plugins claim it, alphabetically-first wins), surfaced as a banner plus flagged rows exactly as `Keymap.conflicts()` is in the Keyboard Shortcuts pane. Only reachable from a hand-edited or hand-merged file: `Editor.resolveExtensionConflict` is the app's single writer and always strips the extension from every other block (loaded or not) before adding it. **(D) Trigger scope, not trigger memory:** the conflict dialog (`src/editor/dialogs/FileTypeDefaults.zig`, modeled on `PluginUpdates.zig`) is called *only* from `installAndLoadPlugin`/`updatePlugin`, never from the startup load path. That scoping is the entire anti-nag mechanism — a considered `seen_file_types` field was **rejected**, because such a record cannot distinguish "nothing changed since we asked" from "a *different* new plugin now also wants this extension", which is precisely the case that must still prompt. Dismissing writes nothing and recomputes fresh next install/update. Takeover is unchecked by default; an extension only the fallback editor was opening is pre-checked. **(E) Settings › File Types:** new `src/editor/FileTypeSettings.zig`, one flat `dvui.grid` (not nested per-plugin — the data is extension-keyed), registered through `explorer/settings.zig`'s `.search = .{ .score, .draw }` hook like `KeybindSettings`, same `max_size_content = .width(0)` width-ratchet discipline. **(F) Open documents on reassignment:** reassigning never touches open docs — routing is by `DocHandle.owner`, not re-derived from the path — so they keep their original owner and keep working; `staleOpenDocsForExtension` + `reopenDocsUnderCurrentOwner` back a per-row "Reopen N" action that reopens clean docs and *skips and counts* dirty ones, since a file-type preference must never discard unsaved work. Plugin *removal* needed nothing new: `unloadPlugin` already closes owned docs behind a `DirtyDocuments` guard. sdk **0.1.51** (fingerprint `0x277348da3a39cd1a` — vtable member swapped, `Host` gained a field, `EditorAPI` gained a member). **Gotchas:** `.extensions` composes as a plain ZON tuple `.{ ".png" }` — `&.{...}` is Zig syntax `Zoir` will not parse, so `extractField` could never read it back (caught by a round-trip test); and `writeMergedSettings` only composes ids in its `touched` set, so a *strip* must also enter `plugin_extensions_pending` or it is silently dropped. Verified: `zig build`/`test`/`test-sdk-version`/`check-web`/`test-integration`, new `Host` resolution tests (assigned / stale / not-loaded / unique-claimant / fallback / tie-break / fallback cleared on unregister) and `SettingsPluginsZon` compose+extract round-trips. All four external plugins (`pixi`, `zig`, `ghostty`, `atlas`) rebuilt against 0.1.51 on a temporary `.path = "../../fizzy/sdk"` pin — **restore the release-asset URL once sdk-v0.1.51 ships**; only `pixi` needed a source change (`fileTypePriority` → `fileTypes`). | -| R19 follow-up — dropped-in builds are *undecided*, not *disabled* | done | 2026-09-03 — R12 gave a dylib dropped straight into `plugins/` the right *safety* (never auto-executed, no settings.zon write) but the wrong *presentation*: `reconcileDiscoveredPlugins` filed it in `disabled_plugin_ids`, the same list a plugin the user deliberately switched off lives in, so a locally built plugin (`cd ~/dev/fizzyedit/pixi && zig build install`) came up looking like a settled "off" row whose only affordance was an Enabled checkbox in the installed card's hover flyout — no indication it was new, present, and loadable. `settings.zon` already held the distinction: an explicit choice writes `.plugins..enabled`, a drop-in has **no such field at all**. New three-way `Editor.readPluginEnabledState` (`unset`/`enabled`/`disabled`, with `readPluginEnabled` a wrapper) exposes it, and a new runtime `undecided_plugin_ids` — a subset of `disabled_plugin_ids` meaning "on disk, never been told to run" — is populated by **both** discovery paths (`reconcileDiscoveredPlugins` off the watcher, and `seedPluginFlags` for a build that appeared while fizzy was closed) and dropped by any explicit decision (`setPluginEnabledPersisted`, either direction) or `uninstallPlugin`. `pruneMissingUndecidedPlugins` (run from `reconcileDiscoveredPlugins`, before the walk) drops entries whose directory has since vanished, so nothing advertises a decision the user can no longer make. **Store:** an undecided (or still-unclassified `.on_disk`) entry now gets one primary **Load** button in `drawCardControls` instead of the broken-build repair controls, plus a `not loaded` part in its info line; Load routes through the existing deferred `queueSetEnabled(id, true)`. `reconcileDiscoveredPlugins` calls the new `PluginStore.markDiskScanDirty()` so the card draws with its probed name/version instead of a bare id. The tab's **Refresh** button now also runs `reconcileDiscoveredPlugins` — a manual stand-in for the watcher on a platform or mount where it never fires — and, like the watcher, only *classifies*: Refresh never loads or executes a plugin. **First load prompts:** `setPluginEnabled` captures `first_load = enabled and isPluginUndecided(id)` before persisting and calls `maybeShowFileTypeDialog(id)` after the load, so the first load of a drop-in offers file-type ownership exactly like a store install does. This does **not** weaken `FileTypeDefaults`' no-nag scoping (still never on the plain startup load path): a first load is a genuine install-equivalent event, and a plugin already carrying `.enabled = true` on record — a rebuild of something you already own — is untouched and stays silent. **Rail badge:** `Sidebar.drawOption` draws a highlight-fill dot at the Plugins icon's glyph corner while `Editor.undecidedPluginCount() > 0`, with the count spelled out in the icon's tooltip. Condition-driven like the infobar's app-update dot (no "seen" state): it clears when the last undecided build is loaded, switched off, or removed — not merely when the tab is opened. Fizzy-only; no SDK/fingerprint change. Verified: `zig build`, `zig build check-web`, and a live macOS sandbox run in an isolated `HOME`/`TMPDIR` — creating `plugins/dropin/` while running logged the discovery and wrote nothing to settings.zon, a restart with it present still did not load it, and the rail badge rendered (screenshot-checked at the glyph's top-right). The Load button's own click and the dialog it raises were **not** exercised: no input automation is available in that harness. | -| R19 follow-up 2 — `.enabled` is a tri-state; uninstall is what re-asks | done | 2026-09-03 — the first follow-up leaned on "no `.enabled` field on record" to mean *undecided*, but `composePluginIdBlock` omits `.enabled = false` under the R12 non-default-only rule, so **a deliberately-disabled plugin and a never-seen drop-in were indistinguishable on disk** — a plugin you switched off came back after a restart wearing a Load button and a rail badge. Fixed by making the field genuinely tri-state: `Reserved.enabled` is now `?bool` (`null` = never asked → omitted, `true`/`false` both written out). `false` is deliberately exempt from non-default-only persistence: it is not a default, it is a recorded decision, and the same reasoning exempts such a block from the "composes to a bare `.{}` → drop the id" rule. `writeMergedSettings` seeds `reserved.enabled` from `readPluginEnabledState` rather than `readPluginEnabled`, so writing some *other* part of a block (a plugin's `.settings`, say) can no longer stamp a decision onto a plugin nobody has answered for. **This also answers "which lifecycle events re-ask about file types?"** — the field's *presence* is now the "fizzy has asked about this plugin" record, and the rule is one sentence: *fizzy asks when a plugin arrives, and uninstall is what lets it arrive again.* New `PendingPluginFlags.erase` + `Editor.clearPluginOwnershipRecord`, called only from `uninstallPlugin`, removes `.enabled` and empties `.extensions` while leaving the plugin's own `.settings` (its config, not a fizzy decision — the reinstall-restores-config promise is about that half). `updatePlugin` **no longer calls `maybeShowFileTypeDialog`**: an update is the same plugin already answered for, and prompting on every store update — or every local `zig build` picked up by `reconcileChangedPluginBinaries` — is nagging, not consent. Deliberate gap: an update that starts offering a *new* extension does not prompt; it resolves normally (unique claimant, else fallback) and Settings > File Types is the override. Enable/disable never prompt and never did, and are now durable across restarts. **One-time cost on upgrade:** a plugin disabled under the old scheme has no `.enabled` on disk (and, with no settings/extensions, often no block at all), so fizzy cannot tell it from a drop-in and will offer it once; answering either way writes the field explicitly and settles it. No migration can recover that — the information was never written down. Fizzy-only; no SDK/fingerprint change. Verified: `zig build`, `test`, `test-sdk-version`, `check-web`; the `Reserved`-field exhaustiveness test extended for `?bool` plus a new `composePluginIdBlock records enabled as a tri-state` case; and a live sandbox launch with a hand-written `.enabled = false` confirming the badge stays off. | -| R19 follow-up 3 — file-type dialog is a table of owner dropdowns | done | 2026-09-03 — R19(D)'s first-prompt window was a checkbox list ("take this type from *X*?"), takeover unchecked unless the prior owner was only the `text` fallback. That hid the real question (who should open `.png` *now*) and offered no way to pick a third owner, or to see that Image and Text (fallback) are fizzy's own viewers. `FileTypeDefaults.Row` is now one extension plus every eligible `Choice` (id / display name / builtin), pre-ordered arriving-plugin first, then other loaded plugins, then built-ins, then the fallback editor if it is not already in the list. The window draws a TYPE / OPENS IN / CHANGE TO table; each CHANGE TO cell is a dropdown tagged "Built-in" or "Plugin", defaulting to the arriving plugin. Confirm writes every row through `resolveExtensionConflict` (leaving a row on its current owner is a real decision). Footer copy uses regular text colour rather than a hover-ghost. `Editor.maybeShowFileTypeDialog` builds and owns the strings (dylib pointers would dangle between open and answer). Trigger scope is unchanged from follow-up 2: arrival only, not update/enable/disable/startup. Fizzy-only; no SDK/fingerprint change. Verified: `zig build`, `zig build test`. | -| Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision | -| R16 — Store detail page: VSCode-marketplace-style header + tabs, `description` in `Manifest` | done | 2026-07-30 — the store's center-provider README view (only the center; the sidebar list is untouched) is now a full detail page. **Manifest:** `description: []const u8 = ""` added to `Manifest` (`sdk/src/manifest.zig`) — the identity-only lock from R2 is deliberately relaxed here, since the detail page needs a description for every plugin, not just ones with a registry entry; not part of `sdk_boundary_types` (never crosses the C-ABI boundary, only ever `std.zon.parse`d from `plugin.zig.zon` text), so no SDK version/fingerprint bump. All 4 built-in `plugin.zig.zon`s got real one-liners. **Description resolution** (`PluginStore.descriptionFor`): registry's own (freshest) → `Editor.builtinDescription` (built-ins read their own compiled-in `plugin_options.manifest_zon` directly, no dylib involved) → `PluginLoader.probeDescription` (new, mirrors `probeName`: opens the on-disk dylib, reads the embedded `fizzy_plugin_manifest_zon` export, parses it) for anything else. **Header** (`drawDetailHeader`): logo (same fetch-or-fallback chain the card list uses) + a stacked name (`.heading` font, matching `SettingsTree`'s root-branch style)/id (small dim mono)/author (dim)/description (wrapped) column, with the existing `drawCardControls` (install/update/uninstall) reused as-is, right-justified. **Tabs** (`drawDetailTabs`): a plain two-tab DETAILS/CHANGELOG strip — same selected/unselected color convention every other tab bar in the app uses, but no drag/drop or scroll area (there are only ever two). Reconstructing the selected plugin's `StoreEntry` for the header needed its own helper (`selectedEntry`), since the center provider draws independently of the sidebar's list-building pass and registry data is only valid while the catalog lock is held for that one frame — same acquire/release-per-frame discipline the list already follows. **Background:** the README view's old rounded `sdk.pane_layout.emptyStateCard` (meant for a genuinely empty hint screen) is now `sdk.pane_layout.mainCanvasVbox` — a plain flat fill, the same background every other content pane in the app uses. **CHANGELOG tab** is a placeholder empty state ("Changelog coming soon") — real GitHub Releases fetching (per-release notes) is out of scope for this pass, by explicit choice. **Install counts** are out of scope entirely — there is no backend/analytics service to source them from; revisit once one exists. Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean; a live isolated-`HOME`/`TMPDIR` run rendered the header/tabs/flat-background README correctly end-to-end (ghostty, registry description + README both showing, "No compatible build in store" control state correct for an uninstalled entry). | -| R19 — `wikilink` service + `documentContentChanged` broadcast + markdown/text consumers | done | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `sdk/src/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`).

**Consumers, same pass.** `markdown` now renders wikilinks, and this turned up a real hazard the design had only flagged as a risk: `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` (`blocks.c`), which merges every adjacent TEXT run into one literal — and since `handle_backslash` represents an escape as its own little text node, `\[\[A]]` and `[[A]]` arrive at the renderer as **the same literal**. Tokenizing the literal alone therefore turns deliberately-escaped text into a live link, with nothing in the AST to tell them apart. What survives is position: `make_literal` (`inlines.c`) sets `start_line`/`start_column` unconditionally (no `CMARK_OPT_SOURCEPOS` needed — that option only governs HTML *output*), and consolidation keeps the first fragment's start while extending `end_column`. New `src/md/wikilink_scan.zig` uses that to read the node's original bytes back out of the source, re-applies cmark's own escape rule to produce (bytes, was-escaped) pairs, and — **only when those bytes match the literal exactly** — drops links whose opening brackets were flagged. On any drift (smart punctuation rewrote a quote, an entity expanded) it **fails open** and the link renders: a link that appears where the author wanted literal text is visible and correctable, one that silently vanishes is an afternoon lost. Fast path is one `memchr` for a backslash. Tested against the **real vendored cmark** via a new `zig build test` step in the markdown plugin's own standalone `build.zig` (16 cases — it can't join fizzy's pure-logic list like `html_images`/`url_join`, which are std-only by design, because the whole point is a claim about what cmark does). Code spans and fenced blocks need no handling at all and now have tests pinning that: both get their own node types and never reach a TEXT node. Link *labels* do need a guard (`insideLinkOrImage`), since `[see [[A]]](url)` puts that text under a LINK parent.

Resolution is memoized per node+token against the resolver's `generation()` and explicitly **not** stored beside the parse (`RenderState.wikilinks` holds positions only): `Preview.ensureParsed` caches by content hash, so a scan-time resolution would freeze "broken" forever — the linking document's bytes don't change when its target is finally created. `tryRevealFileUri` split into `parseFileUri` + `revealPath` so a resolved wikilink reveals a path directly instead of round-tripping through a `file://` URI it would immediately re-parse (percent-encoding a path with a space or `#` is exactly where that goes wrong). `PreviewOptions.document_path` threads the source file down; empty disables wikilinks entirely, which is what keeps the store's fetched-README pane from resolving `[[Note]]` against the user's own local files. `markdown.Api.RenderOptions` deliberately untouched. `text` fires the new broadcast from `Document.tickContentChanged` (300ms typing-quiescence debounce keyed on `history.topOpId()` — already changes on exactly the right events, and comparing two integers beats hashing the buffer every frame) plus immediately in `save`, returning "still pending" up through a new `tickOpenDocuments` so the app keeps drawing until the burst settles rather than idling with a notification owed.

Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`, `zig build test-integration` clean; markdown's own 16 cmark-backed tests; text's standalone build. **Live on macOS** in an isolated `HOME`/`TMPDIR` sandbox: `pixi`/`zig`/`ghostty` rebuilt against 0.1.49 all load, and a `.md` full of `[[links]]` with **no resolver installed** renders byte-identically to before — every form plain text, code span and fence untouched, ordinary markdown links still live. **Not done:** no resolver plugin exists yet, so the resolved/ambiguous/unresolved render paths are untested against a real provider; `pixi`/`zig`/`ghostty` are pinned to the local SDK path and still need a released `sdk-v0.1.49` tarball plus their own re-release before store installs work. | -| R20 — host folder watch + `folderPathsChanged` broadcast | done | 2026-08-05 — the missing half of R19. `documentContentChanged` tells a plugin about buffers *this editor* has open; nothing told it about the rest of the tree, so a `[[wikilink]]` deleted from a file nobody had open stayed in `brain`'s graph indefinitely — nothing ever re-read that file. The first fix was a 15s poll inside brain, which is the wrong place for it: every plugin that cares about files would end up pinning a watcher library and standing up its own thread over the same tree. **New `src/editor/FolderWatcher.zig`** — the third nightwatch adapter in `src/editor/`, and the only one whose output leaves fizzy (`SettingsWatcher` reconciles `settings.zon`, `DocumentWatcher` reloads open tabs). Three things put it on the host side of the boundary rather than in each plugin: (1) **thread hop** — nightwatch calls its handler on its own thread, and plugins are dylibs, so a callback arriving on a thread the plugin never created, possibly mid-unload, is a crash; events are buffered and fan out from `tick` on the UI thread. (2) **ignore rules** — only fizzy knows them (`IgnoreRules`), so `.git`, build output and gitignored paths never reach a plugin instead of every plugin re-deriving the same filter through `Host.isPathIgnored` one path at a time. (3) **one watch** — three interested plugins would otherwise mean three threads and three sets of fds or event streams over one tree. **Nightwatch is deliberately not exposed**: it is an implementation detail behind `folderPathsChanged`, so it can be swapped, forked, or replaced with per-platform code without any plugin noticing — which matters, since its Windows behavior is unproven and its macOS default needs the `macos_fsevents = true` build option (wired conditionally in `build/exe.zig` and `build/app.zig`) because the kqueue fallback wants a file descriptor per directory *and* per file, and a project folder is exactly the shape that exhausts the fd limit.

**SDK surface.** `Plugin.VTable.folderPathsChanged` (`[broadcast]`) plus `PathEvent`/`PathChanges` on `Plugin`, and `Host.notifyFolderPathsChanged` over `plugins.items`. `folderWatchActive` needed a real `EditorAPI` vtable entry (unlike R19's notify, which was a plain `Host` method) because the answer lives in the editor, not the SDK — so `EditorAPI`'s shape moves and `Editor.fizzyFolderWatchActive` joins the vtable. `have_impl` is false on wasm and unsupported targets; `folderWatchActive` reports false there and with no folder open, so a plugin knows to keep its own fallback. Slices in the batch are borrowed for the call only, and `truncated` reports overflow rather than growing the buffer — a branch switch emits events by the tens of thousands, and the useful answer for a consumer at that point is "rescan", not a longer list it still has to walk. `.renamed` carries `old_path` only where the backend can pair the halves (Linux, Windows); elsewhere it arrives as delete + create, which the doc now says explicitly because a consumer has to handle that shape regardless. sdk **0.1.50** (fingerprint `0x80448d5960ab4849`).

**Threading.** The producer never allocates: two fixed ring buffers with a flat path arena (one arena rather than a slot per event, so a handful of deep paths can't crowd out everything else and no path length is a special case), swapped under the lock so the fan-out reads a buffer nothing else can touch and no plugin call ever runs with the lock held. The lock is a spin over `std.atomic.Mutex` rather than a blocking primitive, because blocking would mean `std.Io.Mutex` and therefore `dvui.io` on nightwatch's thread — precisely what the other two adapters' doc comments single out as not to be touched from a watcher callback; both critical sections are a bounded memcpy or a pointer swap. A 200ms coalesce window means one logical save arrives as one batch and a consumer reindexing a file finds it finished being written. A cheap dot-segment reject runs on the watcher thread before the lock (pure string work — no allocation, no host call) so a `git checkout` or a build churning `.zig-cache` can't fill the ring before the authoritative `IgnoreRules` pass gets to run on the UI thread. `stopWatch` tears the watcher down entirely instead of calling nightwatch's `unwatch`, which drops only the path it was given and not the subdirectories its recursive walk added — a folder switch would otherwise leak watches on the old tree.

**Testing.** The buffering and filtering are split into **`src/editor/folder_events.zig`** (`Ring`, `underDotSegment`) and wired as its own `addTest` root (`fizzy-folder-events-tests`), for the same reason `keymap.zig` and `reveal.zig` are: `FolderWatcher.zig` reaches `fizzy.zig` and dvui and can only be exercised through a live editor, while the bugs that would actually bite (an overrun on a path that doesn't fit, a filter that lets `.git` through) live in the std-only half. `Ring` is generic over the event enums rather than importing them, since `Plugin.zig` imports dvui and would drag the file back into the module whose tests never run. 8 cases, including both halves of a rename counted together against the arena, and `empty()` distinguishing nothing-happened from everything-was-dropped — `tick` leans on that, because a batch that truncated with zero surviving events still has to be broadcast.

**Consumer.** brain's `Watcher.zig` routes markdown paths straight to `Indexer.enqueue` (create/modify/delete are all "re-read this path" — the worker treats missing-on-disk as the delete) and falls back to a quiet sweep for the two things a path alone can't identify: directories (one `mv notes/ archive/` moves every note beneath it, which is the tree walk the sweep already does) and attachments (the media table is only rebuilt by a walk). `truncated` goes straight to a sweep. The periodic sweep **stays** rather than being deleted, stretched from 15s to 5min while `folderWatchActive()` — "the watcher started" and "the watcher is still delivering" are different claims, the backends differ per platform, and a silently dead one should cost a few minutes of staleness instead of a permanently wrong graph.

Verified: `zig build`, `zig build check`, `zig build test` (8 new tests), `zig build test-sdk-version`, `zig build test-integration` clean on macOS; brain rebuilt against 0.1.50 (`zig build`, 291 tests). **Not done:** Windows and Linux are untested end to end; `pixi`/`zig`/`ghostty` need a released `sdk-v0.1.50` tarball and their own re-release before store installs work. | -| R21 — app consumes `sdk/` as a package, one dvui pin | done | 2026-08-05 — dvui was pinned twice, in `build.zig.zon` and `sdk/build.zig.zon`, and the two were only kept equal by discipline. When they drifted the failure was unfixable rather than merely annoying: dvui types reachable from the plugin boundary feed `dylib.sdk_shape_fingerprint`, which the app build and the plugin-SDK build each check against the *single* `recorded_sdk_shape_fingerprint` literal in `sdk/src/version.zig` — so two different dvuis compute two different fingerprints from one literal and no value satisfies both. Every value that let fizzy build made brain fail and vice versa, and both blamed `sdk_version`, which a bump cannot repair. Nothing in the error named dvui.

**Fix: dvui is no longer a dependency of the root package at all.** `sdk/build.zig.zon` owns the only pin and the app borrows it through `build/sdk.zig`'s `dvuiDependency` (`b.dependency("fizzy_sdk", .{}).builder.dependency("dvui", args)`), which forwards `args` untouched so each of the 5 call sites keeps full control of backend/target/optimize — only *which* dvui is shared. The direction is forced, not chosen: `sdk/` ships standalone as `fizzy-sdk-v*.tar.gz` and can never reach above its own root, while the app can always reach down into it.

**What made this look impossible at first.** The obvious move — add `.fizzy_sdk = .{ .path = "sdk/" }` — fails immediately with `file exists in modules 'root.@build' and 'root.@dependencies.sdk'`, because the root build scripts reached into `sdk/` by *relative path* in seven places, which claims those files for the root's build module; a file may belong to only one module, so the same files cannot also be a dependency's. That reads like a structural prohibition but is only a spelling problem. `sdk/build.zig` now re-exports what the app needs (`plugin`, `core_module`, `sdk_version`, and dvui's *build* API, since the app can no longer `@import("dvui")` itself) and the six root-side importers (`build.zig`, `build/{app,exe,web,common}.zig`) go through `@import("fizzy_sdk")` instead. dvui's build surface turned out to be one decl deep — `AccesskitOptions` — so re-exporting cost nothing. The seventh crossing ran the other way and surfaced only after the first six were fixed: `plugins/shared/build/helpers.zig` read the version triplet from `../../../../sdk/sdk_version.zig`, putting an `sdk/` file into the root build module from below. Its sibling line 46 (`../../../sdk/manifest_identity.zig`) is `sdk/src/`, a different directory, and correctly left alone.

**The pin-drift guard that is no longer needed.** An earlier pass in this session built a `build/sdk_pins.zig` — semantic `.zon` pin comparison, local paths resolved from two different depths, comment-toggled URLs ignored, unit-tested, failing the configure with a message naming dvui instead of `sdk_version`. It is deleted along with its `fizzy-sdk-pins-tests` root: it made the deadlock *legible*, but one pin makes it unreachable, and a guard against a state that cannot occur is upkeep with no claim behind it. Recorded here because the diff only shows the deletion.

**Note for release.** `recorded_sdk_shape_fingerprint` is dvui-pin-dependent, so it changes when the pin is flipped between `../dvui-dev` and a release tarball — it currently reads `0xd2de25bab58a617a` (local checkout), where R20 above recorded `0x80448d5960ab4849` (tarball). That is expected and no longer ambiguous: there is one pin to flip and both builds always agree on the answer.

Verified: `zig build`, `zig build check`, `zig build check-web`, `zig build test` (264 pass), `zig build test-sdk-version` clean; brain rebuilt against the shared pin (`zig build`, 300 tests) — the fingerprint conflict that motivated this is gone; standalone `plugins/{text,workbench}` builds clean, confirming `helpers.zig`'s new named import doesn't reach the standalone plugin path. **Not done:** `plugins/markdown`'s standalone build still hits the pre-existing, unrelated module-graph conflict noted in R9. | -| R17 — `tags` in `Manifest` + registry-side description/tags dedup | done | 2026-07-30 — closes the gap R16 left for `description`: `tags` couldn't be authored anywhere except a hand-typed `registry/.json` PR in the separate `fizzyedit/plugins` repo, so a plugin with no registry entry yet (or one whose author never filled tags in) had zero search surface for them. **`Manifest`** (`sdk/src/manifest.zig`): `tags: []const []const u8 = &.{}` added, same off-`sdk_boundary_types` treatment as `description` (no fingerprint bump). All 4 built-in `plugin.zig.zon`s got real tags. **Resolution chain** (`PluginStore.tagsFor`, mirrors `descriptionFor` exactly): registry's own → `Editor.builtinTags` (new, mirrors `builtinDescription`) → `PluginLoader.probeTags` (new, mirrors `probeDescription`; returns a caller-owned `[][]u8` via a small `dupeTags` helper, since a manifest's `tags` — unlike `description` — is an array, not a single string) → `tags_cache` (new, same `StringArrayHashMapUnmanaged` shape as `description_cache`, cleared at the same two call sites: `refreshDiskScan` and `deinit`). **`scoreEntry`** now calls `descriptionFor`/`tagsFor` instead of reading `entry.registry.?.{description,tags}` directly, so a built-in or locally-probed dylib's own prose/tags contribute to store search even with no registry entry at all — `author` is the one field left with no fallback, since it was never a `plugin.zig.zon` concept to begin with (attribution, not something a build declares about itself). **No new UI** — tags still have no display surface (chips, filter row) anywhere in the store; this pass is resolution-chain-only, matching what already existed for `description` before R16's header. **Registry-side dedup** (separate repos, coordinated in this pass since the whole point was "don't require authors to hand-duplicate description/tags"): `fizzyedit/plugin-build-action`'s `read_plugin_zon.py` now also reads `description`/`tags` off `plugin.zig.zon`; `build.yml`'s setup job exposes them as job outputs (routed through `env:` rather than direct `${{ }}` interpolation into the assemble-manifest shell step, since these are free-form author-controlled strings — direct interpolation would be a script-injection hole); `assemble_manifest.py` embeds `name`/`description`/`tags` at the top level of the author's `manifest.json` (previously just `{id, releases}`). `fizzyedit/plugins`'s `store/src/manifest.zig` (the *aggregator's* copy of the author-manifest shape, distinct from `sdk/manifest.zig`) gained matching `name`/`description`/`tags` fields; `ingest.zig`'s `upsertPlugin`/`upsertTags` now fall back to the fetched manifest's values when `registry/.json` leaves its own `description`/`tags` empty — registry entry still wins when both are set, so a maintainer can override the store-listed copy without waiting on a plugin release. `docs/manifest.example.json` and both repos' `README.md` updated. **Not done, left for the user:** this is an interface change to `plugin-build-action`'s `build.yml`/`assemble_manifest.py` — existing `release.yml` callers pin `uses: .../build.yml@v3`, and `build.yml`'s own auxiliary-checkout step hardcodes the matching `ref="v3"` literal for its own script checkout, so nothing picks this up until a **new `v4` tag is cut and pushed** (a shared-CI action, deliberately not done automatically) and each external plugin repo (`pixi`/`ghostty`/`zig`/`json`/`markdown`) bumps its own `release.yml` to `@v4`; no `registry/.json` PR was reauthored to drop its now-optional `description`/`tags` either (a per-plugin-author call, not this repo's to make). | +Viewport budget glyph dvui record render column layout editor culling. Word window item region offset item reader record window sample. Sample line table record pane line paint fizzy block render preview field. Item window entry row section column value cache stable value preview data cell wrap. Fizzy offset viewport field window sash plugin header item editor frame reader text record culling. ---- +| Column 1 | Column 2 | Column 3 | +|---|---|---| +| `row-1` | Height render block document plugin paragraph sample value settle reflow. Dvui record scroll pane width table block word. Pane drag view dvui window drag text section. Cell render viewport item layout drag budget. Record budget heading plugin section paint measure document row preview. Document table culling width item dvui. Width viewport reflow preview cache view reflow position line span. Settle value frame viewport plugin paragraph wrap. Line record culling reader offset data layout position. Surface heading preview block budget value wrap window. Document position pane reflow wrap record row. Span dvui frame frame value plugin height. Window wrap entry stable column section layout span heading. Paint reader region offset surface block measure value cell drag surface culling. | Pane pane fizzy position window table paint pane table. Glyph sample text pane block viewport stable cell sash fizzy settle entry. Preview section record text line viewport. Paint preview sample row window layout block view word region measure field. Viewport measure reflow editor viewport span view table culling view wrap. Section frame drag stable table value sample word region data. Layout settle region value culling column list. Region region record section reflow list text column record. List position cache preview drag frame cell. Line text sample text dvui table plugin table column stable. Pane data header word offset span measure surface budget frame. Column editor text offset reflow surface heading viewport wrap list entry item. Fizzy render list header document position list text column position pane row. Cell block measure wrap editor word. | +| `row-2` | Document region region field scroll surface list cache. Viewport glyph plugin span record document culling pane render. Span culling wrap budget view reader wrap sash stable section position. Measure height reflow editor block header. Surface surface offset header header settle data view. Data cell block wrap heading width layout offset section entry. Culling header pane sash span region cell. Word budget cell pane wrap render. Line window dvui word data word section. Sash value reflow culling fizzy pane drag reflow window. Paint reader value heading layout reader. Width cache paragraph cache pane scroll offset paragraph. Stable frame paint cell document drag section heading. Word fizzy pane fizzy reflow viewport sash. | Surface paint offset stable sample data sample cache. Reader block window view fizzy dvui field stable. Record span measure record paragraph line text. Render position paragraph budget field preview scroll culling budget settle surface position. Reflow entry reflow entry viewport render. Layout span dvui view entry sample paint. Value sash list frame cache plugin row fizzy preview list paragraph. Pane sample scroll height paragraph measure layout record cell sample text dvui. Culling view section list document stable. Wrap culling cache paint header paragraph plugin wrap drag viewport pane. Line record cache frame heading stable item entry line glyph render. Pane pane table window line reader layout sample plugin sash view. Column item header document height reader. Measure span list reflow wrap cell data pane pane dvui budget sample. | +| `row-3` | Offset settle position block budget culling column cache. Layout table settle dvui glyph text data column frame span span fizzy. Reader render budget line table viewport. Table layout stable reader paint row. Height view section heading line offset editor offset culling sample region. Value cache data settle data viewport item item sample. Data entry paint header render word wrap height sample row. Sample region position settle cache view line word fizzy. Column line layout drag record wrap line fizzy drag heading. Budget record pane list dvui row preview width preview culling header position. Budget header height column view layout cache. Region wrap measure block stable stable stable viewport paragraph data. Field block viewport plugin text text. Window wrap glyph data glyph cache. | Reflow settle field wrap position paragraph block render entry preview paint region. Item surface sash preview table reflow cell editor word window stable. Table wrap text cache reader pane view. Frame document cell dvui settle sample. Column position offset drag fizzy budget surface budget. Height record word stable item field header list data sample frame span. List preview culling width sash editor list region frame. Cache pane cache view measure layout section. Offset region cache cell block position culling height dvui view row. Wrap frame preview window render value record header span sample record. Word region block frame drag header budget cell. Block width layout region view record measure render scroll. Glyph wrap dvui span region record word paragraph viewport. Wrap plugin record field item scroll. | +| `row-4` | Block heading measure drag column paragraph viewport stable stable culling preview reflow. Span block viewport section surface width dvui cell glyph table column section. Word settle row measure region frame. Pane word preview entry word reflow view sash. Data offset text table field viewport fizzy height pane paint window region. Width render sash sample width text heading column field entry table. Reflow field view height plugin row wrap window word cache. Block column line header paragraph pane editor preview preview. Section block region block position entry. Reader paint measure surface reflow editor measure plugin. Plugin wrap value sample entry sash. Entry culling sample heading item window viewport cache wrap line position item. Line offset section region wrap fizzy. Field line section row stable reflow. | Position column word wrap window row paragraph value settle data cache. Fizzy view section culling wrap position scroll fizzy entry. Budget block stable block frame section viewport. Editor word measure block value surface section. Entry text heading render width field scroll height fizzy field. Text position surface span position surface editor dvui value. Settle frame table field table pane sash line data frame. Item word dvui plugin field culling header drag document height settle. Record editor window paragraph wrap data span. Layout record block preview settle block. Row plugin header section budget span reflow frame. Paint height drag text render sash width cell reflow entry settle. Header window list culling item span scroll position. Entry viewport width render document view view glyph budget position record. | +| `row-5` | Sample sample width paragraph measure column section dvui width. Height width layout cell viewport glyph glyph. Dvui data fizzy paragraph word glyph render culling. Sash value word frame layout culling window record. Cache frame region entry sash stable. Preview window sash preview span list. Word cache render scroll view block. Height window value span section sash layout section field. Offset viewport word line wrap settle text fizzy position. Editor preview sample span span value layout item sash preview width. Stable value table cache settle entry span item window dvui. Block viewport sample section culling height plugin row. Wrap field glyph record value culling. Pane text scroll offset fizzy reader. | Height plugin glyph settle document cell block. Wrap scroll scroll viewport list block. Frame frame region value drag frame height word frame region. Row pane glyph drag item width. Cache span entry table value reader row stable word. Document value list field document header editor measure field. Column plugin width settle field offset line editor document reader document field. Position sample line data pane offset data table reflow preview. Cache heading wrap height entry sash data. Render plugin height render sample header. Value reflow row list surface surface paint dvui table layout text. Field field plugin culling editor reflow budget sample reader row. Field paragraph region frame scroll list scroll field list text text entry. Table glyph drag cell viewport surface span text scroll width window. | +| `row-6` | Block sample scroll window list drag preview scroll heading view span word. Document frame section list field list drag glyph span. Row width sample scroll block measure. Table drag item culling data offset. Column row block document view dvui list sash text word dvui stable. Region field field reader drag offset. Text heading sash list window word text offset header row window. Cache paragraph fizzy cache block block list frame. Reader document editor view frame pane layout viewport paint. Settle offset column value stable frame surface wrap render entry line. Settle position line height entry fizzy culling measure. Table value list pane frame wrap budget height editor measure drag line. Drag text viewport line entry glyph. Entry value list measure row sash data heading. | Settle entry wrap dvui glyph item window column column measure. Sample scroll width settle paint document table field window. Column budget drag plugin section pane. Section fizzy paragraph offset heading item table item paragraph budget layout. Region render pane surface preview view list list. Settle cell surface width wrap data record. Measure value document frame window region plugin window column position. List editor drag window reflow block item item sash offset. Preview plugin fizzy pane view item sample plugin record row paragraph scroll. Render preview text item cell sash reader record. View width height preview cell scroll. Dvui value drag value item measure position fizzy scroll. Paragraph surface height glyph cache drag editor header. Entry cache fizzy view document reader row sash position stable heading. | +| `row-7` | Wrap surface column render cell cell reflow position budget paragraph offset view. Header reflow fizzy line paragraph stable surface span. Header offset editor wrap viewport reader drag region glyph scroll stable. Cell view cell surface field reflow budget heading block cache column region. Column paragraph editor settle window data window settle window column reflow. Scroll editor section viewport region position table fizzy budget settle word. Scroll stable plugin reader offset editor. Layout measure dvui width item layout. Budget table paint drag heading item record. Culling record scroll stable height word stable editor header cache cell header. Glyph width settle surface word list editor section span. Glyph width offset word heading window pane heading. Render culling culling region view row. Reader view width table word table. | Sample plugin reader field view line data. Height wrap viewport wrap width section cell glyph entry culling column record. Surface window editor editor reflow layout block column. Reflow table line field stable cell. Pane drag offset measure view plugin paragraph. Window sample layout block wrap width settle paragraph culling. Width editor column header width header culling settle sample. Drag glyph paint surface drag culling editor sample glyph wrap. Reader word cache span region sample document entry position. Dvui frame budget block sample span text paragraph budget view settle sample. Entry record value stable drag sash surface. Height item table data pane culling settle section text editor. Header item settle measure paint document offset table. Editor measure wrap scroll reflow preview surface reflow. | +| `row-8` | Word word fizzy heading wrap layout. Frame fizzy viewport text line window sash. Record wrap width header dvui text viewport heading reader view preview. Text field settle span render word row paragraph settle field reader offset. Stable window wrap frame entry settle record record culling. Surface record entry region editor glyph width sample fizzy offset culling field. Sash drag budget glyph value header. Dvui data settle paint stable row pane glyph paint layout reader. Fizzy span glyph budget sash sash preview viewport budget frame. Item reader editor word fizzy text sash view surface surface editor frame. Region view entry row record wrap column preview render dvui span. Reflow record stable width cell offset layout scroll table drag word. Plugin settle reader column frame fizzy. Span drag glyph position list height. | Block plugin column region dvui paint drag width. Stable value glyph reflow window paint list paragraph. Row window value region height pane. Editor dvui block offset value frame render. Render width editor entry wrap stable section width. Layout reflow layout preview sash field document cache record value preview. Heading height region stable glyph region cell. Sample budget plugin frame render budget row. Data editor span cache stable word entry row. Stable budget header culling field item surface culling heading reader heading list. Entry header word frame item text window heading reflow. Block span record section width frame layout field stable pane. Sash pane position surface dvui heading. Value scroll pane stable field window list data drag scroll. | +| `row-9` | Stable line fizzy reflow span data data paint header. Editor pane editor section wrap block section header window record fizzy. Width line culling text preview header paragraph pane scroll dvui window. Drag section reflow paragraph pane viewport value. Layout viewport sash value table reflow settle layout reader sash span field. Window viewport text settle offset settle block window stable value. Data editor header reflow window measure. Document region budget text sample paint value measure. Viewport height measure plugin render wrap measure offset sash heading cache. Width word reflow text entry reader render heading table plugin surface. Pane plugin entry reflow text column heading surface line glyph scroll. Dvui layout dvui cache offset line column fizzy measure plugin frame. Offset paint sash fizzy field item region region scroll span row paint. Preview scroll reader view list reader editor paint. | Header word height block data item height pane row. Fizzy viewport viewport cache dvui scroll scroll fizzy. Preview header measure fizzy stable wrap header. Dvui line paint sash cell glyph heading data offset. Record budget measure section item record section item. Width block paint width scroll item plugin entry region cell preview paint. Budget fizzy region preview height header cache document render stable word. List line fizzy word wrap reflow wrap budget paragraph position frame span. Region heading text surface stable section preview sash entry list sample viewport. Item wrap culling editor height value. Scroll field offset record position window window paragraph measure document. Scroll budget field surface dvui drag width culling. Span settle cell measure offset pane fizzy column dvui layout. Span glyph stable item plugin reflow column. | +| `row-10` | Pane cache pane value offset measure height entry block frame value plugin. Editor cell pane view column wrap row. Data view fizzy paint wrap culling offset viewport header. Line render sash paint editor plugin glyph viewport. Width header document drag glyph field. Render editor header row sash surface sash pane sash header width region. Pane glyph fizzy section table surface render. Reflow document cache reflow section surface scroll cell line. Offset height view wrap word field. Culling line heading sample paint document window view word item. Wrap measure width region word region budget table word. Dvui offset line settle glyph row editor surface frame region. Render table text line view view block stable document block. Preview record plugin dvui span section column. | Item sample record measure row data surface paint stable word view pane. Block cell stable text text field text paragraph text editor. View cell table region block row stable. Line field column preview scroll span header cache surface settle. Reader document text column editor fizzy preview document cell window list frame. Header record editor surface budget list paragraph sample glyph table width. Sash stable editor plugin render editor culling sample position span. Cache block view document text heading cell wrap stable. Budget preview reflow entry column header render data field window. Header measure word layout record span height render measure editor. Block fizzy viewport sash table word sash word. Dvui culling block line drag frame region dvui value budget sample. Heading layout drag list settle cell sample. Section fizzy render measure line table row culling block. | +| `row-11` | Word reader list document editor pane sash field dvui measure editor item. Cell window layout height sample fizzy. Offset paragraph column fizzy document record reader. Window list block pane stable record section height pane column fizzy field. Drag stable region item list row. Item viewport plugin wrap window layout data column. Field glyph preview block layout heading settle drag plugin stable height. Region paragraph word dvui render drag column reader text fizzy paragraph. Preview width stable stable data sample. Item cell layout block list settle line viewport measure. Row item paint fizzy header drag. Paragraph data line sample height preview render pane. Region culling view width span settle preview sample region pane value wrap. Culling surface viewport preview record list surface reflow. | Settle row wrap span paint line entry header table dvui row. Drag frame paint view position wrap render scroll line. Surface record wrap stable dvui record glyph view. Line stable scroll table fizzy row cache list region column line. Line pane scroll document value table layout reflow line stable budget word. Reflow table editor render region row. Scroll plugin culling paragraph budget row width fizzy. Drag list word offset position measure text. View field view pane plugin region value position window reader glyph width. Line position field preview view budget. Record frame line dvui editor sash plugin scroll culling offset. Window table drag view span height culling sample offset. Glyph viewport column reader paragraph surface line paint budget width editor plugin. Culling width table section text paragraph scroll fizzy column scroll measure word. | +| `row-12` | Width data budget cache drag line section paragraph editor width data. Editor view header header sample viewport wrap pane reader row plugin scroll. Stable drag surface window paint field region pane. Width document culling table paint column value glyph. Record fizzy frame span drag table measure. Reflow column glyph column row paint. Item fizzy fizzy position render data record. Document measure table list window position measure. Viewport data height height heading plugin reflow dvui paint height. Stable preview data region record value frame text table view header. Cache plugin word word measure editor. Header fizzy line field layout render word field surface. Span entry plugin heading reader span reflow dvui record measure editor. Width block editor line item width sash. | Height heading value offset viewport window budget cache width render word. Paint data paragraph window preview drag region reader reflow. Width word sample text fizzy block stable record. Sash glyph word header stable reader item window paint span frame. List sash cell header settle region table. Reader section field width plugin span glyph cache section. Column editor stable plugin wrap header wrap line width surface sash plugin. Reflow settle list measure culling line viewport section field document. Settle render table heading list measure. Layout scroll item reader block stable section sash preview word. Word reflow word measure item preview. Row table layout dvui stable layout field paragraph cache record width value. Scroll wrap budget layout sample column fizzy value header wrap region. Offset block layout table drag width heading height settle. | +| `row-13` | Fizzy paint render header section text surface column plugin block record. Item paragraph paint plugin glyph item text text window sample list. Window drag pane offset budget word line column paint reflow glyph. Height viewport scroll scroll glyph cache. Line layout view text region width paragraph record region paint. Height word wrap wrap table paint line row wrap wrap. Settle document block frame data wrap span frame block. Plugin fizzy entry paragraph sample cache. Paint fizzy render section height settle measure editor. Line cache heading scroll glyph viewport view section block layout plugin item. Column field layout surface item section height dvui render measure. Item header region stable value preview value. Frame width paint header heading entry region culling paragraph offset. Header editor column width settle measure item stable header. | Field frame section glyph width word header. Width record reader measure width value. Measure settle fizzy cache wrap paint paint table cell text record render. List settle reflow width value record settle layout fizzy glyph window. Offset cell word editor glyph column header drag sash layout region drag. Reader item cache settle item fizzy header field row. Column editor preview render cell list offset header. Sash wrap stable width height paragraph span paragraph budget budget preview. Line width editor line pane dvui sample. Cache table reflow reader reflow cell width column span editor. Span position viewport record paint document wrap frame render list view text. Measure section list position text preview. Reader paint section value height stable plugin paint block. Surface budget sample line heading column column stable line offset budget document. | +| `row-14` | Reader cell word field offset culling width glyph value. Width reflow fizzy value surface wrap cache. Width cache line row preview glyph line block line list editor column. Section editor section row reflow record preview viewport fizzy region. Render header list fizzy document width stable block region. Reader offset region surface offset width pane line line stable budget. Paint settle layout preview fizzy frame data stable plugin section measure list. Sash document record block measure cache frame sample data. Text record paragraph budget width block stable item record. View cell word fizzy culling budget column plugin row. Height column width fizzy layout header budget plugin offset width position. Pane cache row drag reflow measure. Span scroll word height paragraph window pane sample list sash field glyph. Editor glyph table stable column sample measure reader. | Budget width span cell culling table. Record settle stable span section line region line settle. Offset drag offset reader wrap cache item viewport text wrap entry plugin. Item line width plugin text field glyph window. Pane paragraph cache block surface window glyph line line. Render pane position paint sash item editor frame record editor. Position frame fizzy row section paint pane block cell. Render sash surface budget frame frame glyph sash pane frame sample. Document word viewport render reader header word row. Plugin render word budget cache measure cache measure column height budget line. Stable fizzy document paragraph render reflow. Sample stable table paragraph entry heading span record drag. Pane window item stable entry surface dvui entry. Item width field layout text cell record wrap row cache plugin. | +| `row-15` | Budget dvui render block sample span row fizzy item data row sample. Measure drag layout measure position viewport record cell list reader. Frame paint paragraph measure region reflow. Heading header cache position culling row block plugin plugin. Glyph render editor surface render frame position. Window cache line sash width header document line preview document window measure. Reader word cell pane measure height surface heading position. Block text cell sample scroll line viewport viewport. Surface table scroll pane reflow sash frame heading. Height entry record document preview pane scroll. Preview paragraph region span block wrap wrap view record. Scroll drag wrap plugin heading scroll paint width. Sash render value surface preview list. Position window viewport stable measure row block heading value stable header scroll. | Culling render text budget value sample sample. Settle width reader word table position position. Plugin item culling view offset culling drag glyph cache render height surface. Height width view cache wrap reader fizzy document. Record column text viewport preview section window layout stable window table scroll. Paragraph list paragraph record drag record block width drag position text sample. Plugin plugin offset record record height region fizzy editor stable reflow field. Budget line list sash width fizzy. Budget block budget region viewport layout culling position section. Record editor paragraph view item region document fizzy reader position record list. Reflow fizzy table data height frame. Render fizzy header section wrap glyph dvui position render region editor. Column viewport glyph width entry editor sash. Window header wrap viewport layout offset position cache. | +| `row-16` | Surface section measure frame cell fizzy dvui sample. Culling span frame entry reflow span measure stable settle row span. Entry paragraph render paragraph record sample. Drag entry culling editor budget row item paragraph. Sash column stable budget cache block. Height block editor wrap editor line drag sash. Viewport text plugin record word paragraph window table text. Document entry paragraph field reflow stable document render offset offset. Row value preview view header settle word. Column plugin sash table editor paragraph record editor height reader row. Column block plugin record reader glyph record data value. Row word viewport glyph text cache scroll cell reader data. Value reflow culling heading column viewport drag glyph culling width stable. Item culling row height sample text table value paragraph table budget. | Paint region fizzy frame row stable header reader dvui reflow glyph. Data offset view record render sash paragraph cell entry span block. Item item region position data window paint settle surface drag. Render region list frame heading surface plugin paragraph. Frame measure budget word viewport glyph header reader. Position heading fizzy reflow cache section block heading header document paint. Fizzy table entry region table header. Data view sash view header width entry render preview. Pane paint record heading editor stable measure. Field pane viewport surface stable width measure scroll measure frame budget offset. Section stable line dvui stable item render cache culling. Reader sample fizzy table paint measure section viewport. Sample sample block preview editor field list. Wrap block document value height height scroll offset wrap list glyph. | +| `row-17` | Culling cache height fizzy window text column settle value budget data section. Field item editor value wrap frame dvui settle. Frame paragraph line field span entry document header document reader entry position. Paragraph drag editor viewport cache reflow. Text paragraph word heading span paint. Budget culling paragraph item editor frame heading line editor sash table value. List view height heading window header data cell offset view drag position. Word cell layout height value list position paint value render list. Window surface list paint width offset paragraph sash stable section stable height. Layout position column view height viewport reflow row document sample. Line word settle block record value budget cell entry pane plugin. Height header heading reflow item offset cache preview column. Paragraph frame value layout width paint region plugin layout window block. Drag field measure header position surface. | Row plugin paint wrap list glyph span. Stable frame budget surface render render row column. Budget width drag view scroll record settle. Render viewport sash pane height view render. Value record stable reader block column. Item heading stable stable stable settle. Dvui settle field line scroll reflow list sash value frame. Row culling layout plugin preview item span paint word cache. Block height sample position window reflow plugin window text. Drag reflow surface budget line block preview section region span. Viewport height fizzy cache list region drag pane word line. Span reader column budget section measure line section. Table preview stable budget document reader view dvui. View document paragraph span offset table settle cell editor document layout data. | +| `row-18` | Position window layout data region region culling culling span. Height word list item item frame table. Budget heading region layout paragraph paint cell viewport entry height layout. Table plugin section region section glyph dvui cell entry paint table pane. Reflow row entry heading culling item column block. Record wrap layout reader line column span value surface fizzy. Word sash reader paragraph stable width paint. Height glyph preview settle document cache span. Glyph budget wrap block surface paragraph. Paint span wrap window paint dvui record fizzy. Budget text pane sample editor reader editor heading word document. Value surface budget measure reader entry. Editor header render window reader field region budget span document entry. Cell stable item word window document cache. | Position cache entry item word offset reflow cache cell. Window reader view drag drag entry offset drag budget. Field position heading document render region sample paragraph. Cache settle scroll entry cell field scroll plugin field sample culling height. Block sample block row surface data surface list layout. Budget data pane paint paragraph sample document settle wrap viewport reflow. Frame reflow layout settle entry table entry offset value. Settle width row glyph paint line layout glyph. Drag stable width editor viewport list. Scroll line header view view width record row preview record. Entry sash data width line sash plugin budget offset. Column item view surface plugin table. Height stable value height paint table width pane position sample preview. Heading row window position width pane. | +| `row-19` | Span row cache line header item position render. Block layout span measure data settle scroll preview reflow. Data reader heading settle sash settle wrap. Block span header drag height settle layout list scroll header entry. Surface text glyph record offset field view pane region section list. View plugin field entry field row position glyph height column. Reflow wrap plugin heading scroll entry cell row cache paragraph budget budget. Editor measure sample document plugin drag paint budget line. Preview height sample measure offset layout scroll paragraph section pane. Reflow dvui section view item record culling. Row word region offset sash preview offset pane section offset layout line. Fizzy line stable width editor row item cell heading word. Culling block pane fizzy region measure. Cell preview editor width plugin paragraph reflow value height field data culling. | Word fizzy glyph glyph region header. Section layout field sample header cell text paragraph cache render drag. Settle scroll height preview table offset fizzy value list view item. Field item item sample render budget budget data editor scroll. Settle record paragraph preview cache header. Viewport entry cache viewport drag reader frame record paint wrap drag. Height paint drag line fizzy word sash preview viewport frame offset cache. Cache scroll region width sash preview paragraph settle layout header. Plugin glyph budget item viewport fizzy. View width heading list field table frame section block table editor. Viewport view dvui layout preview section editor cell section. Data position viewport view block window stable render. Measure value preview height culling column value region stable plugin. Height field paragraph fizzy list render preview height. | +| `row-20` | Culling preview line paragraph pane fizzy line row editor. Item glyph view budget reader render. View surface field frame offset entry field. Value height editor cache width drag wrap. Frame region document plugin window sample. Window measure width row value viewport heading text entry cache table section. Surface item culling text scroll scroll sash. Word sash paint drag section budget cell scroll height budget. Paragraph offset viewport column cache sash sample field glyph value entry. Budget reflow table cell document surface stable field reflow frame sash. Span document entry word dvui preview surface editor value stable surface word. Glyph span column heading position record measure word. Measure position preview drag text column section viewport text. Settle reader word header width header region measure surface region. | Sash line reflow list glyph window. Paragraph document plugin record window paint row plugin cache sample. Record dvui pane cache row reader sash culling view heading cache span. Value header sash paint entry paint. Item scroll measure row scroll plugin plugin value text reader window. Data list column block paint text span plugin settle row. Culling settle drag section viewport value cache culling item paint. Sample data measure paint data item cell text height wrap. Budget editor row position frame column row block list. Item position region window paragraph paragraph glyph list viewport editor view record. Value value position viewport fizzy surface text. Budget position frame block record dvui table pane height heading preview glyph. Value stable width sample drag height plugin text width window plugin. Reflow pane cache paragraph heading list heading text cell. | +| `row-21` | Plugin document field offset offset cell budget line offset stable. View row document value row section. Block dvui cell paragraph glyph width reflow table window row. Reader reader data dvui glyph layout layout scroll data budget reflow. Stable heading sash surface budget block field culling frame. Data document heading window culling record frame. Dvui document surface row pane column width document text item heading. Height culling fizzy window drag block item drag measure glyph. Layout record entry cache view record pane entry width preview reader plugin. Stable render wrap reader width budget position plugin field fizzy. Sash reflow document heading window list. Field value section frame row line data plugin glyph height. Surface document header pane value viewport. Reader header column line heading data. | Value dvui list stable preview plugin block sample render table section row. Fizzy preview entry entry reflow render value line offset glyph section. Stable editor section section cache entry text budget reflow text cache. Stable position scroll pane cell value row cell editor. Culling glyph height view heading span reader pane. Fizzy data item region cell block width. Scroll measure record table culling reader. Dvui surface section heading dvui dvui preview region. Sash sash drag view render sample line line preview reflow. Layout value heading row region block heading reader data column window cache. Word surface entry width height span reader paragraph layout settle section viewport. Header sample line line cache cache paint record value table viewport culling. Header data plugin frame reflow dvui row measure list plugin block. Cache position frame span sample pane. | +| `row-22` | Heading data width field surface window settle. Row pane word data document row record heading column paragraph. Column height field layout data render entry surface wrap measure sash budget. Sample dvui stable frame sash row word reader header reader. Text span view frame fizzy culling. Record paragraph dvui entry region width sash sample glyph line span. Span sash measure span pane span width block pane paint. Budget record sash plugin culling column editor. Width culling drag row pane scroll frame position field stable measure. Region budget record pane sash offset. List budget record preview field settle region data width reader. Column culling section fizzy item stable. Value sample offset offset heading row cache width section paint. Offset cell preview width span field. | Viewport sample view plugin sample sash plugin sample. Budget text heading sample text block window text sash field region wrap. Preview layout culling budget glyph editor. Region line budget section row word value heading offset. Cell field cell view viewport fizzy column sample item cache cache. Value settle position reader field reflow span span. Data scroll sash field surface layout span width document sash plugin budget. View line header span drag fizzy render position item culling. Paragraph heading surface reflow height reader view value. Reflow header header position preview dvui surface layout header section render. Column reflow budget settle scroll value budget data cache settle heading. Item text preview wrap entry editor settle table column cache render wrap. Line paragraph glyph field row entry span fizzy field. Table height width budget paragraph header sample heading culling sash settle. | +| `row-23` | Reflow record preview offset field cache surface cell viewport. Preview section pane text offset view. View entry cache wrap region view plugin list region. Sash fizzy window field stable cache width data plugin line. Column position word wrap measure table budget reader column value. Layout line entry paragraph document field column preview header item. List editor settle heading wrap line frame. Editor view culling wrap row preview sash fizzy table section. Dvui cache sample scroll scroll header sample. Stable width scroll fizzy preview frame height value block. Field frame plugin table frame reflow cell entry render reader. Stable record scroll window field glyph window width text pane. Column window item paint block item drag region reader item layout section. Preview data header glyph section height glyph region table. | Cell paint heading section record render measure culling paint. Reflow window height block reflow width layout dvui table. Region row sample glyph header paint. Entry column culling header view stable block. Column header reflow dvui reader text line. Section height sash offset glyph list value record block word. Word table heading budget row wrap. Cache document sash entry position frame section. Header reflow paragraph dvui offset viewport reader measure data table. Surface preview field frame list heading section preview. Table sample preview scroll frame sash text layout line offset. Layout line dvui text field window fizzy culling document drag. Row window stable region height surface drag fizzy document drag. Paint region item item text plugin dvui heading. | +| `row-24` | Section column cell settle stable reader culling stable word heading scroll cell. Height plugin layout document region drag column value culling. Cell field table cache column budget heading. Stable text render region sample preview settle span. Data window entry layout plugin paint. Plugin drag frame paragraph column pane layout list frame editor pane. Position stable position reflow row scroll pane. Region view drag culling render heading reader field drag region record section. Reflow value document layout surface span plugin cell pane frame. Paint table heading layout cache field. Budget sash position paragraph height render table. List table paragraph frame height sash column word layout. Wrap entry header entry render width. Sample block width plugin budget text width entry. | Sample cell wrap heading dvui dvui measure. Stable scroll region paragraph height reflow drag. Cell span region window line line span offset settle table. Paragraph word paragraph fizzy plugin glyph entry header stable culling sash table. Cell render block dvui height position settle preview list record wrap item. Column editor surface row section header position position entry pane field. Value sample measure measure editor document settle. Layout drag stable glyph item data reflow entry measure height height layout. Row table item text dvui fizzy cache window glyph plugin record. Sash editor reader sash culling row render. Height viewport culling glyph data row drag surface data render record measure. Width settle budget item stable frame word line offset. Culling block heading frame wrap drag width view. Measure measure reader reflow glyph span. | +| `row-25` | Heading value position frame settle cell settle text frame offset word budget. Window document line drag paragraph value culling. Cell measure sash measure wrap table stable data window column frame reflow. Plugin surface fizzy settle viewport frame budget surface scroll cell. Heading block pane line header reflow view culling cell header row measure. Heading settle list cache line header measure wrap view text offset sample. Measure document cell height record data editor column field table record column. Span word editor sash measure measure drag span pane fizzy header position. Reader sash budget row list item list paint surface header preview paint. Position height value settle sample field viewport position viewport word. Width paragraph value list reader window sample. Position cache wrap scroll item span sash row section header. Scroll word viewport offset offset scroll wrap value cell. Offset surface position dvui list sample item cell section cache culling. | Cell header row width sash list height window. Column list scroll entry column pane layout paragraph scroll. Document heading reader culling editor entry section. Field column section preview cache word. Section scroll sash heading height field render cell paint settle block paint. Line cache fizzy fizzy position sash column window list. Plugin item view data viewport item value record. Item measure render render paint stable. Culling column settle preview word sample header span column field. List heading value width item plugin measure section region measure culling. Scroll sample fizzy height table drag line view text cell. Sample height stable culling row item editor header glyph settle preview cache. Record line drag data dvui cell scroll document. Span cell region reflow glyph sash preview. | +| `row-26` | Data item item layout line value preview preview cell cell. Height stable text span settle data region row. Section cache column preview stable sash entry. Cell frame word budget preview entry culling field. Wrap budget span culling window text region. Settle column sample measure block document reader item. Height reader paragraph paragraph sash budget reader settle glyph block. Pane paragraph height value culling section reader plugin value reflow. Reader dvui sash render text paint sash paint preview. Fizzy surface fizzy editor fizzy column drag field reflow table glyph word. Column render drag layout cell section. Stable glyph heading height cache plugin fizzy settle cell section field. Scroll heading fizzy block entry offset offset region budget row entry paint. Budget column cache glyph heading editor frame span table budget cell. | Line record wrap data cache table. Block header dvui region window cell culling measure layout editor. Data culling editor value fizzy render reflow culling window. Data pane row table entry reader surface frame list. Measure reader fizzy field block viewport plugin line viewport position. Height line stable cell heading paint record. Cell drag text list wrap table data reflow paragraph position. Plugin glyph surface section width text surface stable. Settle table height line pane dvui header. Reader field document position dvui measure list scroll region offset value position. Item glyph header drag list dvui viewport item block frame reader stable. Data surface culling glyph preview table value reader culling region settle. Entry measure width layout entry plugin item document field culling width. Data view budget frame wrap row view block scroll value view render. | +| `row-27` | Width offset paint scroll sample document heading row cell reflow. Frame cell surface drag entry plugin budget editor editor plugin wrap. Render surface item pane width sash word offset word position view. Reader line measure dvui reflow width cell header region cache section list. Measure column value sample dvui document cache editor table line list offset. Editor cache region drag line item wrap editor data. Wrap reader position paragraph height column layout. Header sample item drag offset height reflow header culling list glyph. Scroll settle scroll paragraph layout item budget heading word sample position item. Entry word region word pane wrap entry. Paint drag column height render fizzy wrap entry editor surface value. Budget row measure paragraph dvui drag reflow position glyph preview. Dvui glyph scroll item fizzy field wrap entry value section view window. Text viewport preview wrap measure viewport sash budget. | Row height layout fizzy field heading offset reader data section span. Budget heading region list cell dvui section culling region header. Height block row preview paragraph plugin section item. Row drag plugin line layout item culling fizzy window sash. Measure window wrap pane sample frame item viewport culling. Editor view region scroll word frame field drag position. Pane line fizzy row reader cell record. Viewport drag paint culling offset text paint. Text reader measure item table culling glyph position block dvui span. Settle document pane plugin reader stable width plugin scroll preview drag. Cache window plugin budget frame item offset data settle data. Settle sash record plugin section window stable fizzy. Entry column layout width entry cache layout height table drag plugin. Heading pane width heading column window sash heading cache. | +| `row-28` | Pane sample sash document width item. Stable section paint fizzy drag wrap. Entry span heading reader height line. Scroll list row surface preview sash height sample surface surface budget. Field item budget region table drag block measure wrap. Field settle text field cell view item sash offset dvui wrap. Document cell text surface value plugin line block layout view. Culling settle preview measure viewport settle render column section width. Paint pane region frame dvui column pane sample reader render. Word stable pane width word document surface text header settle region pane. Render scroll word window height measure height layout data. Item paint column sample span cache paragraph dvui drag sash. Plugin column preview frame word surface line budget scroll editor viewport. Height width budget budget culling surface document drag layout. | Reader height surface field cell scroll view width. Record heading heading layout span record preview viewport cell. Dvui value width region editor pane column frame block. Field frame stable drag position item stable view surface fizzy. Item drag block position list cache viewport. Reader text position span cache position item. Header list record height reader block paragraph editor dvui frame scroll. Measure stable fizzy viewport pane editor. Value document view sample row word cell dvui editor record settle. Preview preview position paint header preview column width. Cache item span field viewport offset reflow line culling row. View budget view settle position width. Field preview viewport word reader position region pane. Surface scroll offset glyph width field wrap culling block render stable. | +| `row-29` | Word sample table scroll glyph sash plugin glyph. Offset reader drag surface window row editor. Table header field offset text glyph list budget offset. Text render plugin viewport settle plugin span. Frame glyph view list line header viewport. Culling data position view dvui scroll document. Measure document paint line wrap field table block height culling scroll height. Line table measure record measure reflow drag section span dvui layout block. Column block editor value data dvui stable scroll data column span cache. Field offset cache plugin region preview culling viewport render entry header. Section item sample sash entry header view measure. Budget line row wrap entry editor frame block row line height. Preview word entry reader dvui fizzy pane view budget region. Sample reflow line data settle block. | Scroll frame measure sash culling glyph paint drag block sash budget field. Render header layout reflow offset height. Window data cell text pane sample width reflow pane pane pane. Word frame field measure sample surface position. Column pane dvui height paragraph dvui document reflow cell paint region. Column paint cache dvui cell scroll sash glyph budget window culling dvui. Culling offset editor height column dvui viewport document paragraph view. Drag window fizzy span block table editor column record paint viewport. Paragraph region width heading fizzy layout. Paragraph sample scroll cell word table window viewport surface. Heading span heading block stable view entry. Block glyph position data sash paint. Paint fizzy render window render render glyph width dvui block editor. Drag cell field view document viewport section. | + +## Section 4 + +Column cache record line width cache paragraph frame view span. Frame editor sash settle layout pane surface entry row budget text section list settle. Entry view stable sample surface cell table text window surface drag view data. Value table sample item frame view cell measure data. Cell width drag plugin render offset offset surface. + +## Section 5 + +Wrap settle offset reader scroll paragraph region surface reader culling position heading row budget. Table paragraph frame heading width list sample header text dvui budget. Glyph settle value width header view viewport region offset sash. Stable document scroll table reader word paragraph window window pane item drag document header preview. Cell reader item paint reader line column value. + +## Section 6 + +Reflow value column sample dvui preview block viewport heading. Position pane item block block region section row. Block field budget value height item reflow measure cache record layout row. Value reflow fizzy record view value span data render surface. Budget editor word cache data list word scroll list. + +| Column 1 | Column 2 | Column 3 | +|---|---|---| +| `row-1` | Cell frame window offset viewport render pane drag row. Cell document pane reader pane window width section paragraph column editor. | Settle word sample cell header list line measure sash. Scroll field dvui height render table. | +| `row-2` | View cell wrap record field paragraph. Sample drag header line cache wrap height fizzy. | Field data measure paint culling cell column region editor. Reader document heading span offset row. | +| `row-3` | Height heading section sample fizzy layout item budget. Cache layout scroll position surface text span render. | List header cell word position section. Position word budget culling culling glyph header text stable table record. | +| `row-4` | Viewport position settle header list height view row table render window. Record layout preview record position frame plugin cell. | Dvui settle render field word entry record. Sample stable row preview list sash height preview text. | +| `row-5` | Reader position stable paragraph plugin cache list position section list header. Cache fizzy render view record paragraph region paint document layout glyph settle. | Frame line view layout fizzy sash. Header region frame header stable position glyph sample. | +| `row-6` | Wrap culling fizzy header table stable budget header span. Height block region drag region list document. | Fizzy line sash wrap entry reader list row span. Paint field sample glyph field stable height. | +| `row-7` | List heading cache pane measure measure scroll. Fizzy position block fizzy layout layout preview. | Cache height section line layout plugin. Surface height width editor value budget plugin offset offset. | +| `row-8` | Field glyph block offset span pane plugin view wrap block document render. Frame heading reflow preview row settle height surface culling wrap document dvui. | Scroll plugin entry document record block wrap span line offset. Glyph entry height list offset culling record settle paragraph. | +| `row-9` | Section span width text wrap layout dvui. Span record span editor field pane header. | Layout height word editor height offset heading editor span. Entry section glyph table viewport item reflow window plugin region. | +| `row-10` | Preview fizzy view stable value culling view scroll pane. Column width plugin editor frame scroll pane data plugin. | Data document dvui plugin settle line table height region region. Stable view surface field measure entry column. | +| `row-11` | Reflow region column window render measure. Stable row region settle plugin layout fizzy sample. | Record glyph field editor column field scroll wrap width frame. Field reflow wrap document view section viewport. | +| `row-12` | Word value plugin header settle position window height. Row reflow stable text region word editor reflow reflow record entry. | Record block width span sample region record. Budget paragraph paragraph dvui editor row measure paint. | +| `row-13` | Line view word column word width reflow. Glyph header column region window width paint surface reader. | Header measure width culling plugin viewport section viewport scroll layout. Word entry reader reflow view stable field table. | +| `row-14` | Pane item frame cache view frame culling plugin data culling. Region fizzy header entry data surface position fizzy glyph fizzy field line. | Paragraph word editor row render sash cache paragraph editor column height. Plugin offset culling column layout header position fizzy. | +| `row-15` | Region document entry render editor offset preview. Plugin dvui cache render paint column offset column text field settle. | Header layout drag pane preview paragraph. Settle preview list row line reflow region. | +| `row-16` | Budget span window measure list dvui. Sash editor pane cell surface data paragraph cache region position line. | Drag value frame data preview height settle. Width view pane reflow cache height. | +| `row-17` | Entry cache sash record glyph budget. Offset cache fizzy field value region. | Preview budget budget sample sash heading width view document span. Frame sample view sash pane cache sash sash. | + +## Section 7 + +Scroll height layout window layout wrap scroll span cache cache surface settle row header offset sash. Column row sash list glyph data position column layout scroll entry layout line text. Table pane height heading table measure sample view block stable frame fizzy document pane. Data budget block column line paragraph line word reader scroll plugin frame document item plugin span. Value cache layout height layout item reader sample render. + +## Section 8 + +Layout preview plugin list record paragraph settle field document table window. Data settle wrap record block width measure drag surface span block width line record record. Editor sash settle entry region value block block position record. Header frame sash drag record span reflow text dvui fizzy drag width height reader. Heading header layout height cache value plugin text render glyph. + +## Section 9 + +Dvui paragraph measure reflow section entry surface scroll viewport stable height column. Height fizzy record viewport view width row block span height cache plugin block cell reflow. Drag culling record scroll cache entry dvui paragraph position position surface record dvui culling. Text row view layout width text list glyph header data scroll viewport dvui reflow render. Block column header entry data entry editor value frame section settle span section section. + +| Column 1 | Column 2 | Column 3 | +|---|---|---| +| `row-1` | Region cell position document frame item header budget block. | Layout sample window paint table drag. | +| `row-2` | Surface dvui section plugin reader view record. | Dvui plugin line dvui header table glyph height wrap fizzy view. | +| `row-3` | Settle preview settle line plugin editor wrap span surface table. | Surface preview settle data column editor heading editor. | +| `row-4` | Budget sample value table data window scroll row entry. | Scroll document window record column region region culling text document. | +| `row-5` | Row dvui reflow glyph view cache reader. | Culling block region position offset data editor. | +| `row-6` | Header block culling entry value row column block. | Plugin block span row field header document. | + +## Section 10 + +Section height budget paragraph surface document column height cell editor heading budget region. Document reflow item span surface word entry viewport window cache render preview offset block surface. Paragraph sample text frame culling offset measure span scroll. Document heading text span entry sample region scroll word. Stable settle position window span column paint surface. + +## Section 11 + +Sash position view height table width wrap reflow table span plugin. Culling window entry wrap header stable dvui heading reader record table budget block surface layout. Viewport measure line document glyph word measure dvui frame viewport record culling pane. Data item row wrap row block word measure. Paragraph culling dvui sample preview item settle render. + +## Section 12 + +Fizzy viewport header wrap field document line wrap region table sash frame. Column entry paint width frame height field position dvui drag. Column paint offset sash cell cache glyph surface data render cell layout. Text cell scroll window span viewport value row window. Section heading editor render data list data cache word word section editor entry reader paint pane. + +| Column 1 | Column 2 | Column 3 | +|---|---|---| +| `row-1` | Item data field region line cache record cache. Column sample word cell scroll width budget paragraph column reflow. Glyph record offset scroll column data editor header offset dvui budget word. | Reader height table block block section column reflow preview list. Window table position record list measure editor surface. Height paragraph stable field text offset render word culling header. | +| `row-2` | Width drag culling header frame cell item. Line editor cache section height data table cache column layout span document. Document editor value glyph document position layout field. | Data paint record position plugin block data. Stable sample paragraph preview block block table list column wrap dvui. Wrap glyph fizzy render view data field wrap height reader offset. | +| `row-3` | Viewport drag heading header drag measure row window. Cell plugin reflow row entry document measure dvui. Window header view heading budget row. | Word sash layout frame pane plugin fizzy. Sash layout viewport scroll entry field height. Table text table glyph list text table data text. | +| `row-4` | Heading entry scroll sample wrap render item editor column. Column reader budget row entry value list block. Text record cell dvui cache width field data frame fizzy. | Offset document preview word preview fizzy row. Viewport height field plugin column plugin preview heading text. Value row frame height item pane. | +| `row-5` | Reader measure item width settle width view frame. Plugin preview preview position wrap record surface word line. Width span header header data paragraph fizzy preview. | View span cache cache drag window window frame. Cache view drag sample height pane height. Wrap reflow heading surface wrap plugin budget text span column wrap preview. | +| `row-6` | Wrap span scroll drag view glyph height table settle stable table region. Drag text reader drag text row block. Reflow settle preview field field reader cell list viewport data height. | Settle preview glyph pane budget line view. Reader glyph document drag dvui view line entry plugin paint. Height offset culling item header editor fizzy heading field budget. | +| `row-7` | Paragraph heading heading render position section layout column. Surface drag reflow list region frame. Document paint culling height record window glyph paint window height offset. | Width frame cell culling width header row editor value fizzy. Heading item item fizzy render window. Settle pane plugin field entry measure render item settle dvui. | +| `row-8` | Reflow region list word surface sash cell entry reflow dvui. Budget data word width scroll list region block. Word table sash column plugin pane. | Sash offset measure render section document text header document drag reader render. Culling word value settle text block culling. Budget sample section item heading item. | +| `row-9` | Culling budget dvui row measure reflow sash. Heading list cache height budget data stable. Text value line layout cell document. | Section layout cache paragraph settle reader paragraph cache paragraph. Entry measure render wrap plugin section value stable preview surface. Plugin width dvui region entry stable culling fizzy preview position column. | +| `row-10` | Width record render pane paint sash budget. Block reader record table value budget layout item render. Row line entry column dvui region render view entry section. | Heading column layout drag item drag height drag. Viewport view budget row header stable settle value glyph culling list item. Field field span row data paragraph text. | +| `row-11` | View render data scroll data section span stable drag dvui measure render. Record wrap budget window heading paint render entry header settle item cell. Span word record paragraph editor width budget region. | Sash reflow scroll paint word item. Section line entry view column table document. List offset fizzy paragraph reflow cache header entry reader value table settle. | +| `row-12` | Document paint frame viewport drag editor sample cache plugin reflow. Measure reflow item offset reader view measure word paragraph table layout. List record region section paint line viewport preview cell list block sash. | Drag cell culling position reflow paint offset cell. Window view header fizzy wrap line header table editor view. Measure glyph reflow row list word paint settle stable block. | +| `row-13` | Line reader plugin sash render budget position scroll. Surface measure preview plugin render reflow value. View preview dvui render surface budget. | Viewport table paragraph entry entry document surface. Drag width data item row editor paint. Pane cell entry glyph offset window measure position word. | +| `row-14` | Item span surface reader plugin data position frame table offset culling. Line text plugin item paint height position render pane wrap drag row. Glyph header offset frame plugin pane dvui offset cache data. | Item word paragraph cell editor section cache budget. Text viewport paint sash fizzy editor text column header block preview. Item data offset position table entry row word cache sash. | +| `row-15` | Table line data pane field text frame. Row stable budget line item offset. Table viewport measure stable dvui region preview wrap sash section width settle. | Header reflow culling field dvui record. Field text budget preview section preview pane region line sample sample reflow. Preview width measure section plugin table. | +| `row-16` | Viewport glyph paragraph culling stable plugin block span measure pane sample editor. Reader text offset editor row settle sash text preview record item. Data view record width text glyph row column pane document. | Pane sample reflow culling preview table culling paint wrap region. Height word viewport reflow fizzy entry sash field block. Block record viewport section window document text cache cache table. | + +## Section 13 + +Frame stable viewport word preview record preview frame region frame. Section plugin editor region document column stable plugin table data position. Entry record drag height culling offset offset dvui plugin editor sample plugin data data. Fizzy line item viewport viewport table view region measure wrap scroll text. Column editor heading wrap data region scroll document glyph glyph offset sample budget measure. Offset settle block table region offset drag preview. -## What stays from work already landed (A–C) +- Render sash wrap paragraph stable viewport item entry reflow reader. +- Paint frame preview cache height cell view header pane window surface width. -- Root `plugin.zig` as module root; generated hidden dylib root; hub/`root.zig` removed -- Build helper reading root `plugin.zig.zon` at configure time into `fizzy_plugin_options` -- sdk **0.35.0** fingerprint (R2: identity-only `Manifest`, no `Host` enforcement guards, no on-disk `.zon` sidecar. R4: `Host.settings_schemas`/`registerSettingsSchema` + `sdk/settings.zig`'s `Schema`/`Field`/`Access` — fizzy-drawn settings UI; no settings probe export, per the loaded-only simplification below) +This paragraph and the rule below it repeat, word for word, after every section. --- -## Locked decisions - -| Decision | Choice | -|----------|--------| -| Attribution: publisher vs author (R18) | **Two fields, deliberately not merged.** `publisher` is derived at ingest from `manifest_url`'s owner — never author-writable, so it attests where the binary actually came from. `author`/`author_url` are self-asserted `plugin.zig.zon` fields, cosmetic only. The store draws them as `publisher · author`. General rule: *popularity and trust signals are observed by the registry, never read from the author's manifest.* | -| Author-facing `plugin.zig.zon` | `id`, `name`, `version`, `min_sdk_version`, plus `description` (R16), `tags` (R17), and `author`/`author_url` (R18) — the store's plugin detail page/search need them for every plugin, not just registry-listed ones. Both are optional overrides in `registry/.json` too: the aggregator falls back to the author's own `manifest.json` copy (sourced from this file) when the registry entry leaves them blank (R17). Still no `hooks`, `contributes`, `settings`, or `profile` (removed — zero consumers, see decision log). | -| Capability SoT | `plugin.zig` (`register*` + vtable) — no declare-and-audit against a zon list | -| On-disk sidecar `{id}.zon` | **Gone** — author never manages one; install dir is dylib only | -| Settings authoring | Comptime Zig API (build-system style), not text schema | -| Disabled-plugin settings | **Loaded-only** — field controls only when the plugin is successfully loaded; disabled plugins get Enabled toggle only (no schema probe / no embedded settings zon) | -| On-disk user config | **ZON everywhere** for fizzy state — not JSON | -| Per-plugin settings storage (R10, supersedes R8; nested by R12) | **Merged into fizzy's own `settings.zon`**, as a real nested ZON struct literal keyed by plugin id — `.plugins. = .{ .enabled = true, .settings = .{…} }` (R12). Author fields live under `.settings` so they can never collide with fizzy-reserved `.enabled`. Only non-default fields are persisted; all-default + disabled removes the whole id entry. | -| File-type ownership (R19) | **Per-plugin `.extensions` list** inside each `.plugins.` block — the user's explicit, persisted choice of which plugin opens each extension. Replaces the removed numeric `fileTypePriority`; a plugin now only *offers* extensions via `fileTypes`, and `text` opts into being the catch-all with `Host.registerFallbackEditor`. Written only by an explicit user decision (install-time dialog or Settings › File Types), never by a load/reconcile path. | -| Plugin enabled state (R12, presentation refined by the R19 follow-up) | **Per-plugin `.enabled` field** (default `false`) inside each `.plugins.` block — retires fizzy-level `disabled_plugins` list. Store installs write `.enabled = true` immediately; a dylib dropped into `plugins/` is never auto-executed and triggers no settings.zon write. Absence of the field is its own state: such a build is **undecided**, not disabled — the store offers it a `Load` button and the sidebar shows a count badge, and loading it prompts for file-type ownership like a store install. An explicit `.enabled = false` is a settled decision and stays quiet. | -| Plugin install directory (R10) | **One directory per plugin**: `{config}/plugins/{id}/{id}.{dylib,so,dll}` (and the same nesting for exe-bundled built-ins) — a plugin can use its own directory for assets/data (`Host.pluginInstallDir`) | -| settings.zon file watching (R11, nightwatch in R12) | **Live, on by default, best-effort** — `nightwatch.Default` recursively watches `/` (covers `settings.zon` + newly-created plugin dirs). Not configurable/toggleable; a failed watcher start just disables live reconciliation for that session rather than failing to launch | -| CI SoT | `plugin-build-action` reads `plugin.zig.zon` for id/version/min_sdk; tag must match `.version`; `minimum_zig_version` stays in `build.zig.zon` | -| Integrity / sandbox | Drop hard-reject-undeclared; keep ABI fingerprint + sdk version gates | -| `fizzy` dependency pin (external plugins) | **`sdk-v` git tag**, not an arbitrary commit SHA. Kept in this repo (no separate SDK package/repo) — the tag is just a legible ref into the existing monorepo, auto-created by CI so it can never drift from `recorded_sdk_shape_fingerprint` | - -### ZON on disk (drop JSON for app config) - -| File | Role | -|------|------| -| `{config}/settings.zon` | Fizzy's `Settings` fields **plus** a `.plugins = .{ . = .{ .enabled, .auto_update, .extensions, .settings }, ... }` field (R10 merge, R12 nesting + non-default-only). Both halves record only what differs from their declared defaults (R12 for plugins, R13 for fizzy's own fields) | -| `{config}/plugins/{id}/{id}.{dylib,so,dll}` | Where that plugin's own binary lives — one directory per plugin (R10), usable for its own assets/data too (`Host.pluginInstallDir`) | -| `{config}/recents.zon` | Recent folders list | -| (existing) `window_frame.zon` | Unchanged | - -- No `std.json` on these load/save paths after migration. -- Host plugin settings APIs speak zon (not JSON); `settingsChanged` blob is zon. -- One-shot migration from `*.json` → write ZON → delete JSON after successful migrate. - -**Still JSON (out of scope):** store `manifest.json`, theme stubs `themes/*.json`, json language plugin. +## Section 14 ---- +Cache field word scroll reflow cache list stable surface wrap surface. Line document paint data sample measure column header culling text position. Viewport reader field fizzy viewport plugin field window list measure preview cache document span heading. Cache render cache span plugin view text wrap sample offset. Sash drag list stable word glyph column entry. Layout header window plugin field span fizzy value cache table offset culling entry measure. + +- Paint height section data record row header offset plugin sash. +- Header reader budget fizzy budget field window row dvui viewport document reader. -## Settings: comptime API (landed R4) - -```zig -const MySettings = sdk.settings.Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: u8 = 4, - format_on_save: bool = false, -}); -// register(): -MySettings.load(host, plugin.id, &values); -try MySettings.register(host, &plugin, .{ .title = "Text Editor", .value = &values }); -// Fizzy's PluginSettingsPane draws shared controls — plugins do not supply `draw`. -``` - -`Schema(T)` comptime-walks `T`'s fields into a `Setting` table (name, label, a `Kind` union -tagged by `TypeTag` — bool/int/float/string/enum/color — carrying only the metadata that type -actually needs: `IntKind{min,max,choices}`, `FloatKind{min,max,step}`, `EnumKind{choices}`, void -for bool/string/color) and generates -`load`/`store`/`applyZon` (a zon round-trip on `T`) alongside `register`, which wires a -`SettingsSchema` + typed `Access` vtable into `Host.settings_schemas`. Plugins register metadata + a -living value only; fizzy draws every control so settings share one appearance. The schema -exists in the Host's registry only while the owning plugin stays registered — no -`fizzy_plugin_settings_zon` export, no dylib probe, no on-disk sidecar. -`src/editor/PluginSettingsPane.zig` draws every registered schema plus an Enabled-toggle-only -row per disabled plugin and a reason per failed-to-load plugin. +This paragraph and the rule below it repeat, word for word, after every section. --- -## Explicitly cancelled (prior plan) +## Section 15 -- Hard-reject undeclared `register*` / hooks against zon lists -- Author-written settings schema in `plugin.zig.zon` -- On-disk `{id}.zon` sidecar, self-heal, byte-compare tamper -- “Transparency manifest of every contribution” -- Keeping plugin settings blobs as JSON through a late phase -- Disabled-plugin schema via dylib probe / embedded settings zon (simplified away 2026-07-20) +Surface entry cache paragraph editor header column frame measure frame field pane window header. Item height span line view view header row position. Column data reader text layout preview line settle value glyph width culling data budget. Drag line cell preview culling scroll entry reader stable budget data paint. Word fizzy fizzy budget text settle value line span view paint document paint pane. Width heading reader height region document position layout row row culling. ---- +- Section height section surface width layout data render paint table. +- Plugin frame cache sample drag view region item paint position height reflow. -## Phases - -### R2 — Identity-only zon + no sidecars -Slim built-in `plugin.zig.zon`; slim Manifest/readManifest; remove sidecar install; keep identity injection. - -### R3 — Fizzy config JSON → ZON -`Settings.zig` / `Recents.zig` / `Editor.zig` → `settings.zon` + `recents.zon`; Host holds zon; migrate from JSON. - -### R4 — Comptime settings (loaded-only UI) -`sdk/src/settings.zig` (`make` + register); migrate text; settings pane shows fields only for loaded plugins (Enabled toggle for disabled). - -### R5 — Store + CI SoT -PluginStore identity probes as needed; describe plugin-build-action reading `plugin.zig.zon`. - -### R6 — Docs + external -PLUGINS.md / CLAUDE.md; zig/json/ghostty/pixi on new pin. - -### R7 — `sdk-v*` tag namespace - -**Problem.** §2.3's `zig fetch --save=fizzy https://github.com/fizzyedit/fizzy/archive/.tar.gz` -pins the whole fizzy repo by an opaque commit SHA. Nothing about the ref communicates which -`sdk_version` it corresponds to, and there's no tooling (bot or human skim) that can reason about -bumping it the way a tagged semver dependency can. - -**What was considered and rejected.** Splitting `sdk/src/**` into its own repo/package so plugins -depend on something smaller than the whole monorepo — rejected: fizzy's `build.zig.zon` already -plays double duty as "full app" / "sdk-only" via the `plugin_sdk` build option -(`b.dependency("fizzy", .{ .plugin_sdk = true })`), so a second package would only trim fetch -size, at the cost of a second place to develop the SDK. Not worth it while nothing is straining -on fetch size or on the leak guard in `CLAUDE.md`'s "Keep the plugin build free of app-only -dependencies" (that guard is enforced by discipline — `lazy = true` + `b.lazyDependency` inside -`build/app.zig` — independent of how many repos the SDK lives in). - -**Decision.** Keep the SDK inside this repo. Add a second tag namespace, distinct from the app's -`v*` release tags: **`sdk-v`** (e.g. `sdk-v0.1.35`), pushed at the exact commit where -that `sdk_version` (and its matching `recorded_sdk_shape_fingerprint`) was recorded in -`sdk/src/version.zig`. `release.yml`'s trigger (`tags: - "v*"`) is a glob on the ref's start; -`sdk-v0.1.35` does not start with `v`, so it can never fire the app release/package pipeline — -the two tag namespaces are non-colliding by construction. Plugin authors (and -`docs/PLUGINS.md` §2.3's `zig fetch` instructions) pin against the tag instead of a commit SHA. - -The tag is created automatically, not by an operator remembering to: `.github/workflows/ -sdk-tag.yml` runs on push to `main`, parses `sdk_version` out of `sdk/src/version.zig`, and -pushes `sdk-v` if that tag doesn't already exist. This mirrors how -`recorded_sdk_shape_fingerprint` itself is enforced mechanically (`zig build test-sdk-version` -fails at compile time on drift) rather than by review discipline — the tag now moves in lockstep -with the version bump instead of trailing it. No build/package/artifact step is needed for this -tag (unlike `v*`/`release.yml`); it's a plain ref push, no GitHub Release required. - -**Renumbering alongside this (also R7):** `sdk_version` shifted from the old "bump minor on -every boundary change" scheme (0.5.0 → 0.35.0, patch always 0) to `patch` = ordinary -boundary-change counter, `minor` = manually-bumped compatibility epoch, `major` = reserved for -1.0 — see `sdk/src/version.zig`'s doc comment on `sdk_version` and its 0.1.35 changelog entry for -the full rationale. Done as a one-time renumbering (0.35.0 → 0.1.35, not on to 0.36.0) because it -was free: no `sdk-v*` tag existed yet and no in-repo `plugin.zig.zon` pins a non-empty -`min_sdk_version`. Will not repeat once a real tag or external pin exists. +This paragraph and the rule below it repeat, word for word, after every section. --- -## External migration checklist (zig / json / ghostty / pixi) - -Local reshape done 2026-07-20 against sdk **0.35** (`.path = "../../fizzy"` until fizzy publishes the commit). Re-release when ready: +## Section 16 -1. ~~Add identity-only `plugin.zig.zon`~~ done -2. ~~Move `src/plugin.zig` → root `plugin.zig`; delete `root.zig` + package-root hub~~ done (pixi keeps `src/pixi.zig` as an intra-plugin type hub, renamed from `src/mod.zig`) -3. ~~Drop `pub const manifest`; `fizzy.plugin.create(b, .{ .target, .optimize })` only~~ done -4. ~~Prefs → `sdk.settings.make` + zon blobs (pixi); fizzy-drawn UI~~ done -5. Rebuild / re-release: flip `build.zig.zon` fizzy pin back to URL+hash; `release.yml` is slim (`@v3`, no hand-copied sdk/fingerprint); tag == `plugin.zig.zon` `.version` +Region cache header fizzy data line reader position document frame dvui entry sample glyph frame. Region entry render paint editor list window header line render height dvui data height position. Dvui paragraph row window line editor stable fizzy item frame plugin sash text sash preview field. Render measure reader surface value list sample wrap wrap preview pane culling. Settle paint dvui cell column span view offset position span dvui frame. Reflow list entry stable pane pane value fizzy cell word row fizzy row. -## Verification +- Data document offset entry plugin region paragraph layout paragraph item. +- Sash settle preview entry record reflow row cell paragraph paint column data. -1. `zig build` / `test` / `test-sdk-version` / `check-web` -2. `zig build install` → `{id}/{id}.{ext}` (its own directory, R10) in plugins dir, no `.zon` sidecar -3. Config dir: `settings.zon` (fizzy fields + `.plugins.` per loaded/disabled plugin, R10) + `recents.zon` -4. Disabled plugin: Enabled toggle only (no field controls); its `.plugins.` settings still persist -5. Loaded plugin edit → zon persist (merged into `settings.zon`, R10) + settingsChanged -6. Uninstall a plugin → its whole `{id}/` directory is gone, but `.plugins.` in `settings.zon` survives -7. CI: document `plugin.zig.zon` as SoT (action still takes workflow inputs until it reads zon) +This paragraph and the rule below it repeat, word for word, after every section. --- -## Decision log - -| Date | Decision | -|------|----------| -| 2026-07-20 | Original capability-manifest + sidecar plan approved | -| 2026-07-20 | **Revised:** identity-only zon, no sidecars, comptime makeSettings, ZON user config | -| 2026-07-20 | Drop JSON for settings/recents/plugin blobs | -| 2026-07-20 | **Simplify:** settings UI loaded-only — drop dylib settings probe + embedded settings zon | -| 2026-07-20 | R4 landed: `sdk.settings.make(T)` + `Host.settings_schemas` + `PluginSettingsPane`; `text` migrated. sdk 0.34.0 | -| 2026-07-20 | **R7 planned:** replace commit-SHA fizzy pin with auto-created `sdk-v` git tags; rejected splitting the SDK into a separate repo/package (fizzy's `plugin_sdk` build option already scopes the dependency; a second repo would only trim fetch size, not worth the added dev-location split) | -| 2026-07-20 | **R7:** one-time `sdk_version` renumbering 0.35.0 → 0.1.35 — patch now counts ordinary boundary changes (what minor used to do), minor reserved as a manually-bumped compatibility epoch. Safe only because no `sdk-v*` tag or external `min_sdk_version` pin existed yet; not to be repeated | -| 2026-07-20 | Removed `Manifest.profile` (`.editor`/`.utility`/`.other`) — zero consumers anywhere (no runtime read, no UI grouping, no comptime capability check; that's `Plugin.assertEditorVTable`/`assertUtilityVTable`, unrelated and unaffected). `Manifest` is off `sdk_boundary_types`, so no `sdk_version`/fingerprint bump needed. Dropped from every built-in and external `plugin.zig.zon` in the same pass | -| 2026-07-24 | **R10: reverted R8**, merging per-plugin settings back into one `settings.zon` (nested ZON struct literals keyed by id, not escaped strings) — R8's stated reasons (no escaping, hand-editable) turned out to be achievable in a merged file too, once `std.zig.Ast`/`ZonGen`/`Zoir` (public, the same machinery `std.zon.parse` uses) could locate a named field's exact source span without a full re-serialize. Also landed per-plugin install directories (`{id}/{id}.{ext}`, both config-folder and exe-bundled layouts) in the same pass, since both changes touch the same install-path plumbing. Explicitly did **not** carry forward the pre-R7 JSON/embedded-blob migration paths (deleted, not kept working) — user call: only the current (R8) layout needed a forward migration | -| 2026-07-24 | **R11 planned, not built:** a settings.zon file watcher + hand-edit reconciliation. Deliberately scoped out of R10 — R10 only had to make sure its design doesn't need reworking once a watcher lands (explicit or-die requirement from the user going in); see R10's row above and R11's own row for the seams left in place (`Editor.settings_last_saved_hash` as the future "was that write mine" filter, `Plugin.VTable.settingsChanged` as the already-correct plugin notification path) | -| 2026-07-24 | **R11 landed**, same day: watcher + reconciliation shipped in full scope (both plugin-settings *and* fizzy-field/`disabled_plugins` reconciliation in one pass — user chose the larger scope over a plugin-settings-only first cut when asked). Rejected reusing `std.Build.Watch` (what `zig build --watch` uses) directly — it's real and cross-platform, but its API is keyed on `*std.Build.Step`/`Cache.Path` and importing it drags in all of `std.Build`; used it only to confirm the right native primitives exist, then wrote lighter single-file-scoped backends (inotify not fanotify on Linux; kqueue not FSEvents/`dlopen` on macOS — both are the standard "one file" tools where Zig's own choice was scoped for a whole project tree instead) | -| 2026-07-24 | **R12 landed**, same day: (1) non-default-only `Schema(T)` persistence + remove-via-null pending buffer; (2) per-plugin `.enabled` nested beside `.settings`, retiring `disabled_plugins` — dropped-in dylibs stay disabled with no write, store installs keep auto-load; (3) replaced the three hand-rolled watcher backends with [`neurocyte/nightwatch`](https://github.com/neurocyte/nightwatch) (MIT, `minimum_zig_version = "0.16.0"`, recursive dir watch). Fingerprint unchanged. | -| 2026-07-30 | **R17 landed:** `tags` added to `Manifest` alongside R16's `description`, with the identical registry→builtin→dylib-probe fallback chain (`tagsFor`/`builtinTags`/`probeTags`). Also closed the registry-side duplication this exposed: `plugin-build-action`'s `manifest.json` now carries `name`/`description`/`tags`, and `fizzyedit/plugins`'s ingest prefers a `registry/.json` entry's own `description`/`tags` but falls back to the fetched manifest's when the entry leaves them blank — so authors no longer have to hand-duplicate either value into their registry PR. `author` stays registry-only by design (attribution isn't a build-declared property). No new UI for tags (no chips/filter row) — this pass is data-plumbing only, same scope R16 shipped for `description` before its own detail-page work. **Still needed before this reaches real plugin authors:** cut + push a `v4` tag on `plugin-build-action` (existing callers pin `@v3`) and bump each external plugin repo's `release.yml` to it — deliberately not done as part of this pass (tag push = shared CI, needs its own go-ahead). | -| 2026-09-03 | **R19 landed:** numeric `fileTypePriority` removed outright in favor of explicit, persisted, per-extension user ownership — `fileTypes` offers, `registerFallbackEditor` opts into catch-all, `.plugins..extensions` records the decision, an install/update-scoped dialog asks once, and Settings › File Types is the permanent home (with keybind-style conflict surfacing for hand-edited files). sdk 0.1.51. | +## Section 17 + +Document width budget data paint drag culling surface fizzy frame. Cell cell preview settle row wrap fizzy view section height region frame heading preview pane. Cache reader offset measure document budget entry entry table culling. Width field entry wrap value entry data text field scroll measure. Text table pane dvui sash sash budget preview height pane dvui document. Plugin settle value row measure render window window width pane value header. Text viewport offset preview surface render stable header sample paint budget. Entry plugin record editor document block wrap settle cell value height drag region editor fizzy dvui. Cell preview viewport height column reflow stable glyph offset frame paint. Document view block block scroll cache column wrap measure document. Text fizzy viewport drag plugin text culling view offset field pane. Data stable reflow row paragraph cell section header dvui budget culling table editor surface width item. Height glyph editor sash text row table view render view word scroll word sample. Fizzy culling drag height editor table block sample. Field wrap preview scroll row window column width layout render culling document word reflow position frame. Paint record item region paragraph cell editor list scroll entry wrap. Data measure drag heading reflow position header frame column region plugin glyph. Plugin sash measure line block value reader viewport scroll view offset frame fizzy width table word. Header item settle field item pane dvui culling. Column stable table surface plugin entry cache reader layout measure text drag. Entry layout wrap item dvui viewport block glyph line position block value. Drag fizzy entry region span stable drag paint text measure pane record data. Reflow position header record surface header viewport cache render record render budget. Editor reflow plugin span data reflow cell sample plugin section render viewport. Sash list entry window entry layout cache record height preview reader layout culling. Window column position width glyph window fizzy region heading window frame glyph region culling. Sash sash line data view entry span editor plugin window list region text section table cell. Span span dvui item scroll culling scroll surface. Surface line table word plugin document value reflow list row table region dvui span. Text wrap paint width settle glyph drag culling column row reflow view item window cache cell. Reflow table pane entry table sash sash value culling. Dvui measure frame cache settle text text view region glyph view. Paint render settle surface frame window surface settle region offset frame record stable reader line. Viewport span culling frame dvui section cell width item viewport heading plugin header word measure culling. Word block column layout editor value row stable field position record window preview position. Field paragraph row block row line fizzy cell drag cell line. Block text paint view preview document word text position block culling measure position list pane. Position stable position cell cell offset record offset. Region reader entry fizzy document drag glyph entry value row. Word block cache value offset preview paragraph value. Window offset culling table sash editor render reflow glyph offset glyph reflow height settle value dvui. Position editor layout item fizzy height layout drag text. Reflow drag preview fizzy row layout offset editor cache. Measure drag width region cell reader entry reader record editor heading paragraph reflow glyph. Word viewport plugin sample width offset drag row measure entry measure width editor entry stable data. Field sash field wrap paint paragraph record table measure preview paint editor view. Line sample offset line span settle surface line document data scroll heading stable entry table wrap. Reflow paragraph cache cache surface width line frame line dvui document width sash data glyph viewport. Cache field editor word budget document window reader list region column. Layout preview list span text data editor pane. Dvui layout paragraph span dvui wrap span row layout view. Render render block reader editor table drag sample record record culling cache editor viewport line sash. Sample section glyph value offset glyph block paint heading preview layout pane frame width entry. Editor settle window frame measure stable measure pane span. Data offset document render surface line preview budget. Render view row settle row scroll width culling entry glyph header document line budget wrap budget. Width document cell sample column height viewport entry block. View viewport region cell drag cell word frame block. Measure heading width height list record settle cell. Culling wrap surface budget sample list block scroll document sample text. diff --git a/tests/data/markdown_sample_tables.py b/tests/data/markdown_sample_tables.py new file mode 100644 index 000000000..6e519b25f --- /dev/null +++ b/tests/data/markdown_sample_tables.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Generates tests/data/markdown_sample_tables.md, the markdown preview's table-heavy test input. + + python3 tests/data/markdown_sample_tables.py > tests/data/markdown_sample_tables.md + +Seeded, so the output is the same every run. The words are noise; the shape is what the tests in +tests/integration.zig need: + +- One very large table (~48KB) whose cells wrap over many lines, plus three smaller ones. A table + whose cells wrap is what a deferred height has to get right before its rows are laid out, and + what row culling inside a table has to keep stable while the reader scrolls through it. +- Blocks whose source repeats (rules, a stock paragraph), so source hashes are not unique and + anchoring has to pick the nearest copy, not the first. +- One paragraph several thousand characters long, for bench-markdown's "prose" sample. + +Everything after the tables is appended after them on purpose. The tests scroll in fixed steps, so +a block inserted above a table moves which frames see it, and on the parent of the change that +introduced this file such a shift was enough to hide the bug "block heights do not depend on where +the reader is" exists to catch. Check that test still fails without its fix before reshaping this. +""" + +import random +import sys + +random.seed(20261009) +words = ( + "table row cell column header width wrap line block layout height scroll reader position " + "document preview measure settle frame paint glyph text span word paragraph section heading " + "list item value field entry record sample data plugin surface region pane window view " + "editor fizzy dvui render cache budget stable reflow sash drag offset viewport culling" +).split() + + +def sentence(n): + return " ".join(random.choice(words) for _ in range(n)).capitalize() + "." + + +def para(k): + return " ".join(sentence(random.randint(8, 16)) for _ in range(k)) + + +def table(cols, rows, sentences_per_cell): + t = ["| " + " | ".join(f"Column {c + 1}" for c in range(cols)) + " |", "|" + "---|" * cols] + for r in range(rows): + cells = [f"`row-{r + 1}`"] + [ + " ".join(sentence(random.randint(6, 12)) for _ in range(sentences_per_cell)) + for _ in range(cols - 1) + ] + t.append("| " + " | ".join(cells) + " |") + return t + + +# The same source, more than once: every copy hashes alike. +stock = "This paragraph and the rule below it repeat, word for word, after every section." + +out = [ + "# Markdown sample: tables", + "", + "A synthetic, table-heavy document for the markdown preview's layout tests: one very large table", + "whose rows wrap over many lines, a few smaller ones, and prose between them. Its words mean", + "nothing; its shape is what the tests need (row culling inside a table, block skipping around it).", + "", +] +sec = 1 +for cols, rows, per_cell in ((3, 29, 14), (3, 17, 2), (3, 6, 1), (3, 16, 3)): + for _ in range(3): + out += [f"## Section {sec}", "", para(5), ""] + sec += 1 + out += table(cols, rows, per_cell) + [""] +for _ in range(4): + out += [f"## Section {sec}", "", para(6), "", "- " + sentence(10), "- " + sentence(12), ""] + out += [stock, "", "---", ""] + sec += 1 +out += [f"## Section {sec}", "", para(60), ""] +sys.stdout.write("\n".join(out)) diff --git a/tests/integration.zig b/tests/integration.zig index 839fe3efe..4aa317803 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -631,10 +631,12 @@ const md_render_ast = markdown.render_ast; var md_preview: markdown.Preview = .{}; var md_doc: []const u8 = ""; const md_sample = @embedFile("markdown_sample"); -/// Table-heavy (`tests/data/markdown_sample_tables.md`, a frozen copy of what was -/// `docs/PLUGIN_MANIFEST_PLAN.md` before the plans moved to `fizzyedit/plans`): one of its tables -/// is 45KB on its own, which is what makes it the document that exercises row culling inside a -/// table rather than only block skipping around it. +/// Table-heavy (`tests/data/markdown_sample_tables.md`, generated by the script beside it): one of +/// its tables is 48KB on its own, which is what makes it the document that exercises row culling +/// inside a table rather than only block skipping around it. Its cells wrap over many lines, and +/// that is not decoration. A frozen copy of an old plan doc stood here before it, and its tables' +/// cells mostly fit on a line or two — so a table whose height came out a few lines different +/// laid out than measured off screen passed every test here while the reader saw it move. const md_sample_tables = @embedFile("markdown_sample_tables"); /// Image-heavy. Both samples above are prose and tables, so without this one no test in this file /// ever laid out an image block — the block kind whose height nothing in the source predicts, and @@ -836,8 +838,7 @@ test "markdown preview: scrolling deep and back returns to the same place" { defer md_preview.deinit(); try markdownSettle(); - // Three quarters of the way down — the region the instability was reported in, and on this - // document the one holding the 45KB table. + // Three quarters of the way down — the region the instability was reported in. const max_scroll = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; try std.testing.expect(max_scroll > 2000); // it really is a long document md_preview.scroll.scrollToOffset(.vertical, max_scroll * 0.75);