Files
effectcraft/docs/egui-037-migration-plan.md
T
skylartaylor 9ee853715d M0.37: migrate EffectCraft to egui 0.37
Reconcile main 991904888a and preserve the reviewed lifecycle, IME, rendering and lockfile changes.

Formatting, strict lint, layers, assets and WASM passed. Full CI retained 3,961 passes, 14 baseline-matching GPU failures and 31 ignored executions. The local review commit has explicit approval to retain these documented failures; native/browser acceptance and paired app performance remain open.
2026-10-25 02:01:16 +01:00

124 KiB
Raw Blame History

EffectCraft — egui 0.37 migration plan

Current landing status, 2026-10-25: the working tree includes main 991904888a20b7da72b2a4880b21c7d912154736 and pins egui 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc and shared UI 25da1314d443171b4adeaad2cd9454b273f35c6f. The final IME adapter uses ordered native reset request identifiers and ImeResetCompleted; the earlier tombstone-only description below is historical.

Full current-source CI egui037-effectcraft-current-main-ci-20261025-0231 recorded 3,961 passing tests, 14 failures and 31 ignored across 88 test summaries. All 14 failures are GPU tests: their names and diagnostic blocks match the frozen pre-migration eb51087 baseline exactly after removing thread process numbers. Formatting, strict lint, layering, assets and the 32-crate WASM check completed. The 596-test UI regression suite passes. This is not a green full-CI result or hardware-GPU qualification. Native IME, visible final-source workflows and paired performance remain open.

The repository's green-only commit gate remains unmet by the GPU results; the reviewed worktree and complete evidence are retained for human review. No push, publication or merge is authorized by this status.

Historical implementation and research record follows. Its older pins, test counts and unacknowledged-reset description do not describe the current candidate.

Status: implementation in progress, 2026-10-24. The dependency/API port, IME context adapter, screenshots, accessibility, font coverage and text-edit repairs are present in the dirty migration worktree. The parent-managed local run egui037-effectcraft-candidate-20261024-upstream-tests-09 recorded 820 passed, 14 ignored: effectcraft-ui-egui --lib 297 passed / 3 ignored, --test it 521 passed / 11 ignored and --test ui_accessibility 2 passed; cargo clippy -p effectcraft-ui-egui --lib --tests -- -D warnings exited 0. That run covers effectcraft-ui-egui only; the engine suite, full cargo xtask ci, wasm, native GUI and real-platform IME were not rerun for this revision. Evidence: /home/codex/work/ssd2/task-evidence/egui037-effectcraft-candidate-20261023/task-jobs/egui037-effectcraft-candidate-20261024-upstream-tests-09/output.log. The custom viewer text editor now has an explicit per-viewport IME composition lifecycle instead of a frame-number suppression window: an active composition is owned by the edit session's layer/session identity, and a target switch or editor end turns it into a tombstone. An empty pre-edit is the observed end of the visible composition, but its terminal commit/delete can arrive in a later event, so the owner stays associated with the cleared composition; if the target changes before that terminal event, the tombstone rejects it instead of applying it to the replacement. A lone non-empty old pre-edit keeps the tombstone across arbitrary idle frames. A rejected terminal commit consumes the tombstone immediately, so a new pre-edit later in the same raw input batch starts a composition for the current target. A rejected empty pre-edit or delete-surrounding keeps the tombstone through the rest of that batch so its paired old commit is rejected too, and is consumed at batch end when no commit follows. A composition started after that terminal boundary is accepted, so the tombstone never becomes a permanent lock. egui::ImeEvent payloads still carry no context generation, and the inspected egui-winit 0.37 source ignores Ime::Enabled/Ime::Disabled and implements should_interrupt_composition by toggling set_ime_allowed without an observable reset acknowledgement. The fallback therefore drops the first ambiguous composition after an unobserved platform reset rather than claiming provenance it cannot prove; a producer-side event epoch would be needed to close that residual, and this is not claimed as tested on a real platform IME here. Two real-app headless regressions in crates/ui-egui/tests/it/ui_text_edit.rs cover the observed-empty-preedit pending commit and a new pre-edit after a stale terminal commit in the same raw batch; this revision adds them but does not claim they were rerun here. The later bounded same-owner interruption repair (2026-10-24) tracks the expected caret and selection separately from that immutable layer/session owner. A live or terminal-pending composition is invalidated before application pointer/selection changes can accept an old IME mutation, including keys before a later IME event in the same raw batch and control-client selection changes before a pass. Accepted IME mutations update the expected selection so the IME deletion's own caret shift does not reject its paired commit. The existing terminal rules still permit a fresh preedit after a rejected terminal commit, and a direct commit with no active composition remains valid even after a pointer change in the same batch. Five new real-app regressions cover15 scenarios with both deletion forms, pointer and keyboard interruption, terminal-pending/external selection, undo preservation, fresh composition and direct commits. These new source bytes and tests await parent-managed formatting, focused/full gates and fresh independent review; no new test, native-IME or final dependency-graph acceptance is claimed here.

Every future push or new pull request still needs a fresh, explicit human approval of the exact commit range and its validation; this document is not publication evidence.

Scope: storytold/effectcraft only (this checkout). Shared work in storytold/craft-libs (PR 199 and the CL-2 capture helper), the suite egui fork and the suite winit fork are dependencies with their own plans; this document records what EffectCraft needs from them, not what they should do. Sibling apps, their plans and their dependency bumps are out of scope.

This plan deliberately separates:

  • facts verified by reading the pinned source in this checkout, the egui checkout and the upstream 0.36.2/0.37.0 tags;
  • historical clues (the 2026-10-22 audit compiler output), which are not current acceptance;
  • proposals (refactors and acceptance criteria) that the implementation PRs must prove.

1. Baseline and provenance

1.1 Exact source under plan

Item Value
EffectCraft implementation checkout /home/codex/work/CraftApps/egui037-migration-20261022/effectcraft-implementation
Current EffectCraft worktree HEAD 049070d65c66a73f322b496b22521816e0018f14; dirty migration worktree. The lock and manifests still use the local integration patch, not final immutable pins.
EffectCraft historical planning baseline 9581b53c3f8945683252af0eb5fac79e0e43aed4 (Release 0.16.0, 2026-10-22), retained for baseline comparisons only
Branch codex/egui-037-plan-20261022
Workspace single root workspace Cargo.toml (resolver = "3", members crates/*, apps/*, xtask)
Lockfile Cargo.lock, lock format 4
MSRV / edition Rust 1.95, edition 2024 (Cargo.toml:7-12)
Target upstream release egui tag 0.37.0, peeled commit 841a328ba2dd8f6996beb7189ebb7fc00e668a2a (tagged 2026-10-15)
Candidate suite egui (comparison input only) aiongg/craft/0.37 = a4f9a17f64a6bae61350fb85804c9df0d9865916 (2026-10-21, E1–E21). Read-only source for this plan; not a publication pin.
Candidate suite winit (comparison input only) aiongg/craft/0.30.13-lang = 8b35388bd719e8cc86a95971ba0bc026c9a8f058 (winit 0.30.13 + W1–W15, W17–W27). Read-only source; not a publication pin.
Production egui pin pending approved fork implementation: the final reviewed and published storytold/egui P8 SHA containing E1–E21, P2b async W15P routing, P7 held-key pairing, P7b/E21b, P7c's selected #8763/#8748 fixes and the justified #8731/#8736 performance fixes if measurements require them. All egui-family crates must come from that one revision.
Production winit pin pending approved fork implementation: the completed, final reviewed storytold/winit SHA containing W1–W27, W15P and the W27 lifecycle fixes.
Final dependency unit one immutable set recorded together: the complete storytold egui P8 SHA + storytold winit SHA + craft-ui SHA with CL-1 and CL-2F, proved by a clean --locked fetch. This is the consumer's first and only compatibility landing: no stock-0.37 or intermediate fork production stage.
Suite shared UI dependency craft-ui from storytold/craft-libs, reconciled merged-PR base 3832c47ef68a0e6214f2c1e85f3526b6adcb686e (current shared-library implementation base). Historical planning rev 5c874d51eeb3de70e735c8ce87a59a7dedd47675 is superseded; the final publication pin is the reviewed immutable SHA containing CL-1 and CL-2F.
Shared PR history storytold/craft-libs#199 is merged at 3832c47ef68a0e6214f2c1e85f3526b6adcb686e. Historical port hunks 8614e4a04fae516fba16c22b31ff1008cb6be10b and README 1764bca, applied to old main 49190884278f16c9a9f5823d7698a8ffb5deb062, are superseded. Never merge b68de8c/ec8840a as trees.
Separate language PR storytold/craft-libs#198 (fork E5/E9/E12–E21, winit W17–W27)

All pins must be full commit SHAs. Branch names are not publication pins. The aiongg SHAs above are source-inspection inputs for this review; the lock file in a publication branch must name only the final storytold revisions (plus the existing unrelated craft-libs PDF pin).

1.2 Baseline dependency set (historical 9581b53c; from Cargo.lock and manifests)

The implementation worktree now resolves the egui family at 0.37.0 and craft-ui at 3832c47ef68a0e6214f2c1e85f3526b6adcb686e through local-integration-config.toml; the current Cargo.lock entries therefore have no registry source for those patched crates. That local configuration is review evidence, not an immutable publication pin.

Crate Version / source Where pinned
egui 0.36.2 crates.io, checksum dc938cc2… Cargo.toml:108
eframe 0.36.2 crates.io, checksum 55ad41fa… Cargo.toml:109 (default features off)
egui_kittest 0.36.2 Cargo.toml:110, dev-only
egui-wgpu / egui-winit 0.36.2 transitive, one each
kittest 0.4.0 transitive of egui_kittest
accesskit / accesskit_winit 0.24.1 / 0.32.2 transitive
winit 0.30.13 crates.io, checksum a6755fa5… apps/effectcraft/Cargo.toml:23 ("0.30", default features off) and transitive
wgpu 30.0.1 Cargo.toml:101
craft-ui baseline 0.1.0 git storytold/craft-libs rev 5c874d51…; implementation resolves local craft-libs-implementation at 3832c47… Cargo.toml:107 plus local-integration-config.toml
craft-pdf git storytold/craft-libs rev ca50d63b… Cargo.toml:15
craft-docpath git storytold/craft-libs rev 82a24b00… Cargo.toml:16
hayro-syntax patch git storytold/craft-libs rev ca50d63b…, =0.7.2 Cargo.toml:213-214
wasm-bindgen 0.2.129 (exact crate and CLI), apps/effectcraft-web/Cargo.toml:34 and xtask/src/main.rs:254 web only
filmcraft crates git rev 2c5ba7a7… Cargo.toml:76-95

There is exactly one egui family in the baseline. The target must preserve that invariant (see §6).

Manifest audit: 38 Cargo.toml files (root, 32 crates/*, 3 apps/*, xtask, and the standalone plugin). Only crates/ui-egui, apps/effectcraft, apps/effectcraft-web and (through effectcraft-ui-egui) apps/effectcraft-cli use the egui family. examples/plugins/posterize-bands has its own workspace/lock and no egui. There are no vendored crates and no [patch] on the egui family in the baseline; the only patch is hayro-syntax.

1.3 Package, binary, feature and entry-point targets

Target Kind egui-family surface
effectcraft (apps/effectcraft) native binary eframe with default_fonts, wgpu, accesskit, links, wayland, x11 (apps/effectcraft/Cargo.toml:18); direct winit; native Desktop app (apps/effectcraft/src/main.rs:426-456)
effectcraft-web (apps/effectcraft-web) cdylib + rlib, wasm32 eframe web runner (app.rs:88-122, start() at app.rs:185); eframe features default_fonts, wgpu
effectcraft-ui-egui (crates/ui-egui) library (swappable frontend) the real UI, control channel, docking, viewer, timeline, kittest dev-dep eframe + wgpu (crates/ui-egui/Cargo.toml:24-41)
effectcraft-cli binary + MCP server depends on effectcraft-ui-egui for bench --ops (apps/effectcraft-cli/Cargo.toml:19)
effectcraft-ui-egui --example snapshot example headless kittest+wgpu screenshots (crates/ui-egui/examples/snapshot.rs)
xtask tool ci, wasm, web, assets, layers (xtask/src/main.rs:409-428)
examples/plugins/posterize-bands separate nested workspace no egui dependency; its own Cargo.lock; untouched

Platform cfgs that the migration must respect:

  • wasm32: frames.rs remote/local frame pump, audio.rs Web Audio, theme.rs no system fonts, prefs_live.rs no native drops, viewer deferred readbacks (lib.rs:825-855), sys-locale only off-wasm (crates/ui-egui/Cargo.toml:36-37), web storage/workers.
  • native: pointer wrap (pointer_wrap.rs:133), system font fallbacks (theme.rs:388-430), rfd/cpal, native menu (macOS), zbus appearance (Linux), appimage (Linux).
  • eframe/winit platform features: Wayland and X11 on Linux, muda/objc2 on macOS, winresource on Windows.

1.4 Current test and CI commands (source-declared)

  • cargo xtask ci = cargo fmt --check; cargo clippy --workspace --all-targets --release -- -D warnings; cargo test --workspace --release; PDF dependency check; layers; assets; wasm (xtask/src/main.rs:409-418).
  • cargo xtask wasm checks every L0–L4 crate plus effectcraft-ui-egui and effectcraft-web for wasm32-unknown-unknown (xtask/src/main.rs:235-252).
  • UI tests: 81 files in crates/ui-egui/tests, driven by egui_kittest::Harness (build_eframe, run_steps, synthetic Events, automation-registry rects, AccessKit queries). ui_gpu.rs runs the viewer on egui-wgpu's device; ui_agent_audit.rs audits commands and automation coverage; viewer_geometry.rs has an opt-in visual matrix (CRAFT_UI_VISUAL_DIR).
  • Web: cargo xtask web --serve 8765, then node apps/effectcraft-web/tests/smoke.mjs, workers.mjs, page.mjs, storage.mjs (docs/web.md:412-445).
  • Performance: effectcraft-cli bench --ops --json (apps/effectcraft-cli/src/main.rs:885-925), cargo xtask bench (xtask/src/bench.rs), and the headless ui_egui::bench::Driver (crates/ui-egui/src/bench.rs:13-108).

2. What actually changes in egui 0.37 that affects EffectCraft

This section is limited to changes visible in the pinned sources. It corrects two assumptions that must not enter the plan:

  • App::logic / App::ui and Context::run_ui are not new in 0.37. They already exist in 0.36.2 (egui-0.36.2/crates/eframe/src/epi.rs:167,182; egui-0.36.2/crates/egui/src/context.rs:794), and EffectCraft already uses them (crates/ui-egui/src/lib.rs:1852-1935, apps/effectcraft/src/main.rs:426-456, apps/effectcraft-web/src/app.rs:88-122). The migration is not a wholesale update-to-ui rewrite.
  • Context::run_logic is not a substitute for a pass. It does not interpret time or events; run_logic only refreshes viewport state (egui-0.37.0/crates/egui/src/context.rs:1190-1218). Calling it cannot initialise fonts or consume input.

2.1 Compile-visible API changes (the historical audit is a clue, not acceptance)

The 2026-10-22 audit (/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/effectcraft.errors) compiled only effectcraft-ui-egui (lib and lib test), against a historical fork revision and machine-local path patches. It reported the same 15 errors in fork and stock mode, with 602 (stock) / 603 (fork) warnings; the difference is warning counts only. Those errors are consistent with the upstream 0.37 sources read here:

# Anchor in this checkout Error 0.37 API and required change
1 crates/ui-egui/src/color_picker.rs:19 Role::ColorButton absent WidgetType is a deprecated alias of accesskit::Role; use Role::ColorWell (egui-0.37.0/crates/egui/src/lib.rs:644-659)
2 color_picker.rs:336 Role::Other absent Role::Unknown
3 control.rs:371 Event::MouseWheel missing source add source: MouseWheelSource::Wheel (egui-0.37.0/crates/egui/src/data/input/event.rs:175-200)
4 lib.rs:1539 no egui::UserData screenshots are callback based (ScreenshotCallback)
5 lib.rs:1569 no Event::Screenshot receive the image from the callback, not from input events
6 menu_keys.rs:84 UiStack.id absent UiStack.unique_id / UiStack.scope_id (ui_stack.rs:133-150); use scope_id for a stable panel identity
7 panels/effect_controls.rs:1274 Role::SelectableLabel Role::Button with WidgetInfo::selected
8 panels/project.rs:613 Role::Other Role::Unknown (or Role::TreeItem directly)
9 panels/project.rs:781 Role::Other Role::Unknown
10 panels/text_panels.rs:700 Role::SelectableLabel Role::Button
11 theme.rs:732 &dyn AsRef<[u8]> comparison FontData.font is now a Blob = Arc<dyn AsRef<[u8]> + Send + Sync>; compare FontData::bytes()
12 theme.rs:752 same same
13 widgets.rs:24 UiStack.id scope_id
14 widgets.rs:493 expected IdSalt, found Id Atom::custom(IdSalt, …) (atomics/atom.rs:98)
15 widgets.rs:498 expected IdSalt WidgetAtomResponse::rect(IdSalt) (atomics/widget_atom.rs:224)

Deprecations that -D warnings turns into failures (all 602 audit warnings are in this list):

Deprecated item Count in this checkout Replacement
egui::Id::new 616 lines (591 src, 25 tests); 626 occurrences (601 src, 25 tests) across 59 src and 12 test files Id::unique (same hash; id.rs:101-104 delegates to it)
egui::WidgetType 19 egui::Role (variant renames in §2.2)
Ui::id() 2 (color_picker.rs:335,337) Ui::scope_id() (ui.rs:363-371)
egui::AtomLayoutResponse 1 (widgets.rs:497) WidgetAtomResponse
TextEdit::vertical_align 1 (widgets.rs:329) TextEdit::align(Align2::LEFT_CENTER) (builder.rs:408-427)

Integration tests were not compiled by the audit and must be assumed to contain more breakage. Two known classes are already visible by inspection:

  • 11 Event::MouseWheel { … } constructions lack source (control.rs:371, ui_timeline_zoom.rs:33,81, ui_scrolling.rs:34,157, viewer_geometry.rs:98,235, ui_panels.rs:386, ui_tail.rs:195, ui_layer_panel_zoom.rs:109, ui_camera_tracker.rs:173). The 12th match is a pattern in lib.rs:581.
  • ui_agent_audit.rs:73 reads WidgetInfo.typ; it becomes .role.

There is no PointerSource in the target fork: Event::PointerButton keeps the 0.36 shape (aiongg/craft/0.37:crates/egui/src/data/input/event.rs:106-118). The audit error at control.rs:371 was the wheel event, not the pointer button.

2.2 Role mapping used by this migration

WidgetType → accesskit::Role (egui's own mapping table, egui/src/lib.rs:644-659):

Old New
Button Role::Button
ColorButton Role::ColorWell
Slider Role::Slider
SelectableLabel Role::Button + selected
Checkbox Role::CheckBox
TextEdit Role::TextInput / MultilineTextInput
DragValue Role::SpinButton
CollapsingHeader Role::DisclosureTriangle
Panel Role::Pane
ResizeHandle Role::Splitter
Other Role::Unknown

2.3 Behaviour changes that compilation will not catch

These are the app-specific risks, each with a source anchor and a planned assertion.

  1. default_fonts is no longer a default feature of egui/epaint. eframe still enables it by default, but EffectCraft disables eframe defaults and re-enables only default_fonts, wgpu, accesskit, links, wayland, x11 (crates/ui-egui/Cargo.toml:25, apps/effectcraft/Cargo.toml:18). The app relies on FontDefinitions::default() at theme.rs:334; the feature must stay explicitly enabled on the egui dependency, not only through eframe, so cargo check -p effectcraft-ui-egui and test builds are deterministic. (#8583; egui-0.37.0/crates/egui/Cargo.toml:40-46.)
  2. The bundled emoji fonts are no longer in FontDefinitions::default(). In 0.36.2 the default definitions include NotoEmoji-Regular and emoji-icon-font unconditionally (egui-0.36.2/crates/epaint/src/text/fonts.rs:490-580). In 0.37 they are behind monochrome_emoji_fonts, and the default only adds the small egui-icons font (egui-0.37.0/crates/epaint/src/text/font_definitions.rs:105-180). EffectCraft's own chrome is affected. A read-only cmap audit of the fonts actually bundled by this app and by egui found:
    • ✔ U+2714 (menu check gutter, menus.rs:2176) is in only NotoEmoji and emoji-icon-font;
    • ▣ U+25A3 (Media Browser folder, media_panels.rs:198; Essential Graphics media row, essential.rs:247) is in Hack and emoji-icon-font, but Hack is not in the app's Proportional family (theme.rs:339-347 only prepends Inter and appends JetBrains Mono), so only the emoji font covers it;
    • ✓ U+2713 (checked label text, widgets.rs:485) is in Inter/JetBrains Mono and unaffected. Without monochrome_emoji_fonts these two become tofu, and kittest's default MissingGlyphPolicy::Panic turns that into test failures.
  3. Clipboard support became an eframe feature. 0.36.2's eframe always enabled egui-winit/clipboard (egui-0.36.2/crates/eframe/Cargo.toml:152). 0.37 gates it behind clipboard (egui-0.37.0/crates/eframe/Cargo.toml:142-144), which is in eframe's default set but not enabled by EffectCraft's default-features = false manifests. Without it, cut/copy/paste to the OS clipboard silently degrade to an in-app simulated clipboard (egui-winit-0.37.0/Cargo.toml:44-46), breaking Edit ▸ Copy/Paste, text-field paste and menus.rs:1867-1906 which maps clipboard events back to command keys.
  4. Screenshots are callback based (#8489; data/screenshot_callback.rs, ViewportCommand::Screenshot(ScreenshotCallback), Context::request_screenshot). The current correlation by UserData token through Event::Screenshot is impossible (lib.rs:1530-1580, control.rs:439-444). See §5.7.
  5. WidgetType and ID APIs are deprecated, and the CI clippy gate is -D warnings (xtask/src/main.rs:412). This is 626 mechanical Id::new occurrences (616 lines) plus the 19 source role edits and one more in ui_agent_audit.rs (see §5.2/§5.3).
  6. AccessKit roles/names are enforced by kittest 0.37. HarnessBuilder defaults check_accessibility: true and Harness::run panics on visible input widgets without a name (egui_kittest-0.37.0/src/builder.rs:50-51, src/accessibility.rs:9-45). is_input covers Button, CheckBox, ColorWell, ComboBox, Slider, SpinButton, Splitter, Tab, TextInput, TreeItem, … (egui-0.37.0/crates/egui/src/accessibility.rs:9-60). Many of the app's stock TextEdit, DragValue and Slider fields are currently unnamed (39 TextEdit::singleline/multiline constructors out of 44 TextEdit:: references, and 51 DragValue/Slider constructors), and custom ui.interact widgets have no WidgetInfo at all.
  7. Missing glyphs panic in kittest 0.37 (#8601; MissingGlyphPolicy::Panic is the harness default, builder.rs:50). Tests that deliberately render uninstalled scripts must opt out with HarnessBuilder::allow_missing_glyphs(); two are already known: tests/ui_effect_names.rs:35 (the builder inside harness() at line 20, used by the zh-hans tests at 68, 81, 107 and 127, including 高斯模糊) and tests/ui_presets_folder.rs:40 (ja). Fontconfig on this planning host lists no faces at all, so those tests would panic rather than draw tofu until a CJK face is installed or the opt-out is used.
  8. Ui::id → Ui::scope_id, UiBuilder::id → scope_id, Panel constructors take impl AsIdSalt. EffectCraft does not use SidePanel/TopBottomPanel, and uses CentralPanel::default() only (dock_ui.rs:475, prefs_live.rs:207), so no panel-order rewrite is needed. The root Ui is already the app surface (lib.rs:1905-1935, lib.rs:1614-1723). Never call Context::root_ui from a panel or a test closure: it panics while the root Ui is borrowed (context.rs:1005-1055).
  9. Style defaults changed (roomier buttons, larger corner radii; #8617, #8445). EffectCraft pins item_spacing, button_padding, interact_size.y, menu_margin, scroll style and all widget corner radii from tokens (theme.rs:510-530, theme.rs:474-495), so the visible impact should be small, but atom-based Button/ComboBox layout can still move geometry. This is a geometry/pixel test target, not an assumption.
  10. Menus/popups and text editing changed (#7802, #7624, #8572, #8548, #8763, #8628). EffectCraft builds its own menu bar and keyboard navigation (menus.rs:2025-2050, menu_keys.rs), with MenuConfig style overrides. Regression tests must cover hover-switch, arrows, submenu opening, outside-click close, and a button acting on a focused TextEdit's selection (the #8763 case). Container tooltips now also show when their children are hovered (#7909); check tooltip presence/absence on the app's large custom interact rects (viewer, timeline, dock) instead of assuming the 0.36 behaviour.
  11. Kinetic scrolling/rubber-banding (#8662, #8663) and the new MouseWheelSource change wheel semantics. Synthetic wheel events must say Wheel (not Unknown) so tests and the control channel keep deterministic scroll behaviour.
  12. Frame::default_fonts + AccessKit bump: accesskit 0.24.1 → 0.25.1, accesskit_winit 0.32.2 → 0.34.1, kittest 0.4.0 → 0.5.0. The kittest trees are not identical: lib.rs re-exports accesskit_consumer::NodeRef as AccessKitNode where 0.4 re-exported Node, and query.rs/filter.rs also differ. The 26 query call sites must be compiled against 0.5, not assumed unchanged.
  13. New 0.37 lifecycle tools that this plan uses:
    • ViewportCommand::RequestPaintWhileHidden and ViewportInfo::visible() (viewport.rs:1202-1240, viewport_info.rs:90-101); visibility remains derived from minimized/occluded even during a forced hidden UI paint;
    • Context::request_screenshot / ScreenshotCallback (context.rs:4595-4606). The call sends a ViewportCommand, which requests a repaint of that viewport; a pending screenshot also makes a hidden window take a full UI+paint pass (wgpu_integration.rs:686-690, egui-winit/src/lib.rs:2116-2128). It does not schedule a repaint when the callback completes; the completion slot owns that wake-up (§5.7);
    • Context::set_repaint_observer and RepaintCause (context.rs:2290-2296, 283-300).
  14. Known upstream regressions at 0.37.0 (reported upstream; not measured by this task):
    • #8731: plain-text tessellation ≈61% more instructions (tessellate_text per-vertex colour check). The candidate comparison revision is unpatched: aiongg/craft/0.37:crates/epaint/src/tessellator.rs:2295-2341 still has the conditional inside the vertex loop.
    • #8736: egui-wgpu pipeline creation is ~40 ms slower on Windows/DX12 (FXC) because of wrap_texel_coord. Both are measured acceptance gates in §9 and, if over threshold, must be fixed in the final P8 egui revision before P1 is frozen. P5 reports/retests; it does not change the pin.

3. Fork, winit and craft-libs integration decisions

3.1 What EffectCraft actually needs from the suite fork

EffectCraft has no vendor/ directory and no local egui patch. Its only [patch.crates-io] entry is hayro-syntax, inherited from the craft-libs PDF stack (Cargo.toml:213-214); it is unrelated to egui and must stay.

The production dependency is the complete reviewed and published storytold egui P8 revision, not the personal aiongg branches used as comparison input here. P8 carries E1–E21, P2b async W15P routing, P7 held-key pairing, P7b/E21b outgoing IME surrounding text (§5.5), P7c's selected #8763/#8748 fixes and the justified #8731/#8736 performance fixes. The completed final winit SHA carries W1–W27, W15P and the W27 lifecycle fixes. The two forks and craft-ui CL-1 + CL-2F are approved as one immutable unit at P1; P4 adds extended native/language coverage without a second compatibility graph (§10). There is no stock or intermediate-fork production stage; stock 0.37.0 is a measurement control only.

Fork patch EffectCraft use Decision
E1 viewport_hooks not used (craft-tablet only) keep in fork; no app change
E2+E3 surface fit / wgpu error scopes protects windows whose surface exceeds the adapter limit and reports configure/renderer failures; relevant to the app's "never crash" rule and its device-loss handling keep, benefit; verify with a required-adapter ui_gpu.rs run and a device-loss test
E4 Wayland clipboard/PRIMARY the app edits text in project rows, timeline fields, expression fields, Script UI and settings; Hyprland serves one data device per client keep; requires winit W15 plus the new W15P PRIMARY path (§5.5/§8.7)
E5/E12/E13/E16 bidi and grapheme editing UI labels and stock TextEdits only; text layers use effectcraft-text, not epaint keep; do not claim it fixes text layers
E6 IME focus stock TextEdits keep
E7/E19 letter keys on non-Latin layouts shortcuts (menus::parse_shortcut, handle_shortcuts); requires winit W25 keep; manual platform test
E8 TextEdit glyph clipping stock fields (Thai/Vietnamese/Arabic marks) keep
E9 Style::right_to_left app never sets it; its dock/layout is custom LTR keep in fork; do not enable in EffectCraft (would mirror stock widgets inside an unmirrored custom layout)
E10 web named keys / Latin codes web keyboard behaviour keep; no app change
E11 vello_cpu multithreading not used keep in fork
E14 FontTweak::fallback_align the app installs system fallback faces (theme.rs:388-430) keep; optional follow-up to set it for CJK/Indic fallbacks, with screenshot proof
E15 LineBreaker text layers use effectcraft-text; not adopted keep in fork
E17/E18 Context::set_language / Frame::set_ui_language screen-reader language and UI shaping language (the app has an interface language setting, i18n::language) adopt in the language stage (fork-only; see §5.9)
E20 widget text language goes with E17 adopt with E17
E21 ImeEvent::DeleteSurroundingBytes the app has a custom viewer text editor that consumes IME events itself (viewer_text.rs:397-508) adopt in the IME stage; stock 0.37 only has char-based DeleteSurrounding
P2b (new prerequisite) async W15P PRIMARY routing in egui-winit: bounded read workers, owner-specific completion waker, no blocking UI path in P8; gate in §5.5/§8.7
P7 (new prerequisite) held-key pairing: the emitted egui key identity is stored by physical key and reused on release; clipboard shortcuts alone do not prove this in P8; gate in §3.2/§5.5
P7b/E21b (new prerequisite) candidate egui-winit never calls winit W27's set_ime_surrounding_text; IMEOutput carries no logical text. The custom viewer editor is a required producer. in P8 before final acceptance; see §5.5, §8.4 and ime-surrounding-gap.md
P7c (new prerequisite) selected upstream fixes #8763 (a button/menu command acting on a focused TextEdit selection) and #8748 (hidden parent not updating deferred child viewports) in P8; tested in §5.6/§5.8. Local workarounds are not the final unit.

Removal conditions are the fork's own (PATCHES.md): each patch goes when an egui/winit release in use contains it. EffectCraft must not add a second copy of any of these fixes locally.

3.2 Winit patches that matter here

Patch Why EffectCraft needs it Gate
W1 mixed-DPI rect window placement and monitor hit tests (apps/effectcraft/src/window.rs) Windows mixed-DPI manual test
W2/W5/W20/W21 X11 IME the app's editable fields and expression editor X11 IME under Xvfb+WM (qualified parts only; §8.7)
W3/W18/W19/W22/W24/W26/W27 IME the custom text editor and stock fields; W26/W27 are required by E21 and by E21b/P7b macOS press-and-hold/dictation, Wayland text-input-v3, Windows IME
W15P (new) winit-owned Wayland PRIMARY on the existing connection/event queue, per-seat manager/device/source/offer from SCTK 0.19.2; no second regular data device wlroots/Hyprland, KWin and Mutter capability matrix (§8.7)
W13 Wayland wheel phase zoom/scrub shortcuts must not latch Wayland manual test
W15 Wayland clipboard/files needed by E4 Hyprland clipboard check
W23 macOS Command keys menu shortcuts macOS layout test
W25 Latin layout E19 shortcuts; preserve the emitted key identity by physical key across release Russian/Arabic/Hebrew layout test plus a held Ctrl+Z/Cmd+K release after a layout/modifier change

W27 also needs the lifecycle fixes recorded in winit-review-triage.md: clear the pending preedit/commit/deletion batch on leave with an explicit epoch, test late Done, disable/reset, seat removal and two windows, and preserve deletion-FIFO/commit pairing. Focus epochs and protocol serials are distinct: a current-epoch Done with a mismatched serial still applies its incoming edits exactly once and only defers outgoing surrounding state until a matching serial; a stale (different owner/focus generation) batch is invalidated and discarded. W25/W23/E19 must preserve the emitted egui key identity by physical key across release (including layout/modifier changes), clearing it on focus loss, IME interception and destruction, and test a non-clipboard held key (Ctrl+Z/Cmd+K), repeat semantics and two physical keys mapping to one logical key. W15 needs bounded, cancelable transfers with a 2 s total monotonic deadline and a 16 MiB total byte budget; the egui adapter must not block the UI and must cancel stale pastes after focus/target/generation changes.

3.3 craft-libs PR 199, CL-2 and language PR 198

  • PR 199 is merged at 3832c47ef68a0e6214f2c1e85f3526b6adcb686e, the current reconciled shared-library implementation base. Do not merge its historical head b68de8c5 (a stale tree relative to current main, missing craft-platform and reverting security/settings/docking/ printer fixes) or audit merge ec8840a (stale lock, egui 0.36.2) as trees. The historical API port 8614e4a and README 1764bca on old main 4919088 are superseded by the merged base.
  • EffectCraft pins craft-ui to the reviewed CL-1 merge/rebase SHA on craft-libs main after the port, not to the PR head. That revision must keep its egui dependency on registry 0.37 so EffectCraft's [patch.crates-io] (not a git URL patch) can redirect it.
  • CL-2 capture completion helper. The shared plan's selected design is a bounded, cancelable per-request completion slot with a weak callback reference, aggregate image-byte and request limits, and an explicit viewport wake-up after the slot lock is released. EffectCraft is one of the eleven existing craft-ui consumers, so it pins CL-1 + CL-2F in the same atomic dependency stage as P1; only the DesignCraft/GridCraft pilots use the reviewed provisional CL-2 revision, and their adapters are reviewed before CL-2F freezes the API. Any consumer with an incompatible existing craft-ui bump must reconcile it in that same stage. EffectCraft does not pin the provisional revision. No app-local result channel and no generic UI compatibility layer. EffectCraft keeps its own request ids, paths, crop, deadlines and reply JSON.
  • craft-ui's production API used here (docking::{Action, Zone, Placement, resolve_drop, zone_rect, drop_zone}, layout::{split_rect, SplitAxis, SplitSize, ResizeHandle, ResizeHandleStyle}) is unchanged by the port; the additive printing::tick is not used by EffectCraft.
  • PR 198 / the fork language work is adopted only where §3.1 says so. The app keeps its own catalogs (i18n.rs, i18n/ui.rs, i18n/effects.rs) and its own text engine; it does not adopt craft-text line breaking or a shared translation layer.
  • Blocking: CL-1 and CL-2F must land before the EffectCraft dependency-pin PR can be reviewed honestly. Until then, implementation may use a local pinned SHA only for development, never in a publication branch, and never a machine-local path patch.

4. Root-Ui, lifecycle and multi-pass semantics

4.1 Current split (already on 0.36.2, must be preserved and completed)

Today EffectcraftApp::logic does GPU-failure application, language/theme/font install, FPS, control drain, screenshot scheduling/collection (lib.rs:1870-1903), while ui calls frame() which does polls, playback, events, autosave, prefs, window title, close guard and the whole paint (lib.rs:1905-1935, lib.rs:1614-1723).

Verified integration facts (candidate source, same call sites as upstream 0.37):

  • eframe runs logic and ui inside one Context::run_ui closure for every full pass (eframe-0.37.0/src/native/epi_integration.rs:349-364; web app_runner.rs:289-292). A full pass can run more than once per frame when Context::request_discard is used.
  • While the root window is minimized/occluded, native eframe runs Context::run_logic with App::logic only (epi_integration.rs:384-425), unless a Screenshot or RequestPaintWhileHidden action is pending, in which case it runs a full UI pass with current input and paint (wgpu_integration.rs:680-740).
  • prepare_raw_input calls raw_input_hook before both paths (epi_integration.rs:414-428). update_logic_only then stores the hook-mutated raw input in pending_raw_input for the next full pass (epi_integration.rs:400). Therefore synthetic events fed during a logic-only call are retained, not lost, and are applied exactly once by the next full pass.
  • Native eframe throttles repaints of an invisible/minimized window to at least 100 ms (eframe-0.37.0/src/native/run.rs:27,376-383), so a hidden gesture or capture takes a few throttled full passes; the app's deadlines must allow for that and must not infer failure from the first 100 ms.
  • ViewportInfo::visible() derives from minimized/occluded, not from a paint request. It stays Some(false) during a forced hidden UI pass, so visibility cannot distinguish "logic only" from "forced hidden paint".
  • run_logic refreshes only raw.viewports and raw.focused (egui-0.37.0/crates/egui/src/context.rs:1190-1218); it leaves InputState::time, events and pointer state at the previous pass's values. eframe sets RawInput::time in prepare_raw_input, but run_logic does not copy it into InputState.
  • Context::request_screenshot sends a viewport command, which requests a repaint, and a pending screenshot is a "wants paint" action even when hidden. The callback itself does not request a repaint when the pixels arrive.

Consequences, all pre-existing in 0.36.2:

  • With the window minimized/occluded, job polling, playback advance, audio pumping, auto-save ticks, engine-event draining, the unsaved-close guard and the window-title update do not run on ordinary logic-only ticks.
  • A discarded pass re-runs the run_ui closure, so logic runs again for the same frame. Grid requests a discard on first show (egui-0.37.0/crates/egui/src/grid.rs:468); EffectCraft has 18 Grid::new sites across dialogs/settings/forms.
  • The control channel's AfterInput replies are completed in logic as soon as synthetic is empty, which is before the pass applies the last synthetic event (lib.rs:1893-1901, lib.rs:1921-1930). While hidden, an event can also have been fed into retained raw input with no pass yet applied.

4.2 Target boundaries

Introduce one explicit app tick and one draw pass; do not add a generic Context bridge:

// EffectcraftApp
now: f64,          // monotonic app clock value for this tick, used for deadlines
focused: bool,     // last known focus, refreshed from viewport info
last_input_at: f64,
last_draw_geom: Option<(egui::Rect, f32)>, // last non-discarded root draw: screen rect + ppp
geometry_epoch: u64,   // bumped whenever that screen rect or ppp changes
want_paint: bool,
clock: AppClock,   // real or injected (§4.3)

/// The only non-paint entry. Effects are idempotent; time may advance slightly per call.
pub fn tick(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame);

/// Paint only. Receives the root Ui from eframe.
pub fn draw(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame);

// eframe::App
fn raw_input_hook(&mut self, ctx, raw) { /* feed synthetic, pointer wrap; no clock, no replies */ }
fn logic(&mut self, ctx, frame) { self.tick(ctx, frame) }
fn ui(&mut self, ui, frame) { self.draw(ui, frame) }

tick must contain, in this order:

  1. init_gpu(frame) (moved from ui, lib.rs:761-806), so the compositor exists before the first paint and while hidden; apply_gpu_failure + close_after_device_loss (moved from ui, lib.rs:1905-1910).
  2. i18n::set_current(language), theme/font install and apply_prefs (moved from frame).
  3. Engine/background polling: poll_render, poll_track, poll_mask_track, poll_jobs, poll_offload, poll_warp, poll_roto, poll_camera (lib.rs:1617-1655), plus frames.set_context(ctx) so worker completions can request repaints while hidden. These are queue-draining and a second call in a discarded pass is a no-op (e.g. render_queue.rs:579-583 takes the update queue).
  4. run_script_tasks, handle_events (engine queue drained once), command_inbox; toast/status timestamps use now, not ctx.input(|i| i.time).
  5. run_dialog_answers and the native-menu answers.
  6. tick_autosave (already wall-clock based, lib.rs:1790-1816) with the app clock, watch folder, tick_footage_reload using self.focused/self.now/self.last_input_at instead of ctx.input.
  7. Control: drain_control with now, finish_job_waiters with a bounded repaint.
  8. Screenshots: collect_screenshots (drain completion slots and validate each image against the geometry + epoch recorded at issuance, §5.7), then issue_screenshots (now), then timeouts and cancellations.
  9. Playback: advance_playback(ctx, scale, now) using the last drawn viewer scale; audio pump; cache_when_idle.
  10. Unsaved close guard (panels/unsaved.rs:50-62) and window-title update.
  11. prefs_live::memory_watch / home_on_open (split out of prefs_live::frame, prefs_live.rs:15-19); video_preview stays in draw.
  12. If want_paint, or synthetic input/input_waiters remain, or a control step needs a UI pass, send ViewportCommand::RequestPaintWhileHidden and clear want_paint. This also covers playback with an open Video Preview or floating panel window (§5.8).

tick never reads input events, pointer state or modifiers for decisions. draw keeps only paint and direct manipulation: dock layout, panels, menus, dialogs, areas, ui.inspect registry, pointer hover, shortcuts that read events, finish_native, video_preview, dropped-file overlay/import, Home thumbnail capture, toast, workspace-state save on pointer-up, and frames.pump on wasm (or move pump to tick; either is acceptable if tested, but it must not be the only progress path). draw updates last_input_at from the pass's input activity and records last_draw_geom = Some((ui.ctx().screen_rect(), ui.ctx().pixels_per_point())) at the end of every pass that is not discarded (!ui.ctx().will_discard()), bumping geometry_epoch when the rect or ppp differs from the previous value. Screenshot issuance copies this geometry + epoch; completion rejects/retries if the epoch changed before the save (§5.7). The callback itself supplies only pixels, so the issued record — not callback timing — is the geometry anchor.

No Context::root_ui anywhere. Areas/Windows/Modals keep taking ctx (they create their own layers); panels receive the existing &mut Ui. show_viewport_immediate stays as is.

4.3 App clock, focus and input gating

  • The clock is app-owned and injected. Neither ctx.input(|i| i.raw.time) nor ctx.input(|i| i.time) is valid in a logic-only tick: run_logic copies only viewports/focused (context.rs:1190-1218), so i.raw.time is the previous full pass's raw value and i.time is the previous pass's InputState::time; kittest leaves RawInput::time None.
    pub struct AppClock {
        source: ClockSource, // Real(web_time::Instant) | Manual(f64)
    }
    impl AppClock { fn now(&self) -> f64 { /* monotonic seconds */ } }
    let now = self.clock.now();
    
    tick stores self.now = now before using it. Tests inject AppClock::manual() and advance it; a regression must show that a stale ctx.input(|i| i.time) cannot satisfy a deadline. Native and web use the same web_time::Instant source; EffectcraftApp::now() exposes the last value so WebApp::logic can pass it to persist::snapshot (§8.6).
  • Focus comes from the fresh viewport copy with a visible-pass fallback:
    let focused = ctx.input(|i| i.viewport().focused)
        .or_else(|| ctx.input(|i| i.focused));
    self.focused = focused.unwrap_or(self.focused);
    
    Do not read i.events/pointer in tick; input handling belongs to draw (the known UI phase).
  • Synthetic input is not gated on visibility, but is gated on an unapplied event. raw_input_hook runs once per eframe prepare (frame or logic call), not per egui UI pass, and calls self.feed_synthetic(raw_input) then pointer_wrap::prepare_input(...). In a logic-only call eframe retains the mutated raw input (pending_raw_input) and calls the hook again on the next call; in a forced hidden full pass it is applied immediately. Gating on visible() == Some(false) would skip feeding in both cases and leave the gesture (and its reply) pending forever. feed_synthetic drains one pointer event per call (lib.rs:578-591), so repeated logic-only updates before the forced full pass (eframe's overdue-RedrawRequested path and its 100 ms invisible-window throttle) would append press, move and release to the same retained input and collapse a gesture into one pass. The hook must therefore set a guard when it moves a synthetic event into raw input and skip feeding while that guard is set; the guard is cleared when a known UI pass has applied that step once, even if layout is later discarded; record that application separately from screenshot settling/accepted-frame counters. RawInput events are consumed on the first pass and are not replayed in later passes. Never clear the guard in tick; never wait for the event to reappear in an accepted pass. The next step is fed only on a subsequent host input preparation. No event is discarded. §8.3 tests repeated logic-only ticks with a drag and a click.
  • raw_input_hook performs no reply, no clock read and no side effect other than pointer-wrap state, the synthetic-pending guard and merge-key closing. pointer_wrap::prepare_input returning "gesture ended" clears session.history.merge_key; doing that during a logic-only call is safe because the events are retained and the same reset is idempotent.
  • Replies for AfterInput are completed only at the end of a non-discarded draw pass (after the pass, check !ui.ctx().will_discard()) that ran after the last synthetic event was fed, and never in tick. A hidden request therefore replies only after the forced pass applied it, and a pass that egui throws away cannot produce an early success reply.
  • If a control request needs a UI pass (synthetic input, a panel-window change, or a caller that explicitly wants fresh ui.inspect/ui.elements data), tick sends RequestPaintWhileHidden.
  • control::handle is called from tick with now passed in (a signature change, not a wrapper): replace its ctx.input(|i| i.time) at control.rs:428 with now, and inspect's windowFocused at control.rs:562 with ctx.input(|i| i.viewport().focused). ui.inspect and ui.elements keep last-painted semantics.

4.4 Multi-pass and idempotence

A discarded pass re-runs tick and draw with the same raw input (events are taken by the first pass). Rules:

  • tick effects are idempotent for a replayed pass: polls drain queues; auto-save checks a timestamp; advance_playback computes the target frame from the plan and the monotonic now rather than incrementing, so a replay with a slightly later now does not double-count Playback::frame_shown (lib.rs:230-246); screenshot scheduling advances only when now passes a settle deadline, never once per pass (the old frame counter must not be replayed); job-waiter completion is keyed by the pending job, not by pass count.
  • One-shot side effects in draw must rely on egui's per-pass input (Response::clicked, drag_stopped) and command merge keys, not on layout-pass counts. High-risk paths are viewer drags (viewer.rs:1674-1874), timeline drags (timeline.rs:1500-1700), painting strokes (layer_panel.rs:350-360) and form toggles inside Grids.
  • Add a forced-discard regression test (§8.3) that proves one undo step and one command effect across a real two-pass frame.

5. Concrete refactors with anchors

5.1 Manifests, features, pins and lock checks

Root Cargo.toml (final publication form; placeholders are filled only after the reviewed forks exist). P1 waits for the complete reviewed/published storytold egui P8 (E1–E21, P2b, P7, P7b/E21b, P7c and any justified perf fix), the completed winit (W1–W27, W15P, W27 lifecycle) and craft-ui CL-1 + CL-2F. It includes the custom-editor IME payload adapter atomically. Stock 0.37.0 and aiongg candidates remain comparison controls, never intermediate production stages.

craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "<reviewed main SHA including CL-1 and CL-2F>", features = ["serde"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = { version = "0.37" }

[patch.crates-io]
winit = { git = "https://github.com/storytold/winit", rev = "<final reviewed winit SHA incl W15P>" }
egui = { git = "https://github.com/storytold/egui", rev = "<final reviewed egui P8 SHA incl E21b + P7c>" }
eframe = { git = "https://github.com/storytold/egui", rev = "<same egui SHA>" }
egui-winit = { git = "https://github.com/storytold/egui", rev = "<same egui SHA>" }
egui-wgpu = { git = "https://github.com/storytold/egui", rev = "<same egui SHA>" }
egui_kittest = { git = "https://github.com/storytold/egui", rev = "<same egui SHA>" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" } # keep

epaint, epaint_default_fonts, ecolor and emath are path dependencies of the git egui crates and must resolve from that same git revision. aiongg/* SHAs are comparison inputs only and must not appear in a publication lock.

Notes:

  • craft-ui is a git dependency from craft-libs; redirecting it with [patch."https://github.com/storytold/craft-libs"] is only acceptable for development, never in a publication branch. The publication form is the real rev.
  • Do not patch egui_plot, egui_dock, egui_extras (unused) or egui_system_font_provider (unused while system_fonts is off).
  • The fork's own [patch.crates-io] winit = { branch = … } belongs to the fork workspace and does not propagate; the consumer patch above is mandatory.
  • Feature additions in the app manifests:
    • crates/ui-egui/Cargo.toml:25: eframe features += clipboard.
    • apps/effectcraft/Cargo.toml:18: eframe features += clipboard.
    • apps/effectcraft-web/Cargo.toml:30: keep default_fonts, wgpu (web has no OS clipboard feature; browser clipboard events are separate).
    • egui_kittest dev features: eframe, wgpu, plus snapshot only if §8.4's pixel goldens are adopted. egui_kittest depends on egui with default-features = false; only kittest's own [dev-dependencies] enable default_fonts/monochrome_emoji_fonts, and a dependency's dev-dependencies do not propagate. The workspace egui features above are therefore what EffectCraft's library and integration-test builds receive; do not rely on kittest to enable fonts. If a test root resolves egui independently, it must enable the font features itself or install controlled fonts, and the actual graph is checked per production/test root.
    • monochrome_emoji_fonts is enabled at the workspace egui dependency so ✔/▣ survive (§2.3.2). It propagates into effectcraft-web; record the raw and compressed WASM size change and either accept it with a budget or introduce a native-only ui-egui emoji-fonts feature after measuring (§9).
    • system_fonts stays off initially: the app already installs language-driven system fallbacks (theme.rs:388-430) and scans fonts for its menus; enabling eframe's fontique provider would add startup cost (#8512) and make rendering machine-dependent. Revisit as a separate product change for colour emoji.
  • winit direct dependency in apps/effectcraft/Cargo.toml:23 must match the patched fork (it does: 0.30).
  • Lock/duplicate checks after every pin change:
    cargo tree -d
    cargo tree -i egui@0.37.0
    cargo tree -i winit@0.30.13
    cargo tree -e features --target x86_64-unknown-linux-gnu
    cargo tree -i wasm-bindgen --target wasm32-unknown-unknown
    cargo check --locked
    cargo test --locked
    cargo metadata --locked --format-version 1 --no-deps
    
    Acceptance: exactly one egui-family source (the final storytold revision) covering egui, eframe, egui-winit, egui-wgpu, egui_kittest, epaint, epaint_default_fonts, ecolor and emath; exactly one winit (the final storytold revision); no registry egui/epaint beside it; no path = "…" patch and no branch dependency; a clean checkout fetches every approved revision and passes the checks with --locked.
  • wgpu stays 30.0.1; egui 0.37 accepts 30.0 and already resolves 30.0.1 here. The pinned wasm-bindgen = 0.2.129 must stay consistent with wasm-bindgen-futures; check the wasm-target inverse tree above.

5.2 IDs and scopes

  • Mechanical Id::new( → Id::unique(: 626 occurrences / 616 lines (601/591 in source, 25 in tests; 59 source and 12 test files). Count by occurrence and file, not only by line. GlyphId::new in crates/text/crates/pdf (7 lines) is unrelated and must stay. Values are identical because Id::new delegates to Id::unique (egui-0.37.0/crates/egui/src/id.rs:101-104); persisted workspace state (workspace_state.rs, version 1) and project JSON store no egui IDs, so no migration is needed.
  • color_picker.rs:335,337: ui.id() → ui.scope_id().
  • widgets.rs:24: s.id → s.scope_id (the panel's stable scope; do not use unique_id, which changes with layout).
  • widgets.rs:492-497: make label_swatch_id() -> egui::IdSalt = IdSalt::new("label-swatch"); Atom::custom(salt, …); WidgetAtomResponse::rect(salt).
  • menu_keys.rs:84: find_menu_root(ui).scope_id.
  • No new ID scheme; add a test that the same source yields the same Id across frames and that two different payloads differ. Do not derive IDs from translated text (already true).

5.3 Roles, names and accessibility

Migrate the 19 source WidgetInfo sites with the table in §2.2, plus ui_agent_audit.rs:73 in the test (.role). Specific choices:

  • color_picker.rs:19: Role::ColorWell, label = hex (existing screen-reader contract).
  • color_picker.rs:336: Role::Unknown (a painted swatch, not an input).
  • color_picker.rs:338: Role::Button ("Original color").
  • color_picker.rs:271,300: Role::Slider (labels stay).
  • effect_controls.rs:1274, text_panels.rs:700: WidgetInfo::selected(Role::Button, enabled, selected, label).
  • panels/project.rs:613: use WidgetInfo::labeled(Role::TreeItem, …) directly, but keep the accesskit_node_builder at project.rs:614-620 for selected, level and expanded (WidgetInfo cannot express level/expanded); remove only its set_role(TreeItem) line.
  • project.rs:781: Role::Unknown (comment).
  • dock_ui.rs:367: Role::Splitter ("Resize floating panel") instead of Button.
  • widgets.rs:497: WidgetAtomResponse.

Naming work for kittest's accessibility check:

  • Strict checks stay on from the first green PR. There is no shared test harness to flip: Harness::builder() appears at 180 call sites in 78 test files, and HarnessBuilder::default() sets check_accessibility: true (egui_kittest/src/builder.rs:51). A new helper would not reach existing direct builders, and a per-builder with_accessibility_check(false) would only hide failures. P1 therefore adds the actual names until every test passes with strict checks; a repository grep gate forbids with_accessibility_check(false) (§8.2). No global or blanket opt-out exists at any stage.
  • Stock fields: give each TextEdit a hint_text or accessible_name, each DragValue/Slider an accessible_name. Known call sites: TextEdit (39) in precomp.rs:117, rq_templates.rs:137, scriptui_view.rs:145, timeline.rs:1507,2066,2458,3516, project.rs:704, shortcut_editor.rs:276,440,532, markers_ui.rs:119, text_panels.rs:607, settings.rs:174,607, effect_controls.rs:1214, forms.rs:786,794,806, dialogs.rs:471, plus the rest from grep -rn TextEdit:: crates/ui-egui/src; DragValue/Slider (51) in layer_styles_dialog.rs, gradient_editor.rs, settings.rs, key_dialogs.rs, forms.rs, dialogs.rs, dialogs_3d.rs, viewer_tools.rs, scriptui_view.rs.
  • For rows whose label is a separate widget, use Ui::label_inputs_by(label_id, |ui| editor) — e.g. the Grid rows at forms.rs:771, settings.rs:596, key_dialogs.rs:142,207,251, markers_ui.rs:133, rq_templates.rs:56,100, layer_styles_dialog.rs:214.
  • For rows whose label is painted (Timeline property rows, Effect Controls rows), use Ui::name_inputs(label, |ui| editor) or Response::accessible_name(label) where the label is already known. This covers timeline.rs:2458,3516 and effect_controls.rs:1214.
  • Custom widgets have no WidgetInfo today; add a small local helper, not a compatibility layer:
    fn named(resp: egui::Response, role: egui::Role, name: impl Into<String>) -> egui::Response {
        resp.widget_info(|| egui::WidgetInfo::labeled(role, resp.enabled(), name.into()));
        resp
    }
    
    Use it first for the highest-value controls: dock tabs (Role::Tab, selected state, dock.rs:1149-1280), active/close buttons, the dock split handle, numeric scrub fields (widgets.rs:53-160), the color field/slider (color_picker.rs), the error bar prev/next buttons (expr_bar.rs:59-70), and the floating-panel resize grip (dock_ui.rs:367). Do not blanket-wrap ui.interact: a node without a real name/role is worse than no node. The helper stays private to effectcraft-ui-egui; there is one consumer (this app), so it is not proposed for craft-ui and is not a generic compatibility layer.

Accessibility assertions (§8.4) must include roles, selected, enabled state, names, and the AccessKit language once §5.9 lands. The app's ui_agent_audit.rs (automation coverage) stays separate: an automation id is not an accessible name. docs/gaps.md:227 records that the project has done no accessibility work; this stage is a first, tested step (visible stock controls and the highest-value custom controls), not a claim of screen-reader parity.

5.4 Fonts, glyphs and text

  • Enable egui/default_fonts and egui/monochrome_emoji_fonts in the workspace dependency (see §5.1) so FontDefinitions::default() at theme.rs:334 keeps the 0.36.2 font set. Do not rely on eframe feature unification for correctness.
  • Keep the app's two-pass font warm-up (styled/fonts_ready, lib.rs:1876-1890, lib.rs:1905-1911): 0.37 may apply set_fonts immediately when no text has been laid out (#8602, #8609), but relying on that would change when the first frame draws and would make headless tests depend on pass timing. Do not use run_logic to install fonts in a UI test.
  • theme.rs:732: d.font.as_ref() == bytes → d.bytes() == bytes (and keep d.index).
  • widgets.rs:329: TextEdit::vertical_align(Align::Center) → TextEdit::align(Align2::LEFT_CENTER) (the old call only changed the vertical component; the horizontal default was LEFT).
  • Update the comment at theme.rs:354-360 ("egui … has no bidirectional layout"): the suite fork shapes RTL runs, but text layers still use effectcraft-text and are unaffected. Add a UI-label RTL test (Arabic/Hebrew Label/menu text has visual-right-to-left run order) and keep the text-layer RTL test in crates/text/src/layout.rs:1419 as the text-engine contract.
  • Keep system_fonts off. If colour emoji is wanted later, it is a separate change with a startup measurement and cross-platform screenshots, not part of this migration.
  • Fallback registration stays as is (theme.rs:388-430), including FontData::from_owned and data.index = face.info.index; FontData still accepts that construction in 0.37. Optional follow-up: E14 FontTweak::fallback_align for the installed system faces, with CJK/Indic screenshots before and after.
  • Extend the existing glyph-coverage test (theme.rs:700-716) to the full chrome symbol set used by the app, including ▣, ✔, ✓, ⚠, ▶, ◀, ▸, ●, ○, …, ×, ›, •, ·, −, ←/→/↑/↓, ⌘, ⇧, ⌥, ⌃, ⏎, ↵, ′, ″, in Proportional, Monospace, medium and semibold. This is the regression test for finding 2.3.2.
  • Tests that deliberately render scripts with no installed face opt out explicitly at that test builder: ui_effect_names.rs:35 (the builder inside harness() at line 20, used by the zh-hans tests at 68, 81, 107 and 127) and ui_presets_folder.rs:40 (ja; the builder is inside the test) use Harness::builder().allow_missing_glyphs() with a comment naming the script. No global opt-out. Test fonts come from the workspace egui/default_fonts + monochrome_emoji_fonts features (§5.1), not from egui_kittest's non-propagating dev-dependencies.
  • Headless text rendering is not proof of native font fallback/IME; native platform checks remain (§8.7).

5.5 Input, IME, clipboard and PRIMARY

  • control.rs:371: Event::MouseWheel { …, source: egui::MouseWheelSource::Wheel }; the same in the 11 test constructions (§2.1). Use Wheel, not Unknown, so synthetic scrolls are not treated like momentum/rubber-band input.
  • viewer_text.rs:397-508 (custom text editor): handle stock egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } by converting the caret position to a char range and issuing text.delete { range, merge: <same key> } before the following ImeEvent::Commit, so replacement is one undo step. Native winit emits the fork's DeleteSurroundingBytes { before_bytes, after_bytes } (E21/W26/W27); add that arm in the language/IME stage, converting UTF-8 byte offsets at char boundaries via TextDoc::text, TextDoc::char_of_byte and TextDoc::byte_of (crates/keyframe/src/text_doc.rs:527-545). Regression: preedit + delete-surrounding + commit replaces exactly the intended characters and adds one undo step; press-and-hold/dictation on macOS is verified manually (W26).
  • Incoming composition lifecycle (viewer_text). Each viewport stores ImeComposition::Active(owner), TerminalPending(owner) or Stale, where the owner is (layer, edit session) and excludes caret, selection, text, revision and geometry. A target switch or text.endEdit invalidates both live states; the Stale tombstone rejects every pre-edit/delete/commit from the replaced owner. An empty pre-edit clears the visible composition but records TerminalPending(owner): the visible pre-edit ended, while its terminal commit or delete-surrounding is still pending. A switch before that terminal event therefore still rejects it rather than applying it to the replacement. A non-empty old pre-edit is treated as a continuing update of that old composition and keeps the tombstone across arbitrary idle frames. A rejected terminal commit is the composition boundary and consumes the tombstone immediately, so a new pre-edit later in the same raw input batch is accepted for the current target. A rejected empty pre-edit or delete-surrounding is only tentatively terminal: it keeps the tombstone through the rest of the batch so a paired old commit is rejected too, and is consumed at batch end when no commit follows. Tests must separate the late pre-edit from its commit/delete by at least three idle frames, exercise an observed empty pre-edit followed by a target switch and a delayed old commit, verify a new pre-edit after a rejected terminal commit in the same batch commits to the new target, verify a genuinely new composition after the consumed terminal batch, and verify that no permanent lock remains. Do not replace this with an elapsed-frame guard: time is not causal ownership.
  • E21b/P7b outgoing surrounding text (new prerequisite, not existing behaviour). Source inspection at the candidate revision found no call to winit's WindowExtWayland::set_ime_surrounding_text anywhere under egui-winit, and IMEOutput (egui/src/data/output.rs:100-115) has only purpose/rect/cursor rect/interrupt. Deletion and commit handling alone therefore cannot pass the suite's surrounding-text contract.
    • The fork adds a bounded transient IMESurroundingText payload to IMEOutput (logical text, cursor/anchor as UTF-8 byte offsets, owner/context generation identity). It is built from committed text only, excludes preedit, is skipped from debug/persistence output, and is per IME-owning widget/viewport. The owner generation is present even when support is unavailable; available-empty is distinct from unavailable and is sent as an explicit empty payload, never as an absent one. Changing owner/support (normal, password, unavailable) clears the backend cache and uses the protocol's committed disable/enable boundary; an empty text payload is not a general clearing API.
    • The final completed pass owns the outgoing value, including None: the adapter must not use ime.or(self.ime)-style carry-over that can retain an earlier pass's text. egui-winit calls W27 on text/selection/focus changes; per-window caching cannot bleed between widgets or windows. The excerpt is at most 4,000 UTF-8 bytes cut at valid char boundaries. A selection that cannot fit returns an explicit unavailable context (or an explicit empty one when the document really is empty), never a truncated "complete" selection.
    • A current-epoch Done whose protocol serial does not match still applies its incoming edits exactly once and only defers outgoing surrounding state until a matching serial. A stale (different owner/focus generation) batch is invalidated and discarded. Track focus epochs and protocol serials separately.
    • No new document-sized allocation. Building the new bounded payload must not clone the full document; the baseline TextEdit already clones whole buffers for change detection/undo, and that pre-existing cost is measured separately.
    • EffectCraft custom adapter (viewer_text). The producer at viewer_text.rs:568 must supply the logical model text from the edited layer's TextDoc (committed text), not display-bidi text, with preedit excluded (viewer-text-preedit stays a display-only temp value), plus the editor's owner generation unconditionally. Convert TextEdit.anchor/caret (character indices, crates/engine/src/commands/text_edit.rs:18-40) to UTF-8 bytes with TextDoc::byte_of, clamp at char boundaries, excerpt to 4,000 bytes, and remap cursor/anchor to the excerpt. Emit an explicit empty payload for an available empty document; emit unavailable (and trigger the committed disable boundary) when there is no edit session, when focus leaves the viewer, or after text.endEdit/Escape/click-away, so stale text cannot survive. The editor has no password mode; the stock-field password/unavailable clears are tested there. The payload is scoped to the viewport that owns the focused editor (floating panel windows and the main window cannot share state).
    • Atomic compatibility. IMEOutput loses Copy when the payload is owned. The P8 fork SHA, all egui-family crates, the craft-ui revision and EffectCraft's single IMEOutput literal (viewer_text.rs:568; there are no destructures in this repo) must change in one dependency unit. Do not land a mixed stock/fork lock, and do not claim a stock-0.37 app build after this stage: the literal would not compile against stock IMEOutput (§11.13).
    • Tests: outgoing text immediately after delete+commit and deletion-only batches; scalar/ grapheme vs byte indices; supplementary emoji, combining marks and RTL logical order; preedit exclusion and caret movement; long selection/excerpt limit and allocation bound; same-window field switch; two windows; focus loss and late serial; current-epoch mismatched serial applies incoming edits once and defers outgoing; stale-epoch discard; available-empty vs unavailable and normal/password/unavailable transitions; final-pass None authoritative; multi-pass final output; native Wayland Vietnamese/predictive IME protocol trace. macOS NSRange conversion remains a separate W26 path; this does not claim new macOS surrounding-text APIs.
  • Clipboard: eframe/clipboard feature (§5.1). ctx.copy_text/output.commands paths at lib.rs:1600-1612, lib.rs:1946-1953, viewer_text.rs:413-430, menus.rs:1867-1906 and the macOS Hooks::clipboard_text (lib.rs:159-160, apps/effectcraft/src/main.rs:326) stay unchanged.
  • PRIMARY (E4 + W15P) is a native gate, not one manual row. Upstream 0.37 fills PRIMARY from Label/TextEdit selections and pastes on middle click (#8428); the reviewed fork replaces the arboard fallback with a winit-owned PRIMARY path on the existing Wayland connection/event queue, using SCTK 0.19.2's PrimarySelectionManagerState and per-seat device/source/offer objects. Never create a second regular wl_data_device. set_primary / get_primary route through it; password selections are never offered; an unchanged selection does not reclaim ownership. Serial handling accepts keyboard and pointer press serials for the focused seat with origin, freshness and epoch (do not invent a keyboard-only rule — the protocol describes pointer-driven selection). Claim the selection synchronously while the triggering serial is fresh; only serving the selection is asynchronous. Reads are bounded: a 2 s total monotonic deadline, 16 MiB total bytes, at most 4 read workers and 2 writers with a bounded queue of 8. A live owner-specific completion waker wakes the owning viewport after the slot lock is released and never blocks the UI; canceled or stale results are discarded, and a stale paste is canceled after focus/target/generation changes. The native capability matrix in §8.7 covers wlroots/Hyprland, KWin and Mutter; ordinary paste is not PRIMARY acceptance. An isolated PRIMARY-only fallback requires measured necessity and review.
  • Non-Latin layout shortcuts (E7/E19/W25): headless tests cannot exercise winit's mapping. Add a manual row (Russian, Arabic, Hebrew) that checks Menu shortcuts and the timeline/Project Ctrl+C/V/X paths, and keep parse_shortcut tests (menus.rs:2229-2265) as the app-level contract. Use a non-clipboard held-key case (e.g. Ctrl+Z) as well, because clipboard shortcuts return before emitting Key-down.

5.6 Menus, popups and docking

  • menus.rs:2025-2050: keep MenuBar::new().config(MenuConfig::new().style(menu_style)).ui(...); 0.37 keeps MenuConfig and adds submenu_gap. Regression tests: hover-switch between top-level menus, arrow navigation (menu_keys::Nav), opening a submenu, Escape/outside-click closing, and a menu command that acts on the focused TextEdit's selection (#8763). That last case is P7c's selected fix in the P8 revision, not an app workaround.
  • menu_keys.rs:84: scope id (see §5.2). menu_keys also uses ui.is_sizing_pass() for popup sizing (already); keep and test that a sizing pass does not invoke a command twice.
  • Dock: the layout and drawing are app-owned (dock.rs, dock_ui.rs); only craft_ui::docking actions are shared. Preserve panel order/nesting, clip rects, split sizes and IDs. 0.37's "Panel/Scene clip rect respects parent" (#8562) needs a clip test for a maximized panel and an embedded (web) floating panel.
  • Root Ui order (lib.rs:1614-1723): menu bar strip, header, dock body, native drop overlay, mini flowchart, dialogs, ScriptUI windows, coach, import card, toast. There are no side Panel::left/top/... calls; the dock is an explicit child Ui with computed rects. Keep that order and nesting (it decides hover/layer order and clip rects). CentralPanel is used only inside floating-panel and video-preview viewports (dock_ui.rs:475, prefs_live.rs:207).
  • Do not enable Style::right_to_left (E9); the dock and viewer are custom LTR geometry.

5.7 Screenshots (control channel, kittest, hidden windows)

Replace token-via-input-event correlation with the shared CL-2 completion slot (§3.3). Do not add an unbounded or app-local result channel: the slot is owned by the app request record, and the callback holds only a weak reference to it.

struct PendingShot {
    token: u64,
    path: Option<String>,
    crop: Option<[f32; 4]>,          // logical points, as the control protocol already takes
    reply: Sender<Value>,
    deadline: f64,                   // app clock
    admitted: (egui::Rect, f32, u64), // screen rect + ppp + geometry epoch at acceptance
    issued: Option<(egui::Rect, f32, u64)>, // target-UI rect + ppp + geometry epoch at issuance
    slot: Arc<capture::CompletionSlot>, // CL-2F helper type (API frozen)
    reserved_bytes: usize,
}
  • Admission. Use the shared default policy: at most 4 outstanding requests, 128 MiB per image and 256 MiB aggregate reserved in-flight/completed bytes. Stricter app caps are allowed, but never looser. Estimate the target surface with checked width * height * 4 from the known UI geometry, reject over-budget requests before issuing a capture with an actionable reply, and revalidate the reservation at issuance. A post-callback size check alone does not bound peak capture memory; the reservation does.
  • Issue. ctx.request_screenshot(callback) sends a viewport command that itself forces a repaint, and a pending screenshot makes a hidden window take a full UI+paint pass. A screenshot that is merely queued and not yet issued, and settling deadlines, must have tick/logic send RequestPaintWhileHidden so the UI reaches issuance; do not add a second wants-paint request once the screenshot command has been issued. At issuance record the target-UI logical content/screen rect, ppp and a geometry epoch. That issued geometry + epoch, not callback metadata, is bound to the image: the callback carries only pixels.
  • Completion. The app owns the strong slot and the owner-specific waker; the callback holds only a weak reference, stores the image unless canceled/completed, takes the wake handle, drops the slot lock, then requests the owning viewport's repaint. The callback does not wake itself and must not strongly capture a Context or the app; that would create a cycle with the viewport's queued command. collect_screenshots drains completed slots in tick and encodes/writes/replies outside the callback.
  • Geometry/scale binding. With an unchanged geometry epoch, derive the pixel scale from the returned image dimensions against the issued rect and verify aspect/rounding; the returned image may be smaller when E2 surface fitting applies. Any intervening resize/DPI/zoom/surface epoch change between admission, issuance and save cancels and retries the request — even if the geometry later changes back before the save. Never crop with the admission-time, current or any other ppp that disagrees with the issued epoch. Store admitted/issued/captured values and record mismatches; an explicit error is correct when a retry cannot complete inside the deadline.
  • Cancel/timeout/drop. Removing the request record (timeout, close, app drop, caller drop) marks its slot canceled under the lock; a late callback finds no live slot or a canceled one and drops the image without a second reply or file write. Release the reserved bytes on every exit path (completion, timeout, cancellation) and assert retained bytes return to zero after drain.
  • save_screenshot takes the actual scale/geometry instead of &egui::Context (control.rs:570-606), so it can run in tick.
  • issue_screenshots (lib.rs:1530-1558) uses the tick's now and a settle deadline, not a per-pass frame counter; collect_screenshots (lib.rs:1560-1580) becomes the slot drain.
  • Remove the "occluded/minimized" refusal at lib.rs:1490-1498 for the native wgpu path only after the platform run shows the screenshot request really produces a frame. The refusal exists because 0.36 never painted a hidden window; 0.37 makes a pending screenshot a wants-paint action (wgpu_integration.rs:680-690, egui-winit/src/lib.rs:2116-2128). Keep the timeout error and add ui.focus as the fallback. Record Windows/macOS/Linux results separately. Do not claim hidden-window screenshots on a platform that was not tested. On web, eframe does not paint a hidden tab (logic-only passes have no clipped primitives), so ui.screenshot on web requires a visible tab; record that limitation.
  • Correlation tests: two concurrent screenshots with different crops; callback-once behavior; a timed-out request followed by a late callback (no second reply, no file/PNG); shutdown/close with a pending capture; aggregate byte-budget rejection; a resize/DPI change between admission, issuance and completion (retry or explicit error, never a stale crop); a hidden-window capture on native wgpu is a positive row (not a "missing RequestPaint" negative); retained bytes return to zero; the app/Context can be dropped without the callback keeping it alive.
  • docs/control-protocol.md:125-129 and the module docs at control.rs:1-27 need the new wording (no user-visible protocol change: ui.screenshot keeps its method and reply shape).

5.8 Viewports and GPU

  • Floating panels (dock_ui.rs:466-512) use show_viewport_immediate; the video preview uses the same (prefs_live.rs:166-236). Keep them inside draw. Persisted window rects (PanelWindow::sanitized, ui.panel_windows) are unchanged.
  • Hidden playback and child windows. show_viewport_immediate children only update when the parent runs a pass. If playback is playing while the root window is minimized/occluded, or a floating panel/video preview is open, tick must keep want_paint set (and request a repaint at the needed cadence) so the parent runs the forced hidden pass. Do not rely on the one-shot open/close command mitigation alone. Test a minimized-window playback with the Video Preview open and with a floated panel, asserting the child content advances and the parent does not thrash.
  • 0.37's #8748 (a hidden parent does not update deferred children) is carried as P7c's selected fix in the final P8 revision; verify it in the hidden-playback test. Independently, a command that opens/closes/floats a panel or video preview also sets want_paint, and playback keeps the pass cadence, so the app does not depend on one mechanism alone.
  • GPU: egui_wgpu::Renderer's register_native_texture_with_sampler_options, update_egui_texture_from_wgpu_texture_with_sampler_options and free_texture are unchanged in 0.37 (renderer.rs:790-930), so viewers.rs:150-210 needs no API change. RenderState/Frame::wgpu_render_state are unchanged. Keep E2+E3. The existing ui_gpu.rs GPU-vs-CPU tolerance (at most 0.3% of pixels differ by more than 1/255, worst channel difference ≤4) stays.
  • A skipped GPU test is not evidence. ui_gpu.rs returns early when effectcraft_gpu::Gpu::headless() finds no adapter (tests/ui_gpu.rs:44-47) or when egui-wgpu's GL device has WebGL2 limits (tests/ui_gpu.rs:62-65). For the GPU acceptance row run with a required adapter, assert gpu_adapter().is_some(), and record adapter/backend/driver and the exact test invocation. The planning host exposes an i915 render node (/dev/dri/renderD128) with no display session; that can qualify a headless wgpu adapter run, not presentation.
  • Device loss must be handled from tick (see §4.2); the ui() early return currently means a device lost while hidden is not acted on (lib.rs:1905-1910). Add a logic-only test that a reported failure closes the window and keeps the auto-save.
  • 0.37 Frame::set_wgpu_surface_config is fixed (#8442); EffectCraft does not use it. Do not add it as part of this migration.

5.9 Language and screen readers (fork-only stage)

When craft-libs#198's fork APIs are accepted:

  • In the app tick, after i18n::set_current(language), call ctx.set_language(Some(language.to_string())) (fork E17) and, where an eframe::Frame is available, frame.set_ui_language(Some(language)) (fork E18). This sets the AccessKit root language and widget text layout language. It does not change the app's catalogs.
  • Assert ctx.language() follows the interface language, the AccessKit root node carries the language, and a TextEdit's text is shaped with the UI language (CJK glyph choice where fonts are installed). Tests that need the fork API are marked fork-required in their module docs.
  • Keep the app LTR (Style::right_to_left unset). Text-layer RTL remains effectcraft-text's contract; UI-label bidi is epaint's.
  • The E17/E18 policy sub-stage is optional and separately revertable. The production app already requires the final fork and E21b from P1; a stock-0.37 control uses a separate minimal adapter and is not obtained merely by reverting P4 (§11.13).

6. Dependency topology, publication reachability and rollback

Intended graph (final reviewed storytold revisions; names shown for structure):

effectcraft / effectcraft-web / effectcraft-cli
  -> effectcraft-ui-egui -> craft-ui (craft-libs rev with CL-1 + CL-2F)
                          -> egui 0.37.0 (git <final storytold egui P8 SHA, incl E21b/P7c>)
                             -> epaint, epaint_default_fonts, ecolor, emath (same git rev)
                             -> eframe, egui-wgpu, egui-winit (same git rev)
                             -> accesskit 0.25.1, kittest 0.5.0
                             -> winit 0.30.13 (git <final storytold winit SHA, incl W15P>)
  -> winit (direct, same git rev)
  • Every egui-family crate EffectCraft resolves must come from the same git revision. cargo tree -d must show no second egui family, and cargo tree -i winit@0.30.13 exactly one source.
  • Publication: EffectCraft is an application, so a git egui/winit dependency is acceptable in its lock, but no branch names, no path patches and no machine-local checkout may appear in a publication branch. The aiongg candidate SHAs are comparison inputs and must not appear in a publication lock. craft-libs consumers keep their own patches; the app's [patch.crates-io] is the supported place for the suite fork.
  • A clean checkout must fetch every final approved revision and pass cargo check --locked and cargo test --locked without local overrides; record that proof with the lock.
  • Rollback: keep the exact baseline lock (egui family 0.36.2, craft-ui 5c874d51, winit registry 0.30.13) in the task evidence. The project file format, command ids, undo model, workspace-state version 1 and the GPU compositor are unchanged by this migration, so rollback is a revert of the pin/refactor commits, with no data migration. Do not persist any 0.37-only preference until the final stage, so an older build ignores nothing it cannot read.
  • examples/plugins/posterize-bands is a separate workspace with its own lock and no egui; do not edit it. craft-pdf/hayro-syntax and the filmcraft pins are unrelated and stay.
  • There are no vendored crates in this repository (vendor/ absent); the only patch is the craft-libs hayro-syntax entry. Do not delete it, and do not "clean up" the commented filmcraft path patch by enabling it.

7. Command, undo, save/reopen contracts

Nothing in the egui bump may change these:

  • Every gesture still routes through effectcraft_engine::Session::execute or menus::invoke (lib.rs:1-6, control.rs:1-19); UI-local state must not become the source of truth.
  • One gesture = one undo step: existing tests such as ui_viewer.rs:2263 (fit/zoom), ui_viewer.rs:2306 (rotate), ui_project_delete.rs, ui_timeline_trim.rs and ui_keyframes.rs stay green. Session::history.merge_key is reset when a numeric scrub ends (lib.rs:1866-1867, pointer_wrap.rs:31-36).
  • Save/reopen: .ecproj JSON contains no egui types; save, reopen and pixel-compare a comp after a UI edit. Workspace state (workspaces.json, version 1, workspace_state.rs:37-40,85-88) contains PanelKind, rects and floats, not egui ids; save, restart and restore the dock/floating/ window layout. If a new field is ever required, specify an explicit version bump, a migration and a backward-compatible reader test; do not silently reuse version 1.
  • Multi-pass must not duplicate a command, an undo step, an autosave write or a close prompt (§4.4, §8.3).
  • Auto-save while hidden has two owners today. Project auto-save/recovery already runs from tick; the workspace-layout autosave is still in draw (lib.rs:1715-1722, gated on !ptr.any_down() and workspace_store.due(now)). P2 moves its due check and write to tick using the app clock and the last pointer-down state observed in draw. Only then does the hidden claim cover both kinds; the close guard and crash recovery remain tick-owned. §8.3's test asserts both writes while hidden.

8. Tests and acceptance

8.1 Repository gates

Every stage runs the repository gates (through the server job workflow, never directly from the planning task): cargo fmt --check, cargo clippy --workspace --all-targets --release -- -D warnings, cargo test --workspace --release, cargo xtask layers, cargo xtask assets, cargo xtask wasm, plus node apps/effectcraft-web/tests/{workers,page,storage}.mjs (no build needed). On every dependency stage also run cargo check --locked, cargo test --locked, cargo tree -d, the family/winit inverse trees and the wasm-target wasm-bindgen tree on a clean checkout. cargo xtask ci is the final gate.

8.2 Compile and API contract

  • The 15 errors and 602 warnings above are gone, with no #[allow(deprecated)] added to production code.
  • Grep gates must use precise patterns:
    git grep -nP '\bId::new\(' -- '*.rs'                          # only comments/none
    git grep -n 'WidgetType\|AtomLayoutResponse\|\.vertical_align(' -- crates apps
    git grep -n 'with_accessibility_check(false)' -- crates apps     # must be empty at every stage
    
    The old grep -rn "Id::new" also matches GlyphId::new in crates/text/crates/pdf (7 lines) and can never return "none"; \bId::new\( does not match inside GlyphId.
  • ui_agent_audit.rs:73 uses WidgetInfo.role (typ is gone); all tests construct MouseWheel with source.
  • Tests for Id::unique stability and for the role mapping table.

8.3 Lifecycle, hidden windows and multi-pass

New tests (headless, Context::run_logic + run_ui, no GPU). The driver must mirror the real eframe order: raw_input_hook-equivalent (feed_synthetic) → run_logic → retain the mutated raw input → next run_ui, and it must not replay input events from tick. Use AppClock::manual():

  1. logic_only_drains_control_and_jobs: a control request that only needs tick (e.g. engine.execute of a non-UI command) gets its reply with zero run_ui calls; poll_render completes a render job and the Toast/event is drained. Advance the manual clock between ticks.
  2. logic_only_feeds_synthetic_and_forces_one_paint: with the root viewport marked minimized: Some(true) (so visible() == Some(false)), a queued ui.click is fed by the hook during a logic-only tick; the tick emits RequestPaintWhileHidden and sends no reply. A second logic-only tick does not feed another event while the first is still unapplied. The following forced run_ui sequence (still minimized) consumes one pointer event per pass, applies the buffered gesture exactly once and replies exactly once after the final pass. The same test asserts that the forced passes are not gated on visible() and that no event was dropped.
  3. after_input_replies_only_after_a_paint: same, and assert the command's effect exists before the reply is sent; force a discard on that pass and show the reply still waits for the final non-discarded pass.
  4. forced_hidden_pass_applies_buffered_gesture: feed during a logic-only tick, run forced full passes until synthetic is drained, and assert exactly one command and one undo step (no duplicate from the logic-only call).
  5. playback_advances_without_a_paint: with a viewer scale recorded and the manual clock, hidden ticks advance session.time within the preview step and do not duplicate Playback.frame_shown; audio is started once (stub audio_device); a stale ctx.input(|i| i.time) cannot satisfy a deadline.
  6. autosave_and_close_guard_run_while_hidden: project auto-save and the moved workspace-layout autosave both write in logic-only ticks; a hidden close request opens the unsaved dialog and sends CancelClose.
  7. discarded_pass_is_idempotent: force ctx.request_discard (or show a first-time Grid) while performing one command, one drag and one auto-save; assert one command execution, one undo step, one file write.
  8. device_loss_while_hidden_closes_and_saves: bridge a failure during a logic-only tick; assert the close command and the auto-save, as in lib.rs:2040-2075.
  9. hidden_playback_keeps_child_viewports: with playback playing and a Video Preview or floating panel open, hidden ticks keep requesting a pass and the child content advances (§5.8).
  10. Screenshot-slot tests from §5.7 (concurrency, cancellation, late callback, resize/DPI including a change back, byte budget, shutdown).
  11. repeated_logic_only_ticks_do_not_collapse_a_gesture: queue a ui.click and a ui.drag, run several logic-only ticks (mirroring eframe's overdue RedrawRequested and 100 ms invisible throttle) while the root viewport stays minimized, and assert the hook fed at most the one unapplied event; then run forced full passes and assert press/move/release each reached a separate pass, exactly one click/drag command and one undo step resulted, and no event was dropped. Repeat for a click followed immediately by a drag.

8.4 Screen geometry, pixels and accessibility

  • Fixtures: the built-in demo project (file.openDemoProject) for visual states, the generated engine::perf::large_project for scale/perf, and the bundled Inter/JetBrains Mono fonts. No external media is needed for UI geometry/accessibility/pixel tests; media-dependent tests keep their existing generated fixtures.
  • Geometry/hit tests (existing helpers, automation rects): viewer comp rect and fit at 1.0/1.25/1.5/ 2.0 ppp; dock split resize; tab drag and overflow; floating panel window (native only; embedded web path separate); menu item click at a label; timeline row heights (ui_parameter_spacing.rs keeps 20/19 px); panel order/nesting after maximize/restore.
  • Double-click paths: 24 double_clicked() call sites (27 textual double_click occurrences including the Timeline helper, e.g. project rename project.rs:783,832,1036, Timeline timeline.rs:1502,2116,2325,2516,2876,3529, Flowchart flowchart.rs:360): 0.37 changed double/triple-click classification (#7915). Assert one double-click performs exactly one action and does not also fire a triple-click path.
  • Visual review harness — correct the claim and extend it. Today crates/ui-egui/tests/ui_visual_review.rs (UI_REVIEW_DIR, #[ignore]) captures the workspace in light and dark, but every PanelKind::ALL panel and the About/New Comp/Settings/Shortcuts/ Command Palette dialogs only in dark, at the harness default ppp 1.0, at wide 1600×1000 and compact 1024×768 (ui_visual_review.rs:13-48). Extend it to capture every panel and dialog in both themes at ppp 1.0 and 2.0 (four combinations per state, both sizes), then use it on the baseline and every stage. This is the primary before/after visual record; the golden set below is the automated subset. Record that the ignored test actually ran; an ignored/absent capture is not evidence.
  • Pixel comparisons: choose one of
    • egui_kittest snapshot goldens (enable the snapshot feature, add kittest.toml with threshold per OS, commit a small fixed set of PNGs under crates/ui-egui/tests/snapshots/ with .attribution sidecars and ATTRIBUTION.md rows, because the repository's asset rule applies even to screenshots); or
    • an app-owned tolerant compare against RGBA hashes stored in a Rust constant, with the same tolerance policy and no image assets. Exact selected set: five states — Standard workspace (demo, time 2 s), Timeline maximized with rows open, Project panel with folders open, a menu open, and the Settings dialog — each in light and dark at ppp 1.0 and 2.0 (20 captures), not a single dark/default-scale set. Threshold: max channel delta 2/255, ≤0.1% failed pixels on Linux/Windows, 3/255 on macOS (if any platform-specific rasterization is observed); no tolerance for geometry rects. Never accept a new golden automatically; a changed golden needs a reasoned explanation in the PR.
  • Mutation check (required, recorded as evidence, not committed as a test): nudge Tokens::focus or a panel width by a visible amount behind an env flag and show that the pixel/geometry test fails with the expected diff; restore. This proves the assertions detect change.
  • Accessibility: strict checks throughout; Harness::unnamed_widgets() empty for the workspace, for each panel maximized, for every dialog in Dialog, and for the Home/Media Browser/Effect Controls/Properties/Timeline panels. Role contract tests for color well, tab + selected, splitter, tree item, text input, spin button, slider and a checked menu item. Role::Button + selected for selectable labels. Read-only fields are exempt by kittest. The repo-wide grep gate in §8.2 proves no global/per-builder accessibility opt-out remains.
  • Glyph coverage: the extended chrome symbol test (§5.4); ui_effect_names.rs:35 and ui_presets_folder.rs:40 use allow_missing_glyphs() with a comment. No global glyph opt-out.
  • ui_gpu.rs and ui_visual_review.rs are skip/ignore-capable: the acceptance record must name the exact invocation and show they actually ran. A skipped GPU test is not GPU evidence (§5.8).
  • Screenshot tests (§5.7) and IME/adapter tests (§5.5).

8.5 Save/reopen and command/undo workflows

  • UI edit → save .ecproj → reopen in a fresh EffectcraftApp → project JSON equal and viewer pixels equal; undo/redo across the save still works.
  • Dock/floating/panel-window layout → save workspace → restart → restored; maximize/restore and split sizes unchanged.
  • One command from the menu, one from the control channel and one from a synthetic click each produce one undo step; a discarded pass does not add another.

8.6 Web workflow

  • cargo xtask wasm (all L0–L4 + ui-egui + web) and cargo xtask web --serve 8765.
  • node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out <evidence>: WebGPU/GPU-compositor path, viewer, render.frame, Render Queue downloads, background worker event-loop gaps (<400 ms), AudioContext, save/reopen, persistence across reload, offline service worker. Also workers.mjs, page.mjs, storage.mjs.
  • Browser screenshots come from CDP (smoke.mjs:113), independent of the egui screenshot API; add a page-side check only for the in-app window.effectcraft.request("ui.screenshot", …) reply shape when the tab is visible. A hidden web tab cannot paint (logic-only passes carry no clipped primitives), so ui.screenshot on web is a visible-tab feature; record it.
  • Web is a separate feature set: no OS clipboard/PRIMARY, no system fonts, embedded viewports.
  • Hidden-tab behaviour, realistically. Upstream 0.37's web runner already falls back from requestAnimationFrame to setTimeout(10 ms) when document.hidden() (eframe/src/web/web_runner.rs:238-279), so AppRunner::logic keeps running with logic-only passes and the browser clamps background timers to roughly one second (long-backgrounded Chrome may throttle further). Assert that the session snapshot's timestamp advances across the hidden period at the browser's actual cadence, that frame workers keep completing within that allowance, and that the viewport is fully refreshed when the tab returns. Record the browser, visibility transition and measured cadence; do not require exactly 1 s and do not add a new worker/timer scheduler or page-independent persistence feature in this migration. Fix WebApp::logic to pass the app clock (self.app.now()) to persist::snapshot, not ctx.input(|i| i.time) (which is stale in a logic-only pass).

8.7 Native platform matrix

Row Required evidence
Linux headless wgpu (this server) ui_gpu.rs with a required adapter on the i915 render node (/dev/dri/renderD128, no display session): assert gpu_adapter().is_some(), record adapter/backend/driver and the exact command. This qualifies a headless GPU-compositor run only, not native presentation.
Linux X11 Native window under Xvfb + a real window manager (e.g. openbox) + an X11 input method (e.g. ibus/fcitx): screenshot, save/reopen, floating panel window, focus/menu/close/auto-save, clipboard and PRIMARY between windows, X11 IME (Vietnamese/Japanese) with editor/undo checks. Record Xvfb/WM/IME versions and that the session is virtual and not composited. This can qualify the X11 parts; it cannot qualify a physical GPU or Wayland.
Linux Wayland Requires a real compositor; unresolved unless a managed environment is provided. Run the E4/W15P PRIMARY native capability matrix on wlroots/Hyprland, KWin and Mutter: protocol availability, keyboard focus, keyboard and pointer-selection serial freshness, owner/seat change, empty/absent offers, self-paste, slow/canceled transfer, password exclusion, unchanged-selection reclaim, and no second regular data device (protocol trace). Also trace text-input-v3 current-epoch mismatched Done (incoming applied once, outgoing deferred), stale-epoch discard, and disable/enable on owner/support changes. Record unsupported capabilities honestly; do not substitute X11/arboard results.
Windows (craft-windows clone, separate task) real native build/test, GUI launch, screenshots, menu/close/auto-save, clipboard, IME, DX12 pipeline startup timing, mixed-DPI window placement, and a held Ctrl+Z release after a layout switch; Basic Render Driver is software and must be reported as such.
macOS unresolved gate unless real hardware evidence is provided: press-and-hold/dictation and surrounding-text NSRange (W26), Command-key shortcuts, traffic-light/titlebar placement, screenshots while occluded.
FreeBSD / Nix / Windows ARM64 CI workflows are reusable craft-common-actions; run after the pin lands and report only what the runners provide (FreeBSD nightly, ARM64 MSI on schedule).

Headless kittest is not evidence for native focus, IME, file pickers, clipboard, PRIMARY or GPU presentation. Physical-GPU and Wayland rows remain explicit unresolved gates until measured.

8.8 Known upstream regressions as acceptance gates

  • #8731 (text tessellation, reported +61% instructions for plain text): measure with the paired text-heavy operations (workspace, timeline, project) and the raw tessellate instruction count where possible. This measurement belongs to the pre-P1 gate. If the median regression is above threshold, the row-wise fix (provenance, test, removal condition) must be in the reviewed P8 revision before P1 is frozen, or the migration is blocked. P5 may not add it afterwards.
  • #8736 (DX12 pipeline creation, reported ~40 ms): measure Windows startup and first-frame cost separately for the DX12 backend. If it pushes startup over threshold, the fix must be in P8 before P1 or accepted with an explicit owner decision and the measurement; P5 only re-reports it.
  • #8512 (system font startup cost): keep system_fonts off and record font-install/discovery time in the startup row so a later color-emoji decision has a baseline.
  • #8489/#8583/#8601/#8589 are handled by this plan's refactors.

9. Performance plan

9.1 Baseline before production changes

Add instrumentation first (stage B0, no behaviour change): raw samples per operation, p50/p95/p99, the number of final paint passes and discarded passes (Context::will_discard() at the end of draw), texture-upload bytes per frame from FullOutput.textures_delta, logic-only tick time, startup-to-first-frame and font-install/load time, and an optional counting global allocator in effectcraft-cli for bench --ops (byte counts per operation). Record raw and compressed effectcraft-web .wasm sizes. Measure gains samples, median, p95, p99, cpu_ms; existing names and JSON fields stay. Then run the B0-instrumented baseline: commit 9581b53c

  • the instrumentation patch, with its patch SHA and the 0.36.2 lock recorded. The baseline identity is B0-instrumented 9581b53c, not the bare commit. The same instrumentation is applied to the candidate so both sides are measured identically.

9.2 Matched operations

Operation Command / driver Sample rule
Startup to first frame bench --ops --json startupToFirstFrame; native log "first frame drawn" ≥20 fresh processes per source/lock; same large project, fonts, window 1680×1020
Steady workspace frame workspaceFrame ≥10 warmups, ≥30 timed iterations per session, 3 sessions
Timeline scroll / open rows timelineScrollFrame, timelineOpenFrame 5,000 layers, every 10th open; ≥30 iterations
Project panel scroll projectPanelFrame all folders open; ≥30 iterations
Menu open / dock resize / typing new menuOpenFrame, dockResizeFrame, textTypingFrame fixed input scripts; ≥30 iterations
Frame distributions scripted workspace/timeline/typing loop or native playback ≥1,000 actual final paint passes per row; record discarded passes and dropped frames; p50/p95/p99 only with ≥1,000
Logic-only tick new logicOnlyTick 1,000 run_logic ticks, idle queues; asserts no paint work
Idle repaint rate new idleRepaints60s using Context::set_repaint_observer virtual 60 s; count causes
Playback native preview long enough for ≥1,000 presented frames (e.g. 20 s at 60 fps, or repeated 10 s segments with a fresh start) actual pass count, dropped frames, audio clock; ui.inspect.fps is a smoothed readout, not a distribution
Texture uploads bytes/frame from textures_delta viewer frame and thumbnails
Allocations counting allocator totals per op optional but recorded
Font startup (#8512) cold start with system-font provider off; record font discovery/install/load ≥20 fresh processes; part of startup evidence
WASM size cargo xtask wasm artifact raw and gzip/brotli sizes, baseline vs candidate; accept a budget or make emoji fonts native-only
Engine control cargo xtask bench --quick --repeat 5 guards accidental engine/render drift
GPU vs software same UI ops with llvmpipe/software GL and a required hardware adapter where present; Windows Basic Render Driver separately separate tables, never merged

9.3 Environment and protocol

Record: CPU/GPU model, RAM, OS, driver, display scale, window size, renderer backend, release profile, exact commit/SHA/lock/craft-ui/egui/winit revisions, font set, fixture project SHA, and load average. Every row uses ≥10 warmups and ≥30 timed iterations for steady operations, ≥20 fresh processes for startup, and ≥1,000 actual final paint passes for a frame distribution. Report the exact frame counts, discarded passes, dropped frames, audio underruns, raw samples, median/p50, p95 and p99; do not report a p99 from 30/90 samples. Measure peak RSS (/usr/bin/time -v or -l) and idle CPU (60 s); use perf stat -e instructions:u,cycles:u where available. Use cpu_time/instructions and wall time; on a shared host, wall time is secondary. Software renderers and physical GPUs are separate tables.

Predefined thresholds (owner sign-off required to widen):

  • steady/scroll/project/typing median ≤ +5%, p95 ≤ +10%;
  • startup median ≤ +5%, p95 ≤ +10%, using the shared small-operation noise floor;
  • logic-only tick median ≤ 0.5 ms and p99 ≤ 2 ms on the large project (no paint work);
  • idle repaint count ≤ baseline per minute; no new recurring repaint while idle;
  • texture upload bytes per steady frame ≤ +10%;
  • peak RSS ≤ +5%; record absolute MiB as well; no automatic absolute-size exemption;
  • engine render benchmark (cargo xtask bench) within its own noise band; no regression;
  • text tessellation (#8731) and DX12 startup (#8736) are reported as their own rows and must meet the same median/p95 or be fixed/accepted explicitly.

Noise policy: run the unchanged 0.36.2 build twice as a control. The first report must also run stock egui 0.37.0 as the required upstream-equivalence measurement control (not a production pin), and report regressions only against these controls. If control spread prevents resolving the shared thresholds, report inconclusive and rerun under quieter conditions; never widen a threshold automatically. If a regression exceeds the gate, re-run paired 3× and profile; never update a golden or baseline to hide it. A changed pixel golden is a reported visual change with a reason, not a performance result. No performance number is claimed by this document; the thresholds are proposals to be validated by paired runs on recorded hardware.

10. Staged implementation PRs

Dependency DAG: B0 → P1 → P2 → P3 → P4 → P5. P1 waits for the complete reviewed/published storytold egui P8 (E1–E21, P2b async W15P routing, P7 held-key pairing, P7b/E21b, P7c's selected #8763/#8748 fixes and any justified #8731/#8736 perf fix), the completed winit (W1–W27, W15P, W27 lifecycle), and craft-libs CL-1 and CL-2F after its DesignCraft/GridCraft pilots. P1 includes the compatible IMEOutput producer and all required fork API adaptations. There is no stock or intermediate-fork production stage. If a consumer already has an incompatible craft-ui/egui bump, it must be reconciled in this same atomic stage. P4 adds native language/platform evidence and optional language UI policy; reverting it never removes the P1 compatibility adapter. P5 is measurement/docs only and requires final platform and performance acceptance.

Stage Scope Validation Rollback
B0 Bench instrumentation only: samples/percentiles, final/discarded pass counters, logic-only tick, repaint observer, texture-byte counter, font-startup and WASM sizes, optional allocator; baseline run on B0-instrumented 9581b53c (record patch SHA and lock) bench --ops --json raw evidence; no behaviour change; gates revert commit; baseline evidence retained
P1 Final reviewed storytold dependency pins (complete egui P8: E1–E21 + P2b + P7 + P7b/E21b + P7c #8763/#8748 + justified perf fixes; completed winit W1–W27 + W15P and W27 lifecycle fixes; craft-ui with CL-1 + CL-2F), including the viewer_text surrounding-text adapter and compatible IMEOutput literal, feature clipboard, default_fonts + monochrome_emoji_fonts; mechanical API migration (IDs, roles, Ui::scope_id, Atom/IdSalt, font bytes, TextEdit::align, MouseWheelSource, screenshot callback via the CL-2F slot, test Events); actual strict kittest accessibility names and glyph fixes until all tests pass with check_accessibility: true and missing-glyph Panic, with allow_missing_glyphs only on the two named script tests. No with_accessibility_check(false) anywhere. cargo xtask ci; cargo check/test --locked; cargo tree -d; one-family/one-winit and wasm-target checks; all tests compile and pass revert P1: restores the 0.36.2 lock; no data changes
P2 Lifecycle: tick/draw split, injected app clock, focus, polls/playback/audio/project and workspace-layout autosave/events in tick, hidden RequestPaintWhileHidden (including queued screenshots, settling and playback/child viewports), synthetic-input generation guard, input-waiter ordering after draw, close guard, screenshot scheduling ownership §8.3 tests; native hidden/minimized manual check; benchmark logic-only/idle revert P2: returns work to ui; behaviour-only, no data
P3 Accessibility completion (custom roles/names), glyph coverage, extended light/dark × 1.0/2.0 visual harness, geometry/pixel tests, style/visual adjustments if the tests show unintended movement §8.4 tests, cargo xtask assets (only if PNG goldens), a required-adapter ui_gpu.rs run revert P3: tests and names only (except any visual fix, which is isolated)
P4 Extended language/platform qualification: set_language/set_ui_language policy where applicable, RTL UI-label tests, PRIMARY native capability matrix, clipboard/non-Latin shortcut and native IME rows. E21b/IMEOutput compatibility is already part of P1. fork-required tests and native matrix using the same coherent dependency unit revert P4 policy/test additions; retain P1 final pins and required adapters
P5 Performance acceptance and re-measurement of the unchanged P1 dependency unit (no new fork commit), docs (docs/architecture.md:359 still says 0.36.2; docs/control-protocol.md screenshots; docs/testing.md UI perf), final evidence. Any #8731/#8736 fix must already be in the reviewed P8 pin from P1. paired benchmark report with raw samples, frame counts, thresholds, stock-0.37 control, platform rows, unresolved gates revert P5: docs/report only; no dependency or state change

Each stage is a separate reviewable PR with its own exact source/lock identity, validation log and screenshots. No stage pushes, opens a PR or publishes without fresh human approval of the exact content and validation.

Compatibility checks after P1 and each later stage:

cargo tree -d
cargo tree -i egui@0.37.0
cargo tree -i winit@0.30.13
cargo tree -i wasm-bindgen --target wasm32-unknown-unknown
cargo check --locked
cargo test --locked
git grep -n 'path *= *"/' -- Cargo.toml crates apps   # must be empty
git grep -n 'branch *=' -- Cargo.toml                 # must be empty in publication
git grep -n 'aiongg' -- Cargo.toml Cargo.lock         # must be empty in publication
git grep -n 'with_accessibility_check(false)' -- crates apps # must be empty

11. Blocking unknowns and decisions

  1. Final storytold egui/winit SHAs. The candidate aiongg revisions are comparison inputs only. Production waits for the approved, published implementation: a storytold egui P8 SHA with E1–E21 + P2b + P7 + P7b/E21b + P7c's selected #8763/#8748 fixes + any justified #8731/#8736 fix, and a completed storytold winit SHA with W1–W27 + W15P and the W27 lifecycle fixes. Both must be reachable, immutable and fetchable with --locked.
  2. craft-libs PR 199 base. The implementation starts from merged PR 199 at 3832c47ef68a0e6214f2c1e85f3526b6adcb686e; historical 8614e4a/1764bca on old main 4919088 and the stale b68de8c/ec8840a trees must not be merged. The final immutable craft-ui revision must keep registry egui 0.37 and include CL-1 plus CL-2F.
  3. CL-2F. EffectCraft requires the approved craft-ui slot helper at the CL-2F freeze in the same atomic stage as P1; only the Design/Grid pilots use the provisional CL-2 revision. If CL-2F slips, P1 cannot claim screenshot completion done; no app-local channel or unbounded queue is an acceptable substitute.
  4. E21b/P7b coordination. The egui IMEOutput payload and the egui-winit W27 adapter must land atomically with the custom-editor adapters; the stock/fork IMEOutput shape cannot be mixed.
  5. #8731 fix availability. If text tessellation regresses over threshold, the fix belongs in the reviewed P8 revision before P1 (with provenance/test) or the migration is blocked; P5 cannot change the pin.
  6. #8736 Windows startup cost. Measure before P1; include the fix in P8 or record an explicit owner acceptance. P5 re-reports it.
  7. monochrome_emoji_fonts / system_fonts / WASM size. The plan requires monochrome_emoji_fonts to preserve ✔/▣ and 0.36 parity. Measure the web raw/compressed size; accept a budget or add a native-only emoji-fonts feature. Enabling colour emoji via system_fonts is a separate decision with startup (#8512) and determinism costs.
  8. Pixel golden storage. Committed PNGs require .attribution sidecars and ATTRIBUTION.md rows under the repository asset rule; if maintainers reject committed screenshots, use the hash-based tolerant comparison instead. Both need the same tolerance policy and the exact light/dark × 1.0/2.0 set.
  9. macOS evidence. No macOS hardware is available to this task; the macOS gate (IME/NSRange, Command keys, title bar, hidden screenshots) remains unresolved until real evidence exists.
  10. Windows evidence. Requires a craft-windows clone and the task workflow; the Basic Render Driver is software and cannot stand in for a real GPU result.
  11. Linux Wayland/PRIMARY and physical GPU. The planning host has no display session (only an i915 render node), so a headless wgpu adapter run is possible but Wayland/PRIMARY/real presentation need a managed environment. Xvfb + WM + IME qualifies only the X11 parts.
  12. Accessibility workload. The exact number of unnamed inputs is unknown until the suite runs; the known stock set is 39 TextEdit and 51 DragValue/Slider sites plus custom widgets that gain roles. P1 fixes what strict checks report; there is no global or per-builder opt-out, and P3 completes the custom-control coverage.
  13. Atomic compatibility boundary. P1 uses the complete reviewed dependency unit and its required IME/screenshot adapters. No intermediate base pin is needed. Independent stock-0.37 controls use a bounded test adapter and are never a production handoff. Extended language policy/qualification in P4 cannot undo required P1 API compatibility.

12. Limitations of this planning task

  • No build, test, clippy, GUI, browser or benchmark was run. Compiler inventories are read from the 2026-10-22 audit (a historical fork revision, machine-local path patches, ui-egui lib/lib-test only) and from source inspection; integration tests and the app/web crates will reveal additional errors.
  • The environment record reports no DISPLAY, no WAYLAND_DISPLAY and XDG_SESSION_TYPE=tty, but an i915 render node is present (/dev/dri/renderD128 readable/writable). This supports a headless wgpu adapter run only; it does not qualify native presentation, Wayland, PRIMARY or physical-GPU performance.
  • The cmap/glyph findings are a read-only inspection of the bundled font files, not a rendered proof; the implementation must run the coverage test.
  • Upstream issue claims (#8731, #8736, #8763, #8748, #8631, #8512, #8428, #8657, #8577) are reported by upstream authors, not measured here; the linked evidence file (/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json) records the snapshots. #8764 is open/unmerged (upstream-8764-fresh.json), a proposal only.
  • No performance number is claimed. The thresholds above are proposals to be validated by paired runs on recorded hardware.
  • Windows and macOS behaviour, IME, native focus, file pickers and real-GPU presentation are not qualified by headless kittest and remain explicit gates.
  • Reviewer dispositions: the review's findings on workspace version 1, the visual-harness coverage, the absent shared with_accessibility_check switch, the incomplete PRIMARY matrix, child-viewport freezing while hidden, the performance sample counts, the ppm capture point, dependency/lock gates, skipping ui_gpu.rs, and the Id::new grep/occurrence counts were adopted and are fixed above. Two review suggestions were corrected rather than adopted: RawInput::time cannot supply a logic-only clock (run_logic discards it; an app-owned injected clock is required), and freezing the admission-time DPI is wrong when the frame is captured later (the issued geometry + geometry epoch are bound to the image, and the returned dimensions supply the pixel scale). The v2 review's hidden-input accumulation, P7c/CL-2F dependency, baseline-identity, E21b state/serial, W27/W25 gate, accessibility-builder and anchor findings are fixed in place above. The review's blanket claim that hidden browser logic cannot run is also corrected: eframe 0.37 already falls back to setTimeout for hidden tabs; the realistic gate is the browser's clamped cadence, not a new worker feature. The review's Linux "no display means no X11 evidence" is narrowed to the parts that genuinely need a real compositor/physical GPU; Xvfb + WM + IME can qualify X11 focus, clipboard/PRIMARY and X11 IME behaviour.
  • The pinned fork, winit, craft-libs and PR 199 can move before implementation; every plan stage records the exact SHAs it was reviewed against.

Local implementation update — 2026-10-23

The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval.

Canonical storytold/effectcraft main now resolves to f6ef1a790625657259f449f3f4f0773d7df449bb. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable.

Preserve queue4096/click1–512/drag<=2048 validation and refusal-without-mutation test; these limits differ from FilmCraft. Preserve chrono feature split.

The complete static delta review and exact old/new object identities are retained in the task evidence: canonical-diffs-refresh-20261023.json and overnight-consumer-review-20261023.md. This amendment claims no new build, native GUI or performance acceptance.

Local implementation stage 1 — 2026-10-23

Implemented in the owned worktree only: no commit, push, PR, release or publication; no build, test, fmt, clippy or cargo metadata was run (the parent runs managed task.py validation and generates the lockfile). Local dependency redirects are the parent's (unpublished) worktrees.

Compatibility surface

  • egui/eframe/egui_kittest moved to 0.37 with explicit egui features default_fonts + monochrome_emoji_fonts and eframe/clipboard on the UI crate and desktop app. The craft-ui revision is unchanged (the 0.36-era pin) with a TODO: the reviewed CL-1 + CL-2F revision is the parent's to supply; no remote revision was invented.
  • Id::new→Id::unique everywhere (603 occurrences); Ui::id→Ui::scope_id; UiStack.id→UiStack.scope_id; WidgetType→egui::Role (ColorButton→ColorWell, SelectableLabel→Button with selected, Other→Unknown/TreeItem); AtomLayoutResponse→ WidgetAtomResponse with an IdSalt swatch id; FontData comparison through bytes(); TextEdit::vertical_align→align(Align2::LEFT_CENTER); Event::MouseWheel gained source: MouseWheelSource::Wheel in the control channel and every test.
  • Screenshots replaced UserData/Event::Screenshot token scanning with craft_ui::capture::CaptureSlot: 4 outstanding requests, 128 MiB per image and 256 MiB aggregate reservations from checked geometry, issuance only after 3 settled non-discarded passes plus 0.35 s, geometry-epoch binding (window position excluded), stale-epoch retry and rejection, deadlines, cancellation and late-callback release. The callback holds only the weak slot; files and replies are written in the tick.
  • The covered/minimized refusal in ui.screenshot is intentionally still present: removing it needs a native platform run showing a pending screenshot forces a hidden paint.

Lifecycle

  • EffectcraftApp::tick (all eframe logic callbacks, including hidden logic-only ones): engine and background queues, script tasks, engine events, native command inbox, close guard, window title, project auto-save, workspace-layout auto-save, memory watch/home-on-open, playback + audio, control channel, screenshot completion/issuance, and RequestPaintWhileHidden when a UI pass is needed. EffectcraftApp::draw (the root Ui): language/theme/font setup (the existing two-pass font warm-up), input-driven shortcuts, layout, panels, dialogs, dropped files, Video Preview, workspace-state observation, plus the pass geometry/epoch and the AfterInput acknowledgement on the final non-discarded pass.
  • AppClock ( web_time::Instant, injectable manual clock) replaces every wall-clock or ctx.input(|i| i.time) use in the tick (auto-save, watch folder, footage reload, playback, control deadlines, screenshot deadlines, toast timestamps). panels/viewer now records its drawn scale and playback advances in the tick.
  • One pending synthetic batch: feed_synthetic refuses a second batch until a UI pass applied the first (mark_synthetic_applied), a font-gated pass requeues the batch instead of dropping it, and raw_input_hook keeps the control provenance across hidden iterations.
  • Web: dropped files are read in raw_input_hook; the persist snapshot uses the app clock. RequestPaintWhileHidden is sent on native only — the shared web fork does not carry it, so hidden web input/screenshots remain unqualified.
  • IME: the custom viewer editor handles ImeEvent::DeleteSurrounding (characters) and DeleteSurroundingBytes (UTF-8 bytes, converted at char boundaries through TextDoc), grouped with the following Commit under one typing/undo key. Outgoing surrounding text (E21b/W27) is recorded as the next step at the single IMEOutput producer; no empty placeholder was invented.

Tests added (not run here)

  • lib.rs::lifecycle_tests: logic-only tick records no fonts/geometry; a control command completes without a pass; one pending synthetic batch across repeated hidden hooks with a font-gated requeue and exactly one reply; geometry epoch ignores window movement and tracks resize/back; hidden playback advances and stops at the plan's end; workspace-layout auto-save writes on a hidden tick; a hidden close request opens the guard and sends CancelClose; screenshot issuance/settlement, resize retry, admission bounds + timeout release, and late-callback no-write/no-second-reply.
  • panels/viewer_text.rs tests: byte-count IME deletion around an emoji, character-count deletion over a selection with clamping, and no-op cases.
  • theme.rs glyph test extended to the full drawn chrome set; ui_effect_names/ui_presets_folder opt out of the missing-glyph panic only for their CJK/Japanese fixtures.

Known remaining gates

  • Parent must generate Cargo.lock, supply the local patch config and the reviewed craft-ui revision, then run the repository gates. Strict kittest accessibility is still on: stock TextEdit/DragValue/Slider fields without a label or hint will be reported (no global or per-builder opt-out was added).
  • ui_gpu/native GUI, hidden-window screenshots (the refusal above), Wayland/X11 IME and PRIMARY, Windows/macOS rows, and every performance row remain unmeasured here.