docs/internal/plugin-widget-library-design.md
Status: foundation shipped, one plugin migrated end-to-end, several widget kinds and the Compositor still to build. See §2 for what's in tree, §3 for how to pick up the work, §4 for the remaining roadmap.
Related:
docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md,
docs/internal/unified-hit-test-theme-plan.md,
docs/internal/unified-keybinding-resolution.md,
docs/internal/event-dispatch-architecture.md,
docs/internal/visual-layout-unification.md,
docs/internal/plugin-usability-review.md,
docs/internal/settings-controls-usability-report.md
Design criterion: end-state UX, robustness, flexibility. Shipping speed is explicitly not a constraint. See Appendix A for the rejected TS-only alternative that optimizes for the opposite tradeoff.
Hybrid: a Rust-resident widget runtime with a thin TypeScript
declarative front-end. Plugins describe widgets as data, the host
reconciles, owns layout / hit-test / focus / cursor, and emits
semantic events. The existing setVirtualBufferContent primitive
stays as the escape-hatch.
The design rationale, comparison against pure-TS and pure-Rust alternatives, and the structural reasons this is the only shape that satisfies the brief's five constraints (per-keystroke cost, theme, reach, backward compat, sandboxing) live in §10 below. Read those if you're picking up the work and need the why before the what.
The runtime is real. Plugins can mount widget panels today; one
plugin (search_replace.ts) is migrated end-to-end across the bulk
of its UI. cargo check workspace clean, widget unit tests green,
tsc clean, interactively verified in tmux.
Rust runtime (crates/fresh-editor/src/widgets/)
| File | Purpose |
|---|---|
mod.rs | Public surface: re-exports render_spec, RenderOutput, FocusCursor, WidgetRegistry, HitArea, PanelId, WidgetPanelState, WidgetInstanceState, find_widget_by_key, apply_text_char, apply_text_input_key, apply_text_area_key, apply_text_key, set_toggle_checked_in_spec, set_list_items_in_spec, set_tree_nodes_in_spec, set_tree_checked_keys_in_spec, tree_parent_index. |
registry.rs | WidgetRegistry: panel_id → WidgetPanelState { buffer_id, spec, hits, instance_states, focus_key, tabbable }. Hit-test, get/get_mut, focus_key getter/setter, mount/update/unmount, panels_for_buffer (used by the wheel-scroll path). |
render.rs | The reconciler. render_spec(spec, prev_state, prev_focus, panel_width) → RenderOutput { entries, hits, instance_states, focus_key, tabbable, focus_cursor }. Two-pass Row layout for flex spacers. Per-widget renderers (render_hint_bar, render_toggle, render_button, render_tree_row, plus inline list rendering). The Text arm dispatches on rows > 1 to internal render_text_input (single-line, bracket form) or render_text_area (multi-line block) — both kept as testable units, but one match arm at the spec layer. |
actions.rs | Pure helpers used by dispatch: apply_text_char (UTF-8-correct insertion at cursor for printable / IME-committed text — single helper used by both single-line and multi-line); apply_text_input_key (Backspace/Delete/arrows/Home/End with UTF-8 boundary handling); apply_text_area_key (adds Up/Down/Enter and line-relative Home/End on top of the single-line keys); apply_text_key(value, cursor, key, multiline) (the public dispatcher — picks between the two based on multiline); find_widget_by_key; set_toggle_checked_in_spec; set_list_items_in_spec; set_tree_nodes_in_spec (replaces nodes + item_keys for WidgetMutation::SetItems on a Tree); set_tree_checked_keys_in_spec (stamps Some(checked) onto every node whose item-key is in the supplied list, for WidgetMutation::SetCheckedKeys); tree_parent_index (parent lookup for cascading checkbox updates). Note: there is no set_tree_expanded_keys_in_spec — expanded keys live in instance state, not the spec, so the SetExpandedKeys mutator writes directly to WidgetInstanceState::Tree::expanded_keys without a spec helper. |
Core types (crates/fresh-core/src/api.rs)
| Type | Notes |
|---|---|
WidgetSpec (enum, tagged) | Variants: Row, Col, HintBar, Toggle, Button, Text, List, Tree, Spacer, Raw. Text is the single text-input variant — rows: 1 (default) selects single-line behaviour ([value] rendering, Enter-advances-focus, head-truncate scroll); rows >= 2 selects multi-line (block rendering, Enter-inserts-newline, line nav, vertical scroll). The previous TextInput and TextArea variants collapsed into this one. Tree carries checkable: bool (default false) gating per-row [v]/[ ] glyphs; TreeNode carries checked: Option<bool> (None = no glyph for that row). |
TextPropertyEntry (fresh-core::text_property) | Row-content payload for List, Tree, and Raw. Carries text, inline_overlays: Vec<InlineOverlay>, segments: Vec<StyledSegment>, pad_to_chars: Option<u32>, truncate_to_chars: Option<u32>. The host calls normalize_widths on each visible entry — segments concatenate into text with one Char-unit overlay per styled segment, then truncate, then pad, then char→byte conversion for any remaining char-unit overlays. Plugins describe row content structurally and never name byte/codepoint offsets between segments. |
InlineOverlay | start, end, style, properties, unit: OffsetUnit { Byte, Char } (default Byte). Char offsets resolve to bytes during normalize_widths. |
StyledSegment | text + optional style + optional nested overlays. Building block for TextPropertyEntry::segments. |
HintEntry, ButtonKind, WidgetAction, WidgetMutation | Shapes referenced by the spec / IPC. |
PluginCommand::MountWidgetPanel, UpdateWidgetPanel, UnmountWidgetPanel | Spec lifecycle. |
PluginCommand::WidgetCommand { panel_id, action } | Routes a WidgetAction (key dispatch / focus / activate / select-move / text-input). |
PluginCommand::WidgetMutate { panel_id, mutation } | Targeted in-place mutation (the "Path A" fast path). setValue / setChecked / setSelectedIndex / setItems / setExpandedKeys / setCheckedKeys. |
HookArgs::WidgetEvent | widget_event hook payload: panel_id, widget_key, event_type, payload. Fired for select / activate / toggle / change / expand. toggle is emitted both by Toggle widgets (payload { checked }) and by the per-row checkbox glyph on a checkable: true Tree (payload { key, index, checked }); the host fires the same toggle event when Space is pressed on a focused checkable-Tree row whose checked is Some(_). |
Dispatch glue (crates/fresh-editor/src/app/)
| File | Touch point |
|---|---|
mod.rs | widget_registry: WidgetRegistry field on Editor. |
editor_init.rs | Constructor seeds the registry. |
plugin_dispatch.rs | handle_mount_widget_panel, handle_update_widget_panel, handle_unmount_widget_panel, handle_widget_command, handle_widget_mutate. rerender_widget_panel (host-side re-render after focus advance / selection move / mutator). apply_widget_focus_cursor (translates RenderOutput.focus_cursor to a buffer hardware-cursor position + show_cursors). widget_panel_width (best-effort buffer width for flex layout). |
click_handlers.rs | Mouse click on a widget's hit area moves focus + fires widget_event. |
TS surface (crates/fresh-editor/plugins/lib/)
| File | Exports |
|---|---|
widgets.ts | Builders: row, col, hintBar, toggle, button, text (the unified text-input builder; opt into multi-line with rows: N), textInput(value, opts) and textArea(opts) (thin ergonomic wrappers around text({ rows: 1 }) / text({ rows: 5 })), list, tree ({ checkable } opt-in), treeNode ({ checked: bool | undefined }), spacer, flexSpacer, raw, styledRow, parseHintString. Action builders: key, focusAdvance, activate, selectMove, textInputKey, textInputChar (action names kept stable; both single-line and multi-line widgets receive them). WidgetPanel class with set / command / mutate / setValue / setChecked / setSelectedIndex / setItems / setExpandedKeys / setCheckedKeys / unmount. |
index.ts | Re-exports the above (incl. text and textArea). |
fresh.d.ts | Generated. editor.mountWidgetPanel, updateWidgetPanel, unmountWidgetPanel, widgetCommand, widgetMutate. WidgetSpec (Text variant replaces the old TextInput/TextArea pair), HintEntry, ButtonKind, WidgetAction, WidgetMutation (incl. SetCheckedKeys), StyledSegment, OffsetUnit types. widget_event hook (incl. toggle payload from checkable Tree rows). |
Plugin migration: search_replace.ts
| Migrated | Status |
|---|---|
| HintBar (footer) | parseHintString(t("panel.help")) → hintBar(...). Theme-keyed key styling. |
| Options row (3 toggles + Replace All button) | row(toggle("case"), toggle("regex"), toggle("whole"), flexSpacer(), button("replaceAll", { intent: "primary" })). Right-aligns the button via flex. |
| Search / Replace text fields | textInput(...) (single-line wrapper around text({ rows: 1 })). Constant-width with head-truncate scrolling, host-owned hardware cursor. |
| Match tree | tree({ nodes, itemKeys, selectedIndex, visibleRows, expandedKeys, checkable: true, key: "matchTree" }). Widget-owned scroll, expansion, click-to-select, Enter-to-activate, disclosure-glyph hit area. Per-match exclusion: treeNode({ checked }) on every row; click on the [v]/[ ] glyph or Space on a focused row fires widget_event "toggle"; plugin updates result.selected and pushes the new state via panel.setCheckedKeys. File-row glyph reflects "every match selected" (mixed renders as [ ]). Replace All filters by selected. |
| Mode bindings (Tab / Shift+Tab / Enter / Space / Backspace / Delete / Home / End / Up / Down / Left / Right / PageUp / PageDown / mode_text_input) | All route through dispatch(widgetKey("Tab")) etc. The smart-key dispatcher in core handles based on focused widget kind. PageUp/PageDown move List/Tree selection by visible_rows - 1 (one row of overlap). Space on a focused checkable-Tree row dispatches toggle instead of activate. |
widget_event handlers (change / select / activate / toggle / expand) | Plugin updates its app model from events; Toggle widgets write back via panel.setChecked; checkable-Tree toggle writes back via panel.setCheckedKeys; selection / value / expansion changes don't re-emit spec. |
What's not migrated in search_replace.ts: the matches-section
separator (still in Raw), the truncated warning in matchStats
(bespoke RGB), the panel.focusPanel/queryField/optionIndex
legacy state (kept around but no longer authoritative). These are
not blockers for any flow; they're cleanup.
Theme keys actually used by widgets today
| Widget area | Theme key |
|---|---|
| HintBar key portions | ui.help_key_fg |
| Toggle "checked" glyph | ui.tab_active_fg |
| Focused widget bg/fg | ui.menu_active_bg / ui.menu_active_fg |
| Button "danger" intent | ui.status_error_indicator_fg |
| Text focused bg | ui.prompt_bg |
| Text placeholder | ui.menu_disabled_fg |
| List selected row | ui.menu_active_bg (extend_to_line_end) |
These are all reuses of pre-existing keys. The role-based theme
system from §11 is not yet implemented — plugins still implicitly
pick theme keys via intent: "primary" | "danger" enums; no
per-spec theme override map yet.
Decisions taken on items considered but not pursued:
WidgetMutation::SetSpec).
Skipped. The reconciler already preserves instance state across
a full panel.set(spec) re-emit, so a SetSpec fast path is a
pure IPC-byte optimization with no UX consequence; revisit only
if profiling on a large-spec panel shows it matters.Tabs / Group widget. Skipped — no in-tree consumer.
git_log.ts's "tab" toolbar is a strip of action buttons, not
a UI tab switcher; the buffer-group panes are managed by the
editor's panel manager outside the widget runtime. Revisit when
a real consumer appears.Remaining work, in rough decreasing user impact:
Prompt / Layer / Compositor (§7). The big architectural
piece. Today Popup, Prompt, showActionPopup, hover
tooltips, completion popups all live in separate subsystems.
Unifying them under one Compositor with a mountLayer IPC
subsumes a lot of duplicated focus/dismiss/event-routing logic,
but no plugin can currently mount a tooltip or modal via the
widget runtime.Transient widget (Magit menu). Discoverability per
plugin-usability-review.md. Falls out as one kind of Layer.Table widget. git_log.ts log, find_references.ts,
audit.Role::Action, Role::Destructive, …) and the host resolves
to theme keys. Today the renderer's theme keys are hardcoded in
widgets/render.rs. Adding a roles.rs translation layer lets
plugins override per-widget without touching colors and lets
accessibility variants (high-contrast, color-blind) drop in.embed). The Spec is already data;
what's missing is the persistence layer and the plumbing to
re-render every active panel on a theme_changed event.lib-widgets.i18n.json.mode_text_input:<char> →
WidgetAction::TextInputChar → apply_text_char (one shared
helper for single-line and multi-line Text). Multi-byte
codepoints, multi-codepoint single-event commits, and
step-by-step IME commits all round-trip with byte-correct
cursor advancement; covered by apply_text_char_* unit tests
in widgets/actions.rs. What's still missing is preedit
display — the in-flight composition glyph rendered in the
widget before commit. That depends on the input layer
surfacing preedit events, which server/input_parser.rs does
not currently parse (crossterm has no native preedit signal,
and the editor doesn't yet handle the kitty-keyboard /
bracketed-IME extensions). When that lands, the widget side
is a small addition: an optional preedit: Option<{ text, byte_offset }> field on WidgetInstanceState::Text, a
compose entry point parallel to apply_text_char, and an
underline overlay in the renderer. Plugins get preedit for
free across both single-line and multi-line.apply_text_input_key only handles single-key edits; chords
(g g) still bubble to the plugin's defineMode.view/controls/* renderers shared with widgets. Today widgets
have their own renderer in widgets/render.rs; the Settings
renderer is separate. Sharing requires extracting a common
"render a State + Layout + Colors" shape, which the
view/controls/* modules already have.Spec::SetSpec mutator vs per-field mutators. Currently
field mutators cover SetValue / SetChecked / SetSelectedIndex /
SetItems / SetExpandedKeys / SetCheckedKeys. For richer subtree
changes — e.g. a toolbar that grows a button — the choice is: add
SetSpec { widget_key, sub_spec } (clean) or add more per-field
mutators (incrementally simpler). Currently deferred (see §2.2);
re-evaluate if a real consumer needs it or profiling on a
large-spec panel shows IPC cost matters.Layer work (§7)
absorbs this.widget_panel_width(buffer_id). When the buffer's split resizes,
we don't currently re-render — the plugin gets a resize event
and is expected to call updateWidgetPanel. A future improvement
is for the host to re-render automatically when viewport.width
changes for any buffer with a mounted widget panel.Text (value + cursor + scroll — scroll
carries first-visible-row for multi-line, ignored for
single-line), List (selected_index + scroll_offset), and
Tree (selected_index + scroll_offset + expanded_keys). Per-row
Tree checked is the explicit exception: it lives in the spec
(because the plugin owns "which rows are selectable" as an
application-data fact, not host state) and is mutated in-spec via
SetCheckedKeys. The rule will need to extend to Prompt /
Layer (open/closed) when those land. Pattern is set; just
apply it consistently as new widgets land.defineMode. Today the plugin's
defineMode binds keys → dispatch(widgetKey("Tab")). The §8
design said the widget's keymap should claim keys before
defineMode sees them. We did the inverse: the plugin opts in by
binding to widget commands. That's pragmatic for migration but
means every plugin repeats the same binding table. A
defineMode extension or registry of "panel has a widget
runtime" + "widget keymaps register here" would let plugins skip
the boilerplate.Standard fresh checkout. The widget runtime is part of fresh-editor:
cargo build -p fresh-editor --bin fresh
cargo test -p fresh-editor --lib widgets
crates/fresh-editor/plugins/check-types.sh # tsc on plugins
After modifying the Rust API or types in fresh-core/src/api.rs,
regenerate fresh.d.ts:
cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored
The spec says "verify in tmux." This is real — the rendering pipeline has subtleties (cursor placement, focus styling, mouse routing) that unit tests miss. Recipe:
# Set up a fixture
mkdir -p /tmp/sr-test && cd /tmp/sr-test
echo -e "hello world\nhello again" > a.txt
git init -q . && git add -A
git -c user.email=t@t -c user.name=t -c commit.gpgsign=false commit -q -m init
# Launch in tmux
tmux new-session -d -s sr -x 160 -y 40 -c /tmp/sr-test \
"/path/to/fresh/target/debug/fresh a.txt"
sleep 2
tmux send-keys -t sr 'C-p' # command palette
sleep 1
tmux send-keys -t sr 'Search and Replace'
sleep 1
tmux send-keys -t sr Enter
# … drive keys …
tmux capture-pane -t sr -p # rendered text
tmux capture-pane -t sr -p -e # rendered text + ANSI escapes
tmux display-message -t sr -p '#{cursor_x},#{cursor_y} flag=#{cursor_flag}'
cursor_flag=0 means the hardware cursor is hidden (no Text
widget focused); flag=1 means it's visible. capture-pane -e is essential
for verifying overlay colors / focused-bg styling — plain
capture-pane strips them.
For Tree, Tabs, Table etc. The path through the codebase is
mechanical at this point.
WidgetSpec::<Kind> variant in
crates/fresh-core/src/api.rs next to Toggle/Button/etc.
Fields are spec-only (initial values) — instance state goes in
WidgetInstanceState (step 4). Stable key: Option<String> is
required for any widget that owns instance state.crates/fresh-editor/src/widgets/render.rs
(render_<kind> plus a match arm in render_collected). Output:
one or more TextPropertyEntrys and zero or more HitAreas.
Container-shifting (Row inline-collapse / Col row offset) is
handled by the surrounding code; just emit relative coordinates.collect_tabbable if the widget
takes focus. Add focus-styling override in the widget arm — the
pattern is let is_focused = match key.as_deref() { Some(k) if !k.is_empty() => k == focus_key, _ => *focused }; and then pass
is_focused to your renderer.crates/fresh-editor/src/widgets/registry.rs (WidgetInstanceState
enum). Read from prev map by key; write to next_state. The
Text and List arms in render_collected are the
templates.crates/fresh-editor/plugins/lib/widgets.ts. Re-export from
index.ts. Regenerate fresh.d.ts.WidgetCommand::Key arm in
crates/fresh-editor/src/app/plugin_dispatch.rs (handle_widget_key)
if the widget responds to keystrokes. Existing dispatch table:
Tab/BackTab → focus advance; Up/Down → List/Tree select-move ±1
(or line nav for multi-line Text); PageUp/PageDown → List/Tree
select-move ±(visible_rows-1);
Backspace/Delete/Left/Right/Home/End → Text editing (single-
line and multi-line both routed through the same
handle_widget_text_key, which reads multiline from the
focused widget's rows > 1 and dispatches to
apply_text_input_key or apply_text_area_key); Enter →
activate, except on focused Text { rows: 1 } (advances focus —
form-like UX) or Text { rows >= 2 } (inserts \n); Space →
activate, except on focused Text (inserts " " via
handle_widget_text_char) or a checkable: true Tree row
whose checked == Some(_) (fires widget_event "toggle" with
the inverted value — mirrors clicking the [v]/[ ] glyph).
Add per-kind handling.WidgetMutation if the plugin needs a
targeted fast-path update (e.g. Tree would want
SetExpandedKeys { widget_key, expanded_keys: Vec<String> }).
Wire through handle_widget_mutate and
widgets::set_<thing>_in_spec helper.widgets/render.rs (tests module). Test
render output shape, hit areas, focus styling, instance state
round-trip. The text_input_* and list_* tests are templates.Each step is a few dozen LOC at most. The work scales with the widget kinds, not with the dispatch / state-management plumbing — those are done.
MountWidgetPanel resets instance state. Plugin re-mounting
the same panel id starts fresh. Use UpdateWidgetPanel to
preserve instance state across renders. WidgetPanel.set() does
the right thing automatically (mount on first call, update after).Text value + cursor
(+ scroll for multi-line) and List selected_index +
scroll_offset, instance state is the truth after first render.
The spec's value is initial-only. Plugin updates via
widget_event or via WidgetMutate::SetValue /
SetSelectedIndex. Setting them in the spec on every render is
fine — they're ignored once instance state exists, except via the
re-mount path. Don't rely on spec value for round-trip.\n. Row inline-collapse strips trailing
\n from inline children before merging and re-adds one at the
end of the merged row. Without this, adjacent widget entries
concatenate into one logical buffer line. The renderer takes care
of this if you go through it; if you push entries directly (e.g.
in a Raw migration shim) make sure they have trailing \n.Text widget is focused, the host
sets the buffer's show_cursors=true and positions the primary
cursor to the byte the renderer emitted in
RenderOutput::focus_cursor. Multi-line Text publishes the
cursor as (buffer_row, byte_in_row) (row relative to the
widget's first rendered entry, including the optional label
row); single-line publishes (0, byte_in_row). When focus is
on a non-text widget, show_cursors=false and the hardware
cursor disappears entirely. Don't paint a cursor overlay in the
renderer — let the terminal blink the real one.widget_panel_width() returns
viewport.width - 2 for gutter/scrollbar/border slack. Your
widget can use the full result via panel_width parameter; flex
Spacers consume any leftover. If your widget naturally takes a
fixed width (Toggle = [v] label.len(), Button = [ label ].len()),
the renderer accounts for it in flex distribution.WidgetCommand events can
queue in one editor tick before the plugin processes any
widget_event. Read state from instance state, not from the spec
field, to avoid the race that bit the original "renderer reads
spec value" design.tmux capture-pane doesn't show colors. Use -e to dump ANSI
escapes, or display-message -p '#{cursor_x},#{cursor_y}' for the
hardware cursor. Theme keys resolve at render time; capture-pane
output reflects the real terminal output.#[cfg(test)] test compilation. When you add a new
WidgetInstanceState variant or a new WidgetSpec variant, the
test fixtures need updating (make_list in render.rs, struct
literals scattered across test functions). The compiler will tell
you all the call sites.defineMode bindings shadow widget input.
A plugin binding "x" to a widget command in defineMode will
swallow the lowercase x in every focused Text widget on the
same panel — the binding fires before the input character
stream gets the keypress. Use Space (host-dispatched on focused
widget kind) or a non-letter key for widget commands; reserve
letter bindings for non-text-input panels."S-Tab" plugin bindings. defineMode("S-Tab", …) registers
as (BackTab, NONE) to match what the resolver actually looks up
after normalize_key strips the redundant SHIFT modifier from
Shift+Tab. Either spelling ("S-Tab" or "BackTab") is correct;
before commit b103df2 only "BackTab" worked.The blocking work is the Compositor (§4.1), then plugin migrations that bring more surfaces under the widget runtime (§4.2), then Settings adoption (§4.3). The smaller follow-ups (§4.4) move the existing surface forward without architectural lifts.
§4.5 covers items previously elevated in the roadmap that are now deferred — pick them up when a real consumer materializes.
Layer (the big one)This is §7 of this doc. Unifies Popup, Prompt,
showActionPopup, hover tooltips, completion popup, plugin-mounted
modals/tooltips/context-menus into one Component trait + Z-ordered
stack + mountLayer IPC. Subsumes a lot of duplicated focus / dismiss
/ event-routing logic. Touches a lot of files. Worth a dedicated
multi-PR effort.
Why this is the central blocker: the goal is "all plugin UI is
declarative widgets". Today editor.startPrompt and friends work
fine, but they don't share dismiss / focus / keymap rules with
the widget runtime. The keymap-claim inversion from §8 (host
claims widget keys before defineMode sees them) only reaches
end-state across panels and overlays once one Compositor owns
the focus stack.
Key invariants to preserve during migration:
editor.startPrompt, editor.showActionPopup keep working —
become thin wrappers around mountLayer.event-dispatch-architecture.md Phase 2; if that's not in tree
yet, it lands first.search_replace.tsThis is how reach (goal #5: same shape across every panel) gets done. Status (✅ migrated, 🚧 partial, ❌ untouched, ⏸ blocked):
git_log.ts (commits c0d90b8 + ffb413c). Toolbar
→ Row of Buttons + widget_event "activate" routing.
Log pane → List widget; dropped the byte-offset row table
cursor_moved hook + setBufferShowCursors. Detail
pane stays on setPanelContent (pure diff text, no
widget benefit). Toolbar+log net −57 LOC.pkg.ts (commit 59a42b3). List panel → List
widget (drop manual ▸ cursor + selection styling, gain
click + wheel for free); footer → HintBar. Header
(search + filter buttons + sync) and detail (action
buttons) still use setPanelContent because they
participate in the plugin's cross-panel FocusTarget
cycle which a single widget panel can't model — needs
either a single combined panel (major UI restructure) or
the cross-panel focus stack the §4.1 Compositor work
introduces.theme_editor.ts (commit 4f74b97). Footer →
HintBar. Tree panel (header + separator + filter line +
selectable section/field rows with ▸ markers) and
picker panel (named-color list + palette grid + preview
lines + color-edit popup) still use setPanelContent —
the picker's color-edit popup needs Prompt/Layer
(blocked on §4.1).audit_mode.ts (4792 LOC, untouched). Multi-pane
review UI: toolbar with grouped key hints separated by
│ (HintBar can't render groups today — needs an
enhanced HintBar variant or a custom widget), diff pane,
comments pane, sticky header, staging UI. Multi-session
migration on its own.dashboard.ts (1903 LOC, untouched). Animated
centered ASCII frame with click regions, slide-in
transitions, skip-redraw hash optimization. Doesn't map
cleanly to a widget tree without losing the visual; would
need a UI redesign rather than a substitution.lib/finder.ts + find_references.ts.
Blocked on Prompt → Layer (§4.1). The shape is right
for List + Prompt; just waiting for the compositor.Each migrated plugin is mostly mechanical once the widgets it needs
exist. The work is in (a) discovering hidden assumptions in plugin
state machines (e.g. search_replace's focusPanel/queryField/
optionIndex triple, pkg.ts's FocusTarget enum), and (b)
reconciling event flow with whatever async work the plugin already
does (debounce, LSP, git).
Toolbar is shipped as a TS convention (row(button(), button(), flexSpacer())) without needing a new widget kind. Table was
considered necessary for git_log but List turned out to handle
pre-rendered column-aligned rows fine; revisit when a consumer
needs explicit columns (live resize, column-header sort, etc.).
Transient (Magit-style menu) falls out of the Compositor work.
§11 says shared renderers. The shape today is
widgets/render.rs::render_* for plugin widgets, separate
view/controls/*::render_* for Settings. Sharing requires
extracting a common (State, Layout, Colors) → TextPropertyEntry
shape; both already have it. The work is moving the renderers to
a common location (probably view/controls/) and having
widgets/render.rs call them. Pure refactoring; no new behavior.
Brings goal #5 to the built-in Settings surface so widgets really do paint everywhere.
catch_unwind around per-widget render_<kind>
calls so one panicking renderer paints a placeholder + logs a
RenderError instead of taking down the whole panel. Pure host
work; doesn't depend on §4.5 deferred items.apply_text_char for single-line and
multi-line Text, regression-tested for multi-byte codepoints
and multi-event commits). Preedit display still needs the input
layer to surface composition events first — server/input_parser.rs
doesn't parse the kitty-keyboard / bracketed-IME extensions
yet. Once it does, the widget runtime gains a preedit field
on WidgetInstanceState::Text plus a compose entry point
parallel to apply_text_char, and the renderer paints the
underline; both single-line and multi-line widgets get it
uniformly.apply_text_input_key only handles single-key edits; chords
(g g) still bubble to the plugin's defineMode.defineMode sees them, so plugins stop repeating the
Tab / Backspace / arrows binding table. A defineMode
extension or a "panel has a widget runtime" registry. Closes
goal #1 fully.These items remain valuable design work but are not blocking the end-state and have no in-tree consumer pushing for them. Pick up when real demand surfaces.
widgets/render.rs constants; only Button.intent: "primary" | "danger" is a real role. The full design
(Role enum + Role → theme key table + per-spec override
map) lets accessibility variants and theme packs drop in
centrally. Pain point today: zero — every widget has a
reasonable default theme key. Revisit when a high-contrast
theme or a plugin needing per-widget color overrides arrives.lib-widgets.i18n.json), aria strings on focus change,
OSC 52 / IDE screen-reader bridge, motion-reduce gating for
the (not-yet-shipped) library animations. Most of this depends
on role-based theming for the high-contrast piece.theme_changed so plugins
don't have to subscribe; (c) --record-spec-stream JSONL
replay for plugin-bug repro; (d) headless rendering for
snapshot tests; (e) embed cross-plugin composition. None
blocks a user flow today; (a) and (b) are small UX wins, the
rest are developer / plugin-author tooling. Build the
persistence layer when a real consumer asks (a session-restore
feature request, a theme-switch bug report, etc.).Status column: ✅ shipped, 🚧 partial, ❌ not yet, ⏸ deferred.
| Widget | Status | Used by | Notes |
|---|---|---|---|
Row / Col | ✅ | layout primitives | flex Spacer fills remaining width |
Spacer (fixed + flex) | ✅ | layout | |
Raw | ✅ | migration escape hatch | wraps TextPropertyEntry[] |
HintBar | ✅ migrated | every plugin's footer | parseHintString for legacy Tab:foo Esc:bar strings |
Toggle / Checkbox | ✅ migrated | search_replace toggles | [v]/[ ] glyph + label |
Button | ✅ migrated | search_replace Replace All | intent: "normal" | "primary" | "danger" |
Text | ✅ migrated | search_replace fields (single-line); composer-style plugins (multi-line) | unified single-/multi-line widget; rows: 1 (default) is single-line ([value] rendering, Enter-advances-focus, head-truncate scroll); rows >= 2 is multi-line (block rendering, Enter-inserts-newline, line nav, vertical scroll). Host owns value + cursor + scroll; hardware cursor positioned by host. Builders: text({ rows }) + ergonomic textInput(value, opts) / textArea(opts) wrappers |
List (virtual-scrolled) | ✅ | candidates for finder-style consumers | host owns scroll + selection |
Tree | ✅ migrated | search_replace match tree, audit, file-explorer | host owns scroll + selection + expansion; disclosure-glyph hit area; opt-in per-row checkboxes (checkable: true + treeNode.checked: Some(_)) with toggle events from glyph clicks or Space, persisted via WidgetMutation::SetCheckedKeys |
Tabs / Group | ⏸ | (no current consumer) | skipped; revisit when needed |
Layer (compositor) | ❌ → §4.1 | tooltips, popovers, modals; subsumes Popup/Prompt | big architectural piece |
Prompt | ❌ → §4.1 | finder, every confirm | built on Layer |
Transient (Magit) | ❌ → §4.1 | discoverability | one of the Layer kinds |
Table | ❌ | git_log, find_references, audit | |
Toolbar | ❌ | git_log, audit_mode | composes Button + Toggle |
Panel | ⏸ | every panelled plugin | currently unbuilt as a widget; today's Col does the job |
KeybindingList, MapInput | ⏸ | mirrors of Settings widgets | low priority |
Diagnostic / InlineHint | ⏸ | LSP plugins | |
ProgressBar, Spinner | ⏸ | indexer plugins | |
Dropdown | ⏸ | Settings |
The catalogue stays short by design. Anything not on it lives inside
a Raw widget — the imperative-virtual-buffer escape hatch.
Line-oriented flex along the row axis, absolute along the column axis, with a small Rect-based composition layer. Three reasons:
Toolbar packs left-to-right, a Panel's body fills, a
HintBar packs right-to-left. That's flex-row with grow/shrink
on children.What's actually shipped:
// In TS (plugins/lib/widgets.ts)
type WidgetSpec =
| { kind: "row"; children: WidgetSpec[]; key?: string }
| { kind: "col"; children: WidgetSpec[]; key?: string }
| { kind: "spacer"; cols: number; flex: boolean; key?: string }
| { kind: "hintBar"; entries: HintEntry[]; key?: string }
| { kind: "toggle"; checked: boolean; label: string; focused: boolean; key?: string }
| { kind: "button"; label: string; focused: boolean; intent: ButtonKind; key?: string }
| { kind: "text"; value: string; cursorByte: number; focused: boolean;
label?: string; placeholder?: string | null;
rows: number; // 1 = single-line, >= 2 = multi-line
fieldWidth: number; // 0 = auto-fit (single) / panel width (multi)
maxVisibleChars: number; // single-line soft cap; ignored when rows >= 2
key?: string }
| { kind: "list"; items: TextPropertyEntry[]; itemKeys: string[];
selectedIndex: number; visibleRows: number; key?: string }
| { kind: "tree"; nodes: TreeNode[]; itemKeys: string[];
selectedIndex: number; visibleRows: number; expandedKeys: string[]; key?: string }
| { kind: "raw"; entries: TextPropertyEntry[]; key?: string };
Row layout works in two passes — see render_collected in
widgets/render.rs. The flex distribution is panel_width - sum(non-flex widths) split evenly across flex spacers.
Not yet shipped: fill, fixed, wrap: "never" | "soft", and the
embed composition primitive. Add them when a plugin needs them.
Row content for List, Tree, and Raw flows through a single
TextPropertyEntry shape. Plugins have two ways to build one:
// (a) Pre-rendered text + offset overlays. Overlay offsets default
// to bytes (UTF-8); set `unit: "char"` to address codepoints
// instead — the host converts to bytes natively in Rust during
// `normalize_widths`.
{
text: "TX file.rs (3/5)",
inlineOverlays: [
{ start: 0, end: 2, style: { fg: ICON, bold: true }, unit: "char" },
{ start: 3, end: 10, style: { fg: PATH }, unit: "char" },
],
padToChars: 80, // host pads with spaces after overlays resolve
truncateToChars: 80, // host truncates at codepoint boundary, "..." suffix
}
// (b) Structural segments. The plugin describes the row as a
// sequence of (text, optional style, optional nested overlays);
// the host concatenates and emits one Char-unit overlay per
// styled segment plus each segment's nested overlays shifted by
// the segment's start. The plugin never names a byte or codepoint
// offset between segments.
styledRow([
{ text: "TX", style: { fg: ICON, bold: true } },
{ text: " " },
{ text: "file.rs", style: { fg: PATH } },
{ text: " (3/5)" },
], { padToChars: 80 })
Use (b) when row structure is a flat sequence of styled pieces —
the typical file-tree row, breadcrumb, or label-with-suffix.
Use (a) when overlays land inside a single string the plugin
already has (regex hits inside a context substring, syntax
highlights inside a code line). The two compose: a segment can
carry nested overlays against its own text, and the host
shifts them into entry coordinates.
Why this matters for hot paths: with structural segments the
plugin pays no per-row codepoint walks and no per-overlay
utf8ByteLength bridge calls. The host's normalize step is
O(visible_rows × row_text_bytes), all in Rust. The
search_replace match-tree is the regression test; profiling
notes in commit history.
InlineOverlay, TextPropertyEntry, and StyledSegment all
deliberately omit Default derives — every Rust construction site
lists every field explicitly, so future field additions break
compilation at each site instead of silently picking up a default.
On the TS side the styledRow builder omits keys whose value is
undefined (the JS↔Rust JSON bridge maps JS undefined to JSON
null, which fails to deserialize as Option<…> / Vec<…> host
fields; absence triggers #[serde(default)] instead).
Partially blocked on event-dispatch-architecture.md Phase 2.
Today the editor has half a dozen overlapping subsystems for "thing
that paints over content": Popup (view/popup.rs), Prompt
(view/prompt.rs), showActionPopup, the buffer-group panel
renderer, hover tooltips, completion popups. Each has its own focus
stack, dismiss policy, mouse routing, and keymap precedence.
Unify them as layers in a single Compositor, modelled on Helix's
Component trait, adapted for IPC:
trait Component {
fn render(&mut self, area: Rect, surface: &mut Surface, ctx: &mut Ctx);
fn handle_event(&mut self, event: &Event, ctx: &mut Ctx) -> EventResult;
fn cursor(&self, area: Rect, ctx: &Ctx) -> (Option<Position>, CursorKind);
fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)>;
fn dismiss_policy(&self) -> DismissPolicy;
fn id(&self) -> ComponentId;
}
The Compositor owns a Z-ordered stack. Events bubble front-to-back
until one returns Consumed. Plugin-facing surface:
const tooltip = editor.mountLayer({
kind: "tooltip", // "tooltip" | "popover" | "modal" | "panel"
anchor: { widget: "matchTree", row: hoveredRow },
body: { kind: "widget", type: "InfoCard", props: { ... } },
dismissOn: ["hover-out", "blur"],
});
UX wins this enables (none reachable in a TS-only design):
Button.kind = "danger" confirm spawns a Layer { kind: "modal", body: { type: "Prompt" } } — no separate modal-dialog API.Layer { kind: "popover", body: { type: "List" } }. Plugins do not re-implement context menus.Prompt mounted from inside a panel is the same Component as
the top-level command palette.Files to add when this work starts: crates/fresh-editor/src/compositor/
with the trait, the stack, the dispatcher, and the mountLayer
binding. view/popup.rs / view/prompt.rs / view/hover.rs
migrate to be Component implementations in successive PRs.
A panel-level focus stack with one Tab cycle per panel, computed from the spec's tabbable widget keys in declaration order. Each panel has a single active widget; the host paints focus styling.
Implemented: collect_tabbable walks the spec; focus_key
lives in WidgetPanelState; WidgetCommand::FocusAdvance { delta }
cycles. The smart-key dispatch (WidgetCommand::Key { key }) routes
keystrokes to the right action based on the focused widget's kind.
Dispatch order today (one direction off from the design intent):
defineMode bindings (the plugin opts in by binding
keys to dispatch(widgetKey("Tab")) etc.)handle_widget_key, which routes to
handle_widget_focus_advance / handle_widget_activate /
handle_widget_select_move / handle_widget_text_key /
handle_widget_text_char. Single-line vs multi-line Text
selects automatically inside handle_widget_text_key via the
focused widget's rows field.Dispatch order intended:
defineMode bindingsThe "widget keymap claims keys before plugin keymaps see them"
inversion is open. Pragmatic for migration today (plugins explicitly
bind), but every plugin repeats the binding table. A
defineMode extension that registers a "panel has a widget runtime"
shortcut would let plugins skip the boilerplate.
Shift+Enter ≡ Enter at the terminal, Shift+Alt+Enter ≡ Alt+Enter.
We do not bind Shift+Enter as a distinct key. Multi-line Text
submit defaults to Alt+Enter (Enter inserts a newline); the chosen
key string shows in the panel's HintBar.
The host owns hit-testing. The plugin never sees (buffer_row, buffer_col); it receives semantic events.
Implemented:
HitArea { widget_key, widget_kind, buffer_row, byte_start, byte_end, payload, event_type } during render. Stored in WidgetPanelState::hits.WidgetRegistry::hit_test(buffer_id, row, col_byte) does the
per-panel scan; WidgetRegistry::panels_for_buffer enumerates
every panel mounted on a buffer (used by the wheel-scroll path).click_handlers.rs calls hit_test for every left-click on a
widget panel's buffer; on hit, fires widget_event with the
payload, and moves focus_key to the clicked widget.widget_event payloads: Toggle → { checked: <new> }; Button →
{}; List → { index, key }; Tree row select → { index, key };
Tree disclosure → expand event; Tree checkbox glyph (only when
checkable: true and the row's checked is Some(_)) →
toggle event with { key, index, checked: <new> }; Text →
{ value, cursorByte } (same payload regardless of single-line
vs multi-line).mouse_input.rs
calls into the widget runtime before falling through to buffer
scroll. The first scrollable widget in the panel shifts its
viewport; the selection is dragged to the edge of the new
visible window so the renderer's keep-selection-in-view
auto-scroll doesn't snap the offset back. Wheel does not change
focus and does not fire select.Not yet implemented:
onContext).onPress / onDrag / onRelease).onHover(true|false)). Important for the Layer tooltip
flow.onActivate(key). Today single-click fires
select; double-click would fire activate separately.Reactive on the Rust side, declarative on the TS side. Plugin
re-emits a WidgetSpec whenever its model changes; host runs a
keyed reconciler against the previous spec for that panel and
applies a minimal patch.
Implemented:
WidgetInstanceState holds host-owned
state per widget key (Text value+cursor+scroll, List
scroll+selection, Tree scroll+selection+expanded_keys). The
spec carries initial values; instance state is the truth after
first render.key round-trip: re-emitting the spec preserves instance
state by key.rerender_widget_panel
walks the spec + current instance state without plugin
involvement. Used by focus advance, select move, text-input
mutation, and toggle/items mutators.WidgetMutate::SetValue /
SetChecked / SetSelectedIndex / SetItems /
SetExpandedKeys / SetCheckedKeys. Plugin ships a one-field
change instead of the full spec. SetCheckedKeys stamps
Some(checked) onto every Tree node whose item-key appears in
the supplied list; nodes with checked: None are left as None
(a node only becomes checkable by the plugin emitting Some(_)
in the spec).TextPropertyEntry carries
segments, pad_to_chars, truncate_to_chars; InlineOverlay
carries unit: Byte | Char. The host's normalize_widths
resolves segments → text + overlays, applies truncate/pad, then
converts char-unit overlays to bytes — all in Rust against the
final text. Plugins describe row structure declaratively and
pay no per-row codepoint walks or per-overlay bridge calls.Not yet implemented:
--record-spec-stream) — deferred (§4.5).render_spec directly.embed widget kind) — deferred
(§4.5).spec.version: 1) — unused since v1 only.catch_unwind around per-widget
render_<kind> calls, paint a placeholder, and log a
RenderError event.Widgets carry roles, never colors. Partly implemented.
Implemented:
Button.intent: "normal" | "primary" | "danger" — the only
user-visible role today.widgets/render.rs
(KEY_HELP_KEY_FG, KEY_TOGGLE_ON_FG, etc.). One place to
override for accessibility variants, but no plugin override yet.Not yet implemented:
theme: { Role → OverlayColorSpec } override map.Button.danger.hover.fg).The path forward is §4.5 in the roadmap (deferred without a current driver — every widget has a reasonable default theme key today).
Per-plugin *.i18n.json (docs/i18n.md) stays the authority.
Library defaults (Confirm, Cancel, Toggle, …) live in
lib-widgets.i18n.json (not yet created). parseHintString already
handles the existing per-plugin help strings.
Deferred (§4.5) without a current driver. The full design:
keybindings.json against
KeybindingResolver (already works for the existing widget
commands once the plugin binds them).Pick this up when an a11y review or a user request surfaces. The keybinding piece already works through the existing resolver; the rest is genuinely new code.
search_replace.tsStatus of the original 5-pass plan:
| Pass | Description | Status |
|---|---|---|
| 1 | Mount as Panel, body stays Raw, HintBar real, toggles real | ✅ |
| 2 | Replace search/replace fields with TextInput (host-owned cursor + constant width) | ✅ |
| 3 | Replace match list with Tree | ✅ host owns expansion + scroll + selection; disclosure glyph hit area; per-row checkboxes (checkable: true) for per-match exclusion — Replace All filters by result.selected |
| 4 | Glob filter as TextInput with validator | ❌ |
| 5 | Delete dead code | 🚧 buildFieldDisplay, addCursorOverlay, the cursor-byte arithmetic, the focus enums, the per-key mode handlers all gone. Remaining dead: panel.scrollOffset, panel.focusPanel/queryField/optionIndex (legacy fields kept for the Raw separator path). |
The plugin's defineMode table shrank from per-key handlers to a
small set of one-liner dispatch(widgetKey("...")) forwarders.
| System | Steal | Reject | Why |
|---|---|---|---|
| VS Code TreeView | Declarative TreeDataProvider shape: plugin returns data, host owns hit-test, virtualization, focus | Webview as a generic UI escape hatch | Webviews break the sandbox premise; TreeView's declarative shape is exactly the v1 widget-spec model |
Helix Component trait | Layered z-ordered components; bubble-up Consumed/Ignored; host-owned cursor() and required_size() | Synchronous Rust trait across FFI | Translation: TS handlers are async; Ignored is the IPC default |
| nui.nvim | Widget = "buffer + keymap + lifecycle (mount/unmount)" | "No widget library" stance | Sandboxed JS plus opinionated widgets is a better default than asking plugin authors to roll their own |
| Sublime minihtml | on_navigate href dispatch as the safe link primitive (already analogous to mouse_click) | HTML/CSS layout subset; no keyboard focus | We need real keyboard widgets, and CSS-flow on a terminal is the wrong fit |
| Emacs widget.el | Nothing | The whole library | Resists composition, imperative-by-side-effect — exactly what we'd reproduce by exposing today's setVirtualBufferContent as the only model |
| Magit transient.el | Grouped key→command menu as a first-class widget | Lisp-y EIEIO subclassing | A Transient widget covers git_log and unblocks discoverability per plugin-usability-review.md |
| Risk | Mitigation |
|---|---|
| Reconciler complexity grows past what one engineer can hold | Keep Spec flat (no nested per-widget keys beyond key: string); cap recursion depth; ship the dirtiest plugin (search_replace.ts) as the regression test for every reconciler change |
| Per-keystroke event IPC dominates if plugins re-emit Spec on every keystroke | Document the rule: in widget_event "change", never call updateWidgetPanel unless the rest of the spec actually changed. Use mutators (SetValue/SetChecked/SetItems/SetExpandedKeys) for hot-path. The lint is "panel.update calls per second"; expose it on the dev HUD |
| Capability creep through widget callbacks | Widgets only emit events the plugin can already subscribe to. Code review checklist: a new widget MUST NOT introduce a new PluginCommand-equivalent capability |
Theme role explosion (Button.danger.hover.fg...) | Cap the role tree at three levels; review additions in PRs that touch theme/types.rs |
| Reach: Settings doesn't actually adopt the widget tree | Keep the renderers shared (§4.3) and the Spec shape compatible. Settings can stay on its current direct calls indefinitely |
| Plugin author confusion: Spec vs imperative vs mutators | One way per use-case in the docs. Raw exists for escape hatches, not for rendering rich UI. Mutators are for hot-path single-field updates |
| Terminal-constraint violations (Shift+Enter etc.) | Static lint in TS: any keys string in a HintBar or Transient matching ^Shift\+(Enter|Alt\+Enter) is a build error |
Drift from event-dispatch-architecture Phase 2 / unified-keybinding-resolution / unified-hit-test-theme-plan | This proposal builds on them. The Compositor migration (§4.1) blocks until Phase 2 lands |
Foundation (widget runtime, core types, TS surface, search_replace migration through Pass 3) is shipped. Blocking remaining work, in order:
search_replace.ts (with
Toolbar and Table widgets landing alongside).Deferred without a current driver: role-based theming, accessibility, spec-as-state persistence (§4.5).
The hit-test dispatcher / region_at extension / unified-keybinding
collapse from related design docs were bypassed for v1: the widget
runtime owns its own hit-test against WidgetRegistry::hits, and
plugin defineMode already routes through the existing resolver.
The general dispatcher remains desirable for the Layer compositor.
Going. Foundation shipped, one plugin (search_replace.ts)
migrated end-to-end through the bulk of its UI; cargo check
workspace clean, widget unit tests green, tsc clean, interactively
verified in tmux.
The big architectural lift is §4.1 (Compositor / Layer). It's not
blocked on anything in tree; it's blocked on planning capacity.
Until it lands, plugins that want tooltips / modals / context menus
keep using editor.startPrompt / editor.showActionPopup / etc.,
which work fine but don't share dismiss/focus rules with widget
panels — and goal #1 (host-claimed widget keymaps) only reaches
end-state across panels and overlays once one Compositor owns
the focus stack.
A parallel proposal in docs/internal/plugin-ui-library-design.md
takes the opposite shape: a thin TypeScript helper library — one
VirtualBufferBuilder, a TextInputState + TextInputRouter
wrapping mode_text_input, a FocusRing<T> cycle helper, a small
set of new theme keys. Zero new IPC. Migrates pkg.ts,
search_replace.ts, theme_editor.ts quickly.
It is a coherent v1 if shipping speed is the binding constraint. It is the wrong end-state under the criterion stated at the top of this document. Five UX/robustness/flexibility wins the TS-only shape structurally cannot reach:
TextInput consumes Backspace/arrows/Home/End uniformly across
every plugin without each plugin registering them in its
defineMode. (Partially shipped here; see §8 — host-side
keymap-claim is the inversion still open.)onSelect(key), onActivate(key), onHover(key, true|false));
they never see (buffer_row, buffer_col). (Shipped — WidgetRegistry::hit_test.)setVirtualBufferContent is full delete-all + insert-all + rebuild
overlay tree (virtual_buffers.rs:356–405). With widget state
Rust-side, a keystroke in a TextInput mutates Rust state and
emits one semantic event back; if the plugin's model doesn't
change, no re-render IPC fires at all. (Shipped — instance state
plus targeted mutators.)intent: "primary"|"danger" is the only role today;
the full design is deferred without a current driver, see
roadmap §4.5.)view/controls/*
renderers paint plugin widgets too — Settings, file explorer,
prompts, plugin panels share one render path. The TS-only proposal
freezes the split forever. (Not shipped — see roadmap §4.3.)Three further capabilities the TS-only design forecloses:
Popup/Prompt/showActionPopup/hover/
modals/context-menus/completion under one dismiss-and-focus model)
— see §7 / roadmap §4.1.Where the TS-only proposal is right and we keep its discipline:
button.setLabel(s)). Spec/reconciler is declarative. (Followed —
but WidgetMutate::SetValue etc. exist as bounded escape hatches
for the hot path.)mode_text_input and defineMode for the imperative escape
hatch. (Followed — plugin's defineMode is how it opts into widget
key dispatch.)Net. The TS-only proposal answers "what is the minimum useful help we can ship soon?" cleanly. It does not answer "what should this library be?" Under the criterion stated at the top — end-state UX, robustness, flexibility, with shipping speed deliberately not a constraint — the maximalist version is the answer, and is what's in tree.