Files
gridcraft/docs/egui-037-migration-plan.md
T
skylar taylor-barrick 41769f1d16 Migrate GridCraft to egui 0.37 (#2543)
* E37: migrate GridCraft to egui 0.37

Pin the reviewed fork and shared dependencies, adapt input and rendering to the
new frame lifecycle, and preserve bounded capture handling and font notices.
Add representative accessibility checks and meaningful control labels.

Validation: full repository CI passed all ten stages, with 2,249 tests passed,
zero failed and one existing timing test ignored. Native workflows and paired
performance qualification remain documented separately.

* egui037: record final published pins and local qualification

* M0.37: fix RTL clipping and preserve screenshot budgets

* test: share migrated GridCraft UI harness setup

* egui037: document migrated UI and regression contracts

* egui-037: align popup test fonts and avoid redundant tooltip probes
2026-10-25 14:55:00 -04:00

98 KiB
Raw Blame History

GridCraft — egui 0.37 migration plan

Status (2026-10-25): the local migration incorporates main 52fa4406c1ebff8b260a2922472f03aba198b9cf. Canonical pins are egui 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc, craft-ui/craft-print 25da1314d443171b4adeaad2cd9454b273f35c6f and craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83. The regenerated lock SHA256 is 426a766291a8246573bf57e3ab3f5f17ff812487582254a32ff5a2ec0db39f27 and passed the dependency-source audit. egui037-grid-final-main-ci-20261025-0854 passed all ten stages: i18n-report, fmt, strict workspace all-target clippy, workspace tests, doctest, assets, layers, WASM, parity and keytips. Actual totals are 2,463 passed, none failed and one ignored across 35 summaries. Independent review verified the merged main behavior and the final scroll/accessibility fixes. The local merge preserves both migration history and the reviewed upstream changes; final code and dependency bytes match the passing CI. Subsequent status edits are prose only. Main advanced to 17b9f6ff0125209fe707efc919690632ff49cb0b after that CI run; this tree does not include it. The pass above therefore does not qualify the later integration, and PR CI must pass on the combined tree before it is described as validated. Native/platform/performance and application E21b qualification remain open; canonical pins and full CI do not imply that every native contract is qualified. Human review and explicit publication approval are required. Earlier compiler logs, source anchors, baseline inventories and proposed steps below are historical records; this status governs current pins and acceptance. Final commit/source/CI bindings are in final-landing/grid-publication0865 in the migration task evidence.

Scope: storytold/gridcraft only. The shared storytold/craft-libs, egui fork and winit fork are dependencies of this plan, not files this plan changes. Application document models, commands, undo semantics, XLSX persistence and UI policy remain owned by GridCraft.

1. Baseline, source identity and targets

1.1 Exact source baseline

Item Revision / value
GridCraft checkout /home/codex/work/CraftApps/egui037-migration-20261022/gridcraft-implementation
GridCraft baseline SHA b8771d51fb4da60bd48c13cf313d6d6014dc5e66 (current main merge base for this migration; the earlier ac8da7d4 release baseline is retained as historical evidence below)
Workspace version / edition / MSRV 0.11.1, edition 2024, declared rust-version = "1.95"
Lockfile Cargo.lock, format version 4
Current egui family selected packages at 0.37.0 from canonical6d518fc7; egui_extras declared at 0.37 but unused
Current winit / wgpu winit 0.30.13 at canonical a517b384; wgpu 30.0.1
Current accesskit / kittest accesskit 0.25.1, accesskit_consumer 0.39.1, kittest 0.5.0, accesskit_winit 0.34.1
Current skrifa 0.44.0, shared by epaint and gridcraft-ui-egui
Shared UI dependency craft-ui and craft-print both pin canonical 25da1314; the complete 0.37 UI/capture tree is included
Shared capture helper craft-ui CL-2 provisional capture slot; GridCraft/DesignCraft adapters are the pilot and precede CL-2F. CL-1 and CL-2F are the shared gate for the eleven craft-ui consumers
Target upstream release egui annotated tag 0.37.0, peeled commit 841a328ba2dd8f6996beb7189ebb7fc00e668a2a (2026-10-15)
Historical review candidate egui (superseded by current immutable pin) aiongg/egui craft/0.37 at a4f9a17f64a6bae61350fb85804c9df0d9865916; inspect only, never publish this branch
Historical review candidate winit (superseded by current immutable pin) aiongg/winit craft/0.30.13-lang at 8b35388bd719e8cc86a95971ba0bc026c9a8f058; published base storytold/winit craft/0.30.13 at ffae5198770d7730e718043f035a772b7dbc3b1b
Current implementation egui/winit pins 173cc7375d35dc5fbc5b5d0994fdf787f547c5d1 / ae48c756a3b81125eb796c0cc6dccf60990d1181; full P8/E21b and platform/performance acceptance remain separate, explicitly unqualified gates
Stock comparison egui 0.36.2 49682f8baa058bf49e011035cfbd6e825f88a5ef, 0.37.0 841a328b…; stock 0.37 is a measurement control only, never a production stage or pin
Existing shared update storytold/craft-libs PR 199, branch head b68de8c5f4f1c060904d211a7b1320aa0f7bce78; open and conflicting at inspection
Historical diagnostics /home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/gridcraft.errors

The existing audit merge ec8840af8c5fda695cf195cdd05e5ae078415c07 is not a base: its Cargo.lock still resolves egui 0.36.2 while the source expects 0.37. The PR-199 branch itself has a 0.37 registry lock but no fork/winit pin. Neither is accepted evidence for GridCraft.

rust-version = "1.90" is already inconsistent with the current eframe 0.36.2, whose manifest says Rust 1.95. Cargo only checks the active compiler, so this has not surfaced. The migration must align the workspace rust-version and CI toolchain to 1.95 (or the higher of the selected fork's MSRV), because 0.37.0's egui/epaint/eframe manifests declare 1.95.

1.2 Build targets and entry points

Target Package UI path Notes
Desktop native gridcraft (apps/gridcraft) App::logic / App::ui wrapping SheetApp eframe trait: fn logic(&mut self, ctx: &Context, frame: &mut Frame) and fn ui(&mut self, ui: &mut Ui, frame: &mut Frame) (plus raw_input_hook); SheetApp::logic(&Context)/ui(&mut Ui) take no Frame. The split exists on 0.36.2 and is not 0.37-only
Web gridcraft-web (apps/gridcraft-web) WebShell::logic / WebShell::ui wrapping SheetApp wasm32, eframe web runner, WebGPU with WebGL2 fallback, rfd async picker, no TCP control channel
UI library/tests gridcraft-ui-egui SheetApp::logic, SheetApp::ui, examples/snapshot.rs 85 integration test files (750 #[test] in tests/) plus 153 in-crate unit tests: 903 #[test] attributes in the crate by static count
CLI/headless gridcraft-cli none no egui dependency; must keep building/tests
Shared controls craft-ui (printing only) printing::State::show inside dialogs GridCraft does not use craft-ui docking/tabs/buttons
macOS native menu test apps/gridcraft/tests/mac_menu_appkit.rs AppKit/muda, no egui must still run on macOS
Tooling xtask ci, layers, wasm, assets, parity, keytips cargo xtask ci is the repository gate

There are no child ViewportIds, deferred viewports, docking areas, audio/playback queues, or nested workspaces in GridCraft. The single root viewport is the only egui window. view.newWindow is an engine document command, not an egui child window. The plan therefore does not add viewport routing or docking migrations that GridCraft does not use.

1.3 Historical pre-migration manifest/feature inventory

The following inventory describes the baseline before implementation. The current root adds canonical family/winit patches, both shared-library pins are c488caa9, native clipboard is enabled, and the current lock/pins are recorded above. Baseline absence of patches is not current state.

  • Root Cargo.toml: workspace resolver 3; egui, egui_extras, eframe, egui_kittest, epaint_default_fonts pins; skrifa = 0.44; no [patch] section anywhere in the repository.
  • crates/ui-egui/Cargo.toml: egui (workspace, default features), craft-ui git revision, skrifa, Linux-only zbus = "=5.19.0", dev egui_kittest with wgpu + snapshot, dev epaint_default_fonts, gridcraft-xlsx test support, wasm web-sys.
  • apps/gridcraft/Cargo.toml: eframe default-features = false plus default_fonts, wgpu, accesskit, wayland, x11 at baseline; G1 adds native clipboard to preserve the 0.36 integration-forced OS clipboard. craft-print git revision; desktop rfd, arboard (wayland-data-control on Linux/FreeBSD), Windows pollster, macOS muda and fmv-macos-events, macOS AppKit dev-dependencies.
  • apps/gridcraft-web/Cargo.toml: egui workspace; wasm-only eframe default_fonts, wgpu, accesskit; rfd, wasm-bindgen, js-sys, web-sys feature list.
  • All other crates: no egui dependency. xtask/src/layers.rs already classifies gridcraft-ui-egui as the only crate allowed egui/wgpu.
  • .cargo/config.toml only aliases xtask; no rustflags, target overrides or patches.
  • No vendor/ directory, no [patch] entries, no nested workspace, no examples or benches with egui except crates/ui-egui/examples/snapshot.rs.
  • CI: .github/workflows/ci.yml runs cargo xtask ci (fmt, clippy -D warnings, workspace tests, assets, layers, wasm, parity, keytips). FreeBSD, Nix-evaluation, packaging-lint, release and Windows ARM64 workflows exist; none runs an egui snapshot job today.

The 0.37 graph also changes the egui feature contract:

  • egui and epaint no longer enable default_fonts by default (#8583). gridcraft-ui-egui must explicitly request egui/default_fonts (and update epaint_default_fonts to 0.37) to make its production fallback policy explicit. egui_kittest's normal egui dependency inherits default-features = false. Only kittest's own dev-dependencies enable default_fonts/monochrome_emoji_fonts, and dev-dependencies do not propagate to consumers. Package-scoped kittest/snapshot builds are therefore font-empty unless gridcraft-ui-egui itself enables egui/default_fonts or the harness installs pinned fonts. Inspect the resolved feature graph for each production and test root; do not infer fonts from kittest's dev-dependencies.
  • egui_kittest must move to 0.37/kittest 0.5; its wgpu and snapshot features remain.
  • egui_extras is declared but unused; keep it out of the graph or pin it consistently, but do not delete the workspace entry as part of an unrelated cleanup.

2. Historical diagnostics and what they already prove

The audit logs from 2026-10-22 (not current acceptance) list five fork errors in GridCraft:

  1. control.rs: egui::UserData no longer exists.
  2. control.rs: egui::Event::Screenshot no longer exists.
  3. dialogs.rs:1969: Role::SelectableLabel no longer exists.
  4. ribbon.rs:146: Role::Checkbox no longer exists.
  5. theme.rs:282: FontData::font is no longer Cow<[u8]>; it is Arc<dyn AsRef<[u8]> + Send + Sync>.

The stock log adds the same font-family API errors at theme.rs 993/1065/1098/1104/1119 (comparison, to_vec, len, Cow construction). The current source confirms those anchors. These errors are only the first compilation wall; they do not cover the behavioural work in sections 3–9.

3. Actual incompatibilities and design decisions

3.1 Root Ui, lifecycle and hidden-window logic

GridCraft already uses the correct 0.36/0.37 shape:

  • crates/ui-egui/src/lib.rs:1148 SheetApp::logic(&Context) handles control polling, font readiness, desktop theme, zoom, close guard and the asynchronous file inbox; the screenshot deadline/slot drain moves here in 3.5.
  • crates/ui-egui/src/lib.rs:1270 SheetApp::ui(&mut Ui) lays out the title bar, ribbon, backstage, formula bar, status bar, tabs, panes and central grid.
  • apps/gridcraft/src/main.rs impl eframe::App for App calls self.0.logic(ctx) in logic and self.0.ui(ui) in ui; apps/gridcraft-web/src/web.rs does the same for WebShell.
  • egui_kittest harnesses call app.logic(ui.ctx()); app.ui(ui) through Harness::build_ui_state (for example tests/interaction.rs:10). examples/snapshot.rs uses the same closure and harness.render().

Do not rewrite this into Context::run_pass/root_ui calls. Context::root_ui panics while App::ui/run_ui already holds the root Ui (#8535). Panels are already created on the passed root Ui; CentralPanel::show(ui, …) in SheetApp::ui stays. Tests keep Context::run_ui (#8535 keeps it as a wrapper over run_pass + root_ui).

Actual lifecycle contract. Both native (eframe/src/native/epi_integration.rs::update) and web (eframe/src/web/app_runner.rs::logic) call App::logic and then App::ui inside the Context::run_ui closure, once per layout pass. A multi-pass frame therefore calls logic more than once. When nothing is painted, eframe calls App::logic through Context::run_logic instead; that call leaves events, input time and layout stale and must not consume UI input. After a normally completed run_ui, current_pass_index() is zero even after several passes (end_pass takes the output and only run_dyn restores the count before the next pass), so it cannot mark hidden work. ViewportInfo::visible() reports minimized/occluded (None unknown), not the phase: an issued screenshot command or ViewportCommand::RequestPaintWhileHidden forces a full hidden UI pass and paint. A screenshot merely queued in logic has not reached that point (section 3.5). Use consumed queues, request/slot identity and monotonic deadlines, not a visibility flag or pass index, to decide background work; input handling belongs in ui.

Only these background paths move into logic: the control poll (already there), printing feedback polling, screenshot result drain/deadlines, autosave, the window title and the existing queued file inbox. Do not move ribbon_keys, pointer handling, ctx.text_edit_focused() or event-list scans.

Two lifecycle defects must be fixed while the graph changes:

  1. Autosave currently runs in ui (lib.rs:1331). Move the decision into logic, keep store_view_state()/file.save there, and call ctx.request_repaint_after(2 s) while AutoSave is on and the active workbook is dirty and has a path. Keep one monotonic last_autosave check so a forced hidden UI pass (logic called again) cannot save twice. Preserve the existing guards: no cell editor, no text-box editor, .xlsx path, last_autosave > 2000 ms.
  2. Printing feedback is polled twice per frame, and neither path is SheetApp::logic. apps/gridcraft/src/main.rs:43 calls self.0.poll_printing(ctx) immediately after self.0.logic(ctx), and SheetApp::ui calls poll_printing again at lib.rs:1272. Move the one poll into SheetApp::logic, delete the ui call and delete the desktop app's extra call. The web shell has no extra poll. printing::State::poll and print_feedback_seen remain idempotent; the direct calls in tests/native_print.rs:40,121,124 move to a real logic drive, or stay as explicit helper tests with a name that says they bypass the lifecycle.

Also move the window-title update (lib.rs:1342) into logic, next to the close/autosave state. ViewportCommand::Title is valid outside a pass. This makes a control-channel rename while hidden update the native title when the window returns. Keep last_title in GridState/SheetApp so it is sent only on change.

Multiple UI passes and synthetic input. Context::run_dyn takes RawInput fields once per frame, so a second pass sees no pointer/key events and must not replay a click. It can still re-run non-input side effects. App::raw_input_hook runs once per frame, before run_ui or run_logic, not once per pass; in a hidden logic-only tick it can run with no UI to consume its events. Do not dequeue-and-discard a gesture in the hook. Keep one unapplied gesture step in app state and carry it across repeated hidden logic ticks until a full ui pass consumes it (eframe also retains an unconsumed RawInput for the next pass); do not offer the next step while one is unapplied, and keep press and release as distinct steps rather than collapsing them. While a step is unapplied, logic sends ViewportCommand::RequestPaintWhileHidden every frame (the command holds for one frame) so a UI pass runs. An issued screenshot command requests paint for its own capture, but it does not make synthetic input run, nor does it issue itself. Audit SheetApp::ui for pass-dependent effects:

  • control::issue_screenshots runs after every widget but before collect_screenshots, autosave and the title update; it is the last UI-only command issuer, so no GridCraft widget can request a discard after it. It issues once per admitted request (section 3.5). Do not gate it on will_discard(): that query only reports a discard already requested and cannot predict a later one.
  • control::collect_screenshots is replaced by a logic-side slot drain (section 3.5).
  • poll_printing moves to logic (above).
  • ctx.send_viewport_cmd(ViewportCommand::Title) and store_view_state/autosave become logic calls.
  • backstage, dialog and popup state changes are input-driven; the second pass has no events, so they do not repeat.
  • grid::show writes grid.rect, grid.geo, grid.cells_rect and scroll state. These are recomputed on every pass from the same SheetApp/session; a discarded pass may leave values from the discarded layout, but the final pass overwrites them.

The discard-injection hook cannot be a #[cfg(test)] field used by crates/ui-egui/tests/*: integration tests compile the library as a normal dependency, so cfg(test) is off there. Put the private force_discard_once field and the discard tests inside a #[cfg(test)] mod in crates/ui-egui/src/lib.rs, where unit tests can set it and call ui/control directly; keep integration tests to observable commands without the hook. Tests:

  • click a ribbon command, then a hook requests request_discard after screenshot issuance in that pass → exactly one journal entry/undo step, one title command, one autosave, and the screenshot request completes exactly once (its command is appended to the combined pass output; no duplicate command is issued);
  • a forced hidden UI pass after a completed multi-pass frame still runs autosave/printing once and applies queued synthetic input, while a plain hidden logic tick runs no ui;
  • repeated hidden logic-only ticks with a queued gesture retain one unapplied step (press and release still distinct) and a later full UI pass applies it exactly once; a queued-but-not-issued hidden/occluded screenshot receives RequestPaintWhileHidden and completes once a current size is reported (a minimized request waits for restore or reports its timeout);
  • logic only (no ui) with a dirty autosave workbook → exactly one save after the delay and no duplicate when a forced ui pass follows.

3.2 WidgetType → accesskit::Role

Sixteen call sites use the deprecated WidgetType alias:

File Sites Mapping
crates/ui-egui/src/backstage.rs 5 WidgetType::Button → Role::Button
crates/ui-egui/src/border_preview.rs 2 Button and Label → Role::Button, Role::Label
crates/ui-egui/src/dialogs.rs:1969 1 SelectableLabel → Role::Button with WidgetInfo::selected(Role::Button, enabled, selected, label); keep and assert the selected-state claim
crates/ui-egui/src/grid.rs:3421 1 Button
crates/ui-egui/src/ribbon.rs 6 Checkbox → Role::CheckBox; the rest Button
crates/ui-egui/src/tabs.rs:73 1 Button

Use egui::Role (re-export of accesskit::Role), not a local enum. Preserve the existing selected/enabled arguments and labels; the border-style items in dialogs.rs use WidgetInfo::selected so their checked state survives. Add contract tests against the actual controls: Role::CheckBox with checked state on Dark Mode (the only WidgetType::Checkbox site; the custom-painted ribbon/tab controls are not checkboxes), Role::Button plus selected state on Format Painter and a selected Backstage row, Role::Button on All sheets and on each sheet tab, and Role::Label on the border preview. The custom-painted sheet tabs currently expose no widget info, so G1 gives each one WidgetInfo::selected(Role::Button, …) with its sheet name before asserting selection. Compilation alone is not evidence of a role change.

3.3 Deprecated IDs

Id::new and Ui::id still compile in 0.37 but warn, and cargo xtask ci denies warnings. Replace all of them mechanically; the values are identical:

  • 46 production Id::new( occurrences and 10 test occurrences (excluding FontId::new/ LayerId::new), across grid.rs (17), formula_bar.rs (6), panes.rs (5), paper_view.rs (4), widgets.rs (3), ribbon.rs (3), and one each in cell_pictures.rs, credits.rs, dialogs.rs, floating_pictures.rs, i18n.rs, printing.rs, tabs.rs, text_box.rs.
  • 9 ui.id() uses: tabs.rs:98, widgets.rs:149,150,246,327, paper_view.rs:470, ribbon.rs:44, grid.rs:867,920 (nine lines; widgets.rs contributes four).
  • No UiBuilder::id or AsId uses.

Replace with Id::unique and Ui::scope_id. Do not change the source values or add salts. The debug ID tracker in 0.37 is stricter about unstable IDs; a ui.scope_id().with(...) under a reordered row is stable only if the salt is stable. Existing salts already include row/column/ document identity; keep them. Add a focus-retention test: focus the name box, switch language, open/close the comments pane, and verify focus stays on the name box (not on another field).

3.4 Font data API

epaint::text::FontData.font changed from Cow<'static, [u8]> to Blob = Arc<dyn AsRef<[u8]> + Send + Sync>. FontData still derives Clone and implements PartialEq/Debug itself; the Blob type has no PartialEq. Tests that want pointer identity must therefore compare the blobs with Arc::ptr_eq, not rely on FontData equality.

Concrete changes in crates/ui-egui/src/theme.rs:

  • try_load (theme.rs:244): build FontData::from_blob(blob, *index) or FontData { index, ..FontData::from_static(bytes) }.
  • font_file (theme.rs:263): cache Blob rather than &'static [u8] in the existing Mutex<BTreeMap<...>>. Return Option<Blob>; clone the Arc for each FontData. This preserves the existing “one file loaded once” memory intent and makes Arc::ptr_eq meaningful.
  • drop_unparseable_fonts (theme.rs:281): font_parses(fd.bytes(), fd.index).
  • font_definitions_with (theme.rs:457) and load_fallback continue to insert Arc::new(fd); font_data is still BTreeMap<String, Arc<FontData>>.
  • crates/ui-egui/src/fonts.rs:355 bind_family uses FontData::from_owned(bytes); unchanged, except any .font field access must use .bytes().

Tests in theme.rs must change, not be deleted:

  • fallback_faces_load_each_file_once: compare !Arc::ptr_eq(&a.font, &b.font) instead of a.font != b.font.
  • unparseable_font_files_are_rejected_not_fatal: fd.bytes().to_vec(), fd.bytes().len().
  • interface_fonts_are_read_once_and_shared_with_egui: with the Blob cache, assert Arc::ptr_eq(&first.font, &second.font) and first.bytes() == epaint_default_fonts::UBUNTU_LIGHT. This is stronger than the old Cow::Borrowed pointer check and proves egui shares the bytes.
  • Keep drop_unparseable_fonts as the last line of defence. It is not a vendored patch; it is the app's protection against epaint panicking on a corrupt system font (#226), and 0.37 still parses faces during set_fonts.

Add egui = { workspace = true, features = ["default_fonts"] } to crates/ui-egui/Cargo.toml (or enable it on the workspace dependency) and update epaint_default_fonts to 0.37. Without this, FontDefinitions::default() is empty in font_definitions_with in a production library graph without another font-enabling dependency. The consumer's egui_kittest dev-dependency does not propagate kittest's own default_fonts/monochrome_emoji_fonts dev-features, so the test harness must enable egui/default_fonts itself or install pinned fonts.

3.5 Screenshot callback — exact lifecycle

Current code (crates/ui-egui/src/control.rs):

  • Shots { token, queued: Vec<(token, at_ms, frames)>, pending: Vec<(token, path, Sender, deadline)> } (control.rs:70).
  • poll enqueues a screenshot request and a pending reply (control.rs:450).
  • issue_screenshots (control.rs:477) waits 100 ms and three UI frames, then sends ViewportCommand::Screenshot(UserData::new(token)).
  • collect_screenshots (control.rs:493) scans Event::Screenshot events in the next input, writes/replies, and expires pending requests after 8 s.
  • selection_glide and grid use app.shots.in_flight() to freeze animation while a capture is pending.

0.37 replaces both the input event and UserData with a one-shot callback. GridCraft adopts the shared CL-2 completion slot; it does not add a local SyncSender channel or a second token map. The selected topology is CL-2 provisional slot API -> GridCraft and DesignCraft adapters against that exact revision -> CL-2F API freeze after joint review. The slot owns one result and the request record owns the slot; the screenshot callback holds only a Weak reference to it:

// Sketch of the shared CL-2 ownership, not an existing API.
let weak = Arc::downgrade(&request.slot); // app owns the strong slot and its waker
let callback = egui::ScreenshotCallback::new(move |image: Arc<egui::ColorImage>| {
    // Short lock only; never encode, write, reply, run commands or touch app state here.
    let Some(slot) = weak.upgrade() else { return }; // canceled/dropped: release image now
    let wake = slot.offer(token, image);            // one result, replaces nothing
    drop(slot);                                     // release the lock before waking
    if let Some(wake) = wake { wake.wake(); }       // slot-owned UI waker, after unlock
});
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback));

Design:

  • Shots keeps request identity, path/reply routing, settle counter, 8 s deadline and the in_flight() query. Replace the queued/pending vectors with one slot per admitted request from craft_ui::capture (CL-2 provisional revision); do not add a second channel. The app owns the strong slot and the waker; the slot stores at most one Arc<ColorImage>, records the issuance-time UI logical geometry and geometry epoch, and is released on drop/cancel/timeout. The callback holds only Weak, unlocks before invoking the slot's waker, and holds no strong Context; it does not encode, write, reply or request its own repaint. A late callback after cancel or app shutdown upgrades to nothing and drops the image without a file or reply. Correlation is slot ownership plus the request token; callback order is irrelevant and a late image cannot bind to a new request.
  • Admission is bounded by count and aggregate bytes before issuance, with checked arithmetic, not just by the number of slots. Adopt the shared capture defaults: 4 outstanding requests, 128 MiB per image and 256 MiB aggregate reserved for in-flight/completed images; an app may choose stricter explicit limits. Reserve checked width * height * 4 bytes for the expected capture from the current viewport logical size and pixels_per_point, rounded up and capped by the surface/max_texture_side before issuing; reject with a clear “screenshot budget exceeded” reply when count or bytes would exceed the budget, without evicting earlier requests. The reservation and the CaptureSlot per-image bound are the largest image validate_image accepts (one pixel of rounding per axis), so the aggregate bound is strict for retained bytes; a viewport with no size yet reserves the per-image maximum and waits for a real size or the deadline instead of being refused as an overflow. Revalidate the geometry immediately before issuing. Record the issuance-time UI logical content/screen geometry and a geometry epoch and bind them to that image; cancel or retry on any intervening resize/DPI/zoom/surface change before save, even if the value changes back to its original. The callback supplies pixels only, so derive scale from the returned image dimensions and the recorded logical viewport, and verify aspect/rounding. On completion reject an oversized, stale or epoch-mismatched image, release its bytes and reservation, and keep retained slot bytes back at zero after drain/shutdown. The renderer/readback transient allocation is recorded separately in peak RSS; a post-callback size check alone does not prove the peak is bounded.
  • issue_screenshots runs after every widget but before autosave and the title update, so no GridCraft widget can request a discard later in that pass. Issue once per admitted request (one callback, one command); Context::run_dyn appends each pass's output before it checks requested_discard(), so a command issued in a discarded pass still reaches the integration. Settling counts one real pass per request (cumulative_pass_nr, so repeated ui calls in one pass count once) and skips a pass that has already asked to be discarded; a discard requested after issuance can still cost one counted pass, but the image is geometry-validated at drain. request_repaint_after alone wakes logic only, and a queued request has not yet issued its paint-requesting command. Therefore, while a request is queued or settling with a capturable size, logic sends ViewportCommand::RequestPaintWhileHidden on each frame until issue_screenshots runs; once issued, the Screenshot command's wants_paint forces the painted frame, so no further hidden paint request is sent. A minimized request has no capturable size and sends none: its hidden full-frame wait cannot reach issuance, and the restore-triggered pass issues it instead. Also keep the 16 ms request_repaint_after from both ui and logic for completion and timeout.
  • collect_screenshots becomes drain_screenshots(app, ctx, now) called from logic (not ui). G1 includes the drain, deadline/timeout handling and the basic exactly-one reply, so its own screenshot smoke can pass; G2 owns the adversarial byte-budget/discard stress. For each completed slot: validate request identity, size, geometry/epoch and deadline, take the image once, then encode/write/reply outside the slot lock. A completed request with no live record drops its image without a reply.
  • logic checks deadlines and replies with the existing error text (control.rs:521), updated if it still tells users to make the window visible (an issued screenshot command paints a hidden window; a queued one reaches issuance through RequestPaintWhileHidden). It must not hold a slot lock while calling reply.send.
  • Request IDs use wrapping_add(1) and skip zero; never index arrays by token. A reused viewport or document does not matter because the slot dies with its request record.
  • in_flight() remains true while any request record has an unfinished slot, so animation stays frozen through a late/queued capture.
  • If CL-2's stress tests expose a weakness, fix it in CL-2/CL-2F with the DesignCraft adapter in the same review; a GridCraft-local channel is not a fallback.

Tests (crates/ui-egui/src/control.rs unit tests or tests/screenshot_callback.rs):

  1. Build a Context, run issue_screenshots for a request and inspect the ViewportCommand::Screenshot(callback) from FullOutput; complete it with a 2×2 red image; run logic; assert one PNG/path reply with width/height, and the file bytes decode.
  2. Two requests with distinct slots: complete out of order; each reply gets its own image/path.
  3. Timeout/cancel/drop: inject a fake clock, cancel one request and time out another, then complete both callbacks; assert no second reply, no file write and retained bytes return to zero.
  4. Count and byte budgets separately: with an aggregate budget below four 4K captures and a per-image budget below one oversized capture, admission stops on bytes before the count; extras are rejected once with an error; completed/oversized images release their reservation. Assert peak retained bytes, not only slot count.
  5. Discard after issuance: a unit-test hook calls request_discard immediately after issue_screenshots; assert exactly one command was issued, the capture completes exactly once, and the reply/file is written once. Do not require a post-pass API.
  6. Resize/DPI/zoom: change viewport size/scale between admission and issuance, and again between issuance and completion including a change back to the original values; assert the geometry epoch mismatch retries within the deadline or returns the size/geometry error and releases the reservation; never combine old pixels with current geometry. The identity intentionally keeps zoom/visibility/occlusion, so a bounded retry failure is acceptable instead of comparing only the expected physical size.
  7. End-to-end ui.screenshot through ControlRequest/handle in a kittest harness with wgpu: file and base64 forms, invalid path error, hidden/occluded-window capture with a current size (queued admission sends RequestPaintWhileHidden, then the issued command paints the hidden window), a minimized request that waits for restore and otherwise times out, and no reply after drop.

3.6 MouseWheel event source

0.37 adds the required source: MouseWheelSource field to Event::MouseWheel (#8665). Every literal constructor must add source, preserving the test's intent:

  • crates/ui-egui/tests/it/wheel_zoom.rs: line events use source: MouseWheelSource::Unknown (simulated wheel); point/page units and the Wheel, Trackpad and Momentum sources are now covered, including a gesture with no TouchPhase::End.
  • crates/ui-egui/tests/it/sheet_tabs.rs and tests/it/filter_list_scroll.rs: Unknown.

Production matches (keytips.rs:64, grid.rs:2945) use .. or a wildcard and need no field change. Add tests for Wheel, Trackpad and Momentum as well, because the 0.37 wheel smoothing changes (#8662, #8678) and the winit W13 source reporting affect the notch counting in grid::show (grid.rs:591-637) and Ctrl+wheel zoom. The expected zoom ladder (grid::wheel_zoom_step) and scroll distance must not change silently. Event::PasteImage and Event::MiddleClickPaste are new variants; no exhaustive match exists today, but new handlers must choose explicitly (section 6.4).

3.7 Style and geometry pinning

0.37 changes default widget styling (#8617, #8445): widget corner_radius rises from 2/3 to 4/6, button padding from 4/1 to 8/2, and interact_size.y from 18 to 20. GridCraft already sets button_padding = (6,3) and interact_size.y = 22 in theme::apply (theme.rs:804, values at 831–832), but does not set widget corner radii. To avoid an unintended restyle:

  • In theme::apply, set v.widgets.noninteractive.corner_radius = CornerRadius::same(2), inactive = 2, hovered = 3, active = 2, open = 2 (the 0.36 defaults), in both light and dark themes. Keep the existing window_corner_radius = 10, menu_corner_radius = 8, popup shadow, and all token colours.
  • Do not rely on Visuals::dark()/light() defaults for anything GridCraft paints with egui widgets. GridCraft's custom-painted ribbon/grid already set their own radii.
  • TextEdit::horizontal_align(Align) is deprecated (paper_view.rs:489). Replace with TextEdit::align(Align2), preserving vertical top alignment: [LEFT_TOP, CENTER_TOP, RIGHT_TOP][e.section.min(2)]. Do not use Align2::LEFT_CENTER, which would move the header/footer text vertically.
  • 0.37 TextEdit changes (#8628 unclipped growth, #8420 min size, #8763 focus surrender) affect the formula bar, in-cell editor, header/footer editor, dialogs and tab rename. Review tests/in_cell_editor_size.rs, in_cell_editor_wrap.rs, committed_text_fields.rs, formula_bar tests, page_layout_view.rs, sheet_tab_rename.rs and add a click-away selection regression (section 8).

4. Root-Ui, multi-pass and logic-only acceptance

Risk Observable assertion
Hidden logic stops With the window hidden/minimized and AutoSave on, a dirty saved .xlsx is saved once after the delay; control ui.inspect still answers; a plain hidden tick runs logic only (a forced hidden UI pass is covered separately)
Duplicate side effects A unit-test hook requests request_discard after a command and after screenshot issuance: one journal entry/undo step; one title command; one autosave; screenshot completes once
Root Ui borrow No Context::root_ui call inside SheetApp::ui; the passed &mut Ui is used for every panel; a debug build does not panic with the root-Ui borrow message
Panel order Title bar, ribbon/backstage, notice, formula bar, status bar, sheet tabs, pivot pane, panes, central panel reserve space in that order at 1200×800 and 640×420; grid.rect equals the central panel's inner rect
Clip/hit agreement Geo cell positions, grid.cells_rect, scrollbars and ui.cell.click hit the same cells after resize, zoom (75/100/200%), right-to-left sheets and frozen panes
Multi-pass geometry A pass discarded by a Grid sizing pass leaves final grid.geo equal to a single-pass render
Logic input A control ui.key/ui.click while hidden is applied in forced hidden UI passes; one unapplied gesture step is retained across repeated hidden logic ticks with press/release distinct, logic sends RequestPaintWhileHidden until a full ui pass consumes it, and logic never scans Event::Key/Pointer

Test anchors: tests/interaction.rs (harness shape), tests/interface_scale.rs, tests/host_chrome.rs, tests/right_to_left.rs, tests/wheel_zoom.rs, tests/scroll_position.rs, tests/selection_scroll.rs, tests/freeze_handles.rs.

5. Dependency graph, fork and vendoring decisions

5.1 Intended GridCraft graph

For suite validation, GridCraft must resolve one copy of each egui-family crate and one winit:

gridcraft (eframe + egui + egui-wgpu + egui-winit + epaint)
  -> eframe 0.37 (approved storytold rev)
  -> egui 0.37 (approved storytold rev)
  -> egui-wgpu 0.37 (approved storytold rev, E2+E3)
  -> egui-winit 0.37 (approved P8 rev: E4/P2b W15P routing + E7/E19 held-key + E21 + E21b/P7b)
  -> epaint 0.37 (approved P8 rev, E5/E8/E11/E12/E13/E14/E15/E16/E20)
  -> ecolor/emath 0.37 (approved P8 rev)
  -> winit 0.30.13 (approved completed rev, W17-W27 + W15P + W27 lifecycle)
  -> craft-ui / craft-print (craft-libs approved rev -> egui 0.37 fork)

G1 explicitly enables native eframe/clipboard; verify egui-winit/clipboard and arboard in the native feature graph. Web features are unchanged. Cross-application copy/paste must work in the grid, formula bar and in-cell TextEdit, including text from an independent process and copy back to it; an in-process clipboard is not acceptance.

Add a root [patch.crates-io] in gridcraft/Cargo.toml for every egui-family package GridCraft actually resolves: egui, epaint, ecolor, emath, eframe, egui-wgpu, egui-winit, egui_kittest, epaint_default_fonts, plus winit. Use full revision SHAs, never branches. A dependency's patch section does not propagate; this root patch is required. egui_extras can be patched too if it stays in the graph, but it is currently unused.

Publication rules:

  • No path = overrides in the published tree; no machine-local patches; no moving branch.
  • cargo metadata --locked and cargo tree -i egui/-i winit must show exactly one source per package for native and wasm feature sets.
  • cargo check --locked must work in a clean checkout with network access to the public fork.
  • aiongg is a review candidate only. The production graph must use the complete reviewed/published storytold egui P8 release (P2b async W15P routing, P7 held-key pairing, P7b/E21b, P7c's selected #8763/#8748 fixes and justified performance fixes) and the completed winit series (W15P and W27 lifecycle). No stock 0.37 or intermediate P stage is a production candidate. The final SHAs are not selected yet; do not pin a candidate head, branch, old SHA or invented SHA. S2 and G1 stay blocked until the approved full SHAs are recorded in 1.1 and checked in a clean --locked build.
  • Keep the old coherent set (current Cargo.lock + craft-libs d27646a5) as the rollback point.

5.2 craft-libs PR 199 and issue 198

GridCraft consumes only craft_ui::printing today; G1 adds craft_ui::capture (CL-2) for screenshots. PR 199's role/ID/docking/ellipsis edits are relevant to craft-libs itself, not to GridCraft's source. Integration decisions:

  • Do not rebase or merge PR 199's tree. Its head b68de8c and the audit merge ec8840a are stale relative to 49190884278f16c9a9f5823d7698a8ffb5deb062: they omit craft-platform and revert security, settings, docking and printing fixes. Port only the API-port hunks from 8614e4a04fae516fba16c22b31ff1008cb6be10b plus the relevant README change from 1764bca onto current 4919088, preserving intervening commits; gate the result with a diff check against 4919088 that shows only those intended hunks.
  • CL-1 must add the winit patch and a publication-compatible check (registry egui 0.37 build of the packaged crate). GridCraft needs the fork pin separately.
  • CL-1 and CL-2F are the shared gate for all eleven craft-ui consumers at the P8 stage. GridCraft and DesignCraft are the CL-2 provisional pilot; both adapters are reviewed before CL-2F freezes the API. A consumer already bumped to an incompatible craft-ui revision must land that bump in the same atomic dependency stage as the egui P8 landing; no mixed craft-ui/egui graph is acceptable.
  • Bump craft-ui and craft-print to one approved craft-libs revision. Leaving craft-print at d27646a5 while craft-ui moves creates two git checkouts of the same repository and an incoherent graph.
  • Issue 198 is a language-support proposal, not a merged dependency. Do not import its locale or formatting roadmap. GridCraft keeps its own i18n table and formula locale; only consume the fork APIs the suite actually selects (for example Context::set_language on the fork, E17).
  • If craft-libs' printing::State API changes in CL-1, update crates/ui-egui/src/printing.rs and apps/gridcraft/src/print.rs; the current wrapper already isolates GridCraft from most of craft-ui.

5.3 Vendored fixes and provenance

GridCraft has no vendor directory. Its current root uses canonical egui/winit Cargo patches; those dependency selections do not replace these app-local workarounds, which must be preserved:

Fix Provenance Decision
graphics.rs backend selection, no Vulkan by default, FXC for DX12, GL fallback GridCraft #120, #339, #354 Keep in apps/gridcraft/src/graphics.rs; E2/E3 fork errors make the existing fallback path effective
theme.rs font parse guard and drop_unparseable_fonts GridCraft #226 Keep and adapt to Blob; add a corrupt-font regression
Custom text_box.rs wrapper and selection glide GridCraft UI Keep the wrapper, its state sync and selection glide; its inner field is built-in TextEdit and inherits E21b. Do not replace the wrapper with a bare TextEdit
cell_pictures 64 MiB/4096-entry texture cache, floating_pictures size cap GridCraft image work Keep; verify texture IDs/uploads under 0.37
macOS AppKit exit workaround in apps/gridcraft/src/main.rs on_exit GridCraft #117 Keep; re-test on macOS; the 0.37 AccessKit adapter may change the teardown
fmv-macos-events, muda pins macOS file-open/menu Keep; winit remains 0.30.13

Relevant fork patches by provenance:

  • E2+E3 (9dd799ab): surface fit within max_texture_dimension_2d and returned WgpuError::ConfigureSurface/CreateRenderer; provenance is PdfCraft's vendored egui-wgpu (pdfcraft#609, #617, #1042), now in the shared fork. Required by GridCraft #761 and by the existing graphics.rs fallback/retry logic. Without it, a too-large window or a GL surface timeout can panic before the app exists and the fallback never runs.
  • E4 (66bacf23) is only the regular-clipboard half: it initializes arboard, leaves smithay: None and routes text through winit; the candidate keeps arboard for PRIMARY. The selected completion is winit W15P: SCTK 0.19.2 PrimarySelectionManagerState on the existing connection/event queue, with per-seat PRIMARY device/source/offer objects and no second regular wl_data_device. E4's get_primary_text/set_primary_text route through W15P in the separate P2b stage. GridCraft's own arboard handle is used for HTML (wayland-data-control); plain-text and PRIMARY routing must follow the single-path design in 6.4, with real compositor evidence.
  • E5/E8/E12/E13/E14/E15/E16 (epaint/egui): bidi shaping/order, TextEdit clipping, grapheme caret/delete, fallback alignment, line breaking, visual cursor. GridCraft draws CJK/Thai/Hebrew/ Arabic workbook text and uses an app-managed in-cell editor built on TextEdit; preserve and test.
  • E7/E19/E10 (keyboard layout): letter keys from Latin layout/physical code. E19 needs winit W25 (KeyEventExtLatinLayout). GridCraft's keytips and shortcuts must keep working on Russian/Arabic/Hebrew layouts and web keyboard events.
  • E17/E18/E20 (language): Context::set_language, UiBuilder::language, accessible language, widget text language. GridCraft's UiState.language should drive these; map PtBr to pt-BR rather than the current short code() string, and do not duplicate RTL processing in app text.
  • E21 (a70470b0): ImeEvent::DeleteSurroundingBytes and exact-byte deletion. GridCraft's cell editor undo transaction must remain one step; pair with W26/W27.
  • E21b/P7b (new prerequisite, no candidate commit yet): bounded outgoing IME surrounding text in IMEOutput, produced by the built-in TextEdit and sent by egui-winit through W27 set_ime_surrounding_text on Wayland. GridCraft's acceptance is the complete shared E21b/W27 contract, not a payload restatement: the owner/context generation is present even when text is Unavailable; an available-empty document is distinct and sent as an explicit set_surrounding_text("", 0, 0) plus commit; an owner/unavailable transition clears caches and commits the disable/enable boundary before the new owner; the final completed pass, including an authoritative None, replaces the old ime.or(self.ime) accumulation; a current-epoch done serial mismatch applies incoming edits once and defers outgoing state until a matching serial, while an obsolete epoch invalidates the batch; the new <=4,000-byte payload extraction must not allocate a full-document clone (existing TextEdit change-detection/undo clones are baseline, not this requirement). Preedit is excluded and cursor/anchor are valid UTF-8 byte offsets; password input never supplies text. GridCraft's formula bar (formula_bar.rs:321-323) and text-box/in-cell editors (text_box.rs:151) use built-in TextEdit, so they inherit the producer; they still need focus, deletion+commit, deletion-only, preedit, reversed-selection, emoji/combining/RTL, empty-vs-unavailable, final-pass None, both serial cases, owner generation, committed clear and two-surface tests. Custom painters that synthesize IMEOutput would need their own model adapter; GridCraft currently has none, and must not set the new field to None as a port.
  • E1 (ViewportHooks): not used by GridCraft (single viewport); do not add a hook.
  • E6 (request_focus during IME): relevant to the in-cell editor; test.
  • E9 (Style::right_to_left): GridCraft's sheet mirroring is app-level; do not turn on global RTL UI style. It would mirror the ribbon/dialogs incorrectly.
  • E11 (vello multithreading): not enabled by GridCraft; no action unless a dependency turns it on.

6. Behaviour-specific refactors and tests

6.1 Commands, undo and persistence

The engine and command registry do not change. Required checks:

  • Every UI action still goes through SheetApp::run/Session::execute; no direct model mutation from the 0.37 API changes.
  • home.bold, cell.set, edit.copy/cut/paste, edit.undo/redo, file.save, file.saveAs and file.open produce the same journal and undo labels.
  • A UI click and the control-channel command produce the same one undo step.
  • Programmatic empty params never open a dialog (UiRequest::Dialog only from menu-style invocation).
  • Save → close → reopen a synthetic workbook: values, formulas, number formats, styles, merges, tables, frozen panes, right-to-left flag, print settings and UiState prefs round-trip. Compare sheet.read, document.inspect, and raw XLSX package parts/hashes where stable.
  • Autosave in logic writes exactly once per change after 2 s; saving while hidden does not touch UI state.
  • engine.journal replay still works; no password appears in journal/errors.

Anchors: crates/engine/src/cmd/* (unchanged), crates/ui-egui/src/lib.rs::run_command, tests/autosave_persist.rs, tests/interaction.rs, crates/engine/tests/command_sequence_fuzz.rs.

6.2 Fonts, glyphs and text layout

  • Font definitions: theme::font_definitions_with must still build UI, UI-bold, cell, cell-bold, cell-italic, cell-bold-italic, serif, mono, plus loadable script fallbacks and workbook families.
  • FontData API refactor as in 3.4; keep the Blob cache and drop_unparseable_fonts.
  • 0.37 MissingGlyphPolicy: the production Context default is Tofu; egui_kittest defaults to MissingGlyphPolicy::Panic and check_accessibility = true. GridCraft has 76 Harness::builder/build_ui_state/build_state call sites in 73 test files (plus the snapshot example) and no shared harness module, so the default would make CI depend on installed fonts and on names that do not exist yet. egui_kittest's own default_fonts/monochrome_emoji_fonts dev-dependencies do not propagate into GridCraft, so the harness must enable egui/default_fonts itself or install pinned fonts. G1 must make the suite green without globally disabling the checks:
    • crates/ui-egui/tests/common/mod.rs is the one harness constructor now used by the tests/it binary and keytips.rs. It installs theme::pinned_font_definitions() (egui's bundled faces plus the pinned craft-fonts input, never the host system scan) through HarnessBuilder::with_context_setup, marks the test SheetApp with SheetApp::use_prepared_fonts() so the first logic pass does not replace the pinned set, and leaves check_accessibility = true and MissingGlyphPolicy at Panic. The snapshot example and header_autofit_fonts.rs deliberately keep the production/system-font path where the subject of the test is installed-font behavior.
    • The CI/acceptance font contract is the existing pinned input: set CRAFT_FONTS_DIR to the craft-fonts checkout (and CRAFT_FONTS_REQUIRED=1 for release/acceptance). A minimal image without it runs the deterministic bundled-face harness; the one test that renders every catalog skips Ja/Ko/Zh in that mode and records the gap, rather than relaxing MissingGlyphPolicy or accessibility globally.
    • Complete all accessible names/roles that the existing test suite exercises as part of G1 (the form fields, Name Box/formula bar, Font Size box, tab rename, dialog labels and any other unnamed input the strict harness reports). G3 adds the remaining dialog/grid/popup names and semantic assertions; it does not own "make G1 green".
    • allow_missing_glyphs() or with_accessibility_check(false) is not a suite-wide default. A specific test may opt out only with a recorded reason, an owner-facing follow-up gate and a comment at that call site; it must not be hidden in the shared constructor.
    • tests/symbol_glyphs.rs is the installed-face coverage test and is strict in both modes (below); do not weaken it.
    • Do not change production font policy from Tofu.
  • Fonts come from pinned, OFL-licensed faces in storytold/craft-fonts, never from a copy in this repository: BIZ UDPGothic Regular/Bold (Jpan,Latn), NanumGothic Regular (Hang,Kore), Noto Sans Symbols Regular and Noto Sans Symbols 2 Regular (Zsym). crates/ui-egui/src/craft_fonts_policy.rs is the single policy the build script and the tests share: exact family/style plus manifest script tags, the barred-family filter (Adobe*, Source Han*, Source Sans*, Source Serif*, Noto Sans CJK*, Noto Serif CJK*, with no broad Noto Sans prefix that would reject the permitted Symbols faces), the required-face check and the web allowlist. CRAFT_FONTS_DIR is the only input (no fixture path lives in Cargo configuration), and without it the generated table is empty and the build is unchanged. CRAFT_FONTS_REQUIRED=1 makes a missing checkout, manifest, row, font file or required face a build error.
  • Two symbol modes, each asserted exactly:
    • no-font/default: bundled faces must supply ↘ ◀ ↑ 📋 and the rest of the interface set. ✕, ✓ and ⌃ have no bundled egui face, so chrome paints Icon::Close/Icon::Check and the Control-key tooltip falls back to ^; workbook text still refuses to fake a glyph.
    • extended acceptance (CRAFT_FONTS_DIR, CRAFT_FONTS_REQUIRED=1): the exact union ↘ ✕ ◀ ↑ 📋 ✓ ⌃ in both Proportional and CELL, plus the Korean interface labels, must render; a loaded or required set that lacks a face fails instead of skipping.
  • Native builds embed every non-barred row of fonts/manifest.txt, not only the five required faces: permitted BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian, Noto Sans Hebrew and Noto Sans Thai rows are also present when the pinned checkout carries them. All are OFL/non-barred, so the asset policy holds; the policy and size gate must describe the actual set, not claim five faces.
  • Simplified Chinese is a native-only opt-in: the OFL LXGW WenKai Lite row from craft-fonts' fonts/simplified-chinese-manifest.txt (the face DeckCraft uses). It is outside WEB_ALLOWED and never enters the wasm bundle; CRAFT_FONTS_REQUIRED=1 native builds require the manifest, while ordinary builds tolerate an older checkout without it. System Han faces still lead the chain when present. The native font set (including this face) needs its own raw/compressed size measurement; the current measured figure is the five-file web allowlist, 12,970,840 bytes raw.
  • Web (wasm32) embeds only BIZ UDPGothic Regular/Bold, NanumGothic Regular and the two Symbols faces. Raw font bytes, raw wasm and compressed wasm still need measuring against the consumer's real hosting limit; do not record "within budget" before that measurement.
  • Font rebuild gate: 0.37 applies set_fonts immediately when no text has been laid out this pass (#8602, #8609). GridCraft's one-frame fonts_ready skip remains correct but may now skip a frame unnecessarily. Re-test language switch, workbook-font discovery and installed-font rebind; if the extra frame is observable, gate only when text already exists.
  • 0.37 #8512 system-font discovery/provider: GridCraft has its own fonts::start_scan and theme candidate probing. Do not switch to egui_system_font_provider without measuring startup, preserving workbook font names, and keeping fallback order. This is a blocking comparison, not an automatic adoption.
  • Text shaping: E5/E12/E13/E16 change RTL rows, caret/selection and grapheme deletion. Add tests for mixed LTR/Arabic/Hebrew in a cell, visual order, caret at the visual end, Delete/Backspace over a combining cluster, and formula-bar caret. Use installed fallback faces and skip only when the face is absent, as theme::fallback_covers_its_script already does.
  • E14 fallback alignment and E15 line breaking: the defaults are intended to preserve the old rules, but compare wrapped cells, CJK/Indic fallback rows and vertical alignment against the baseline. A changed fallback baseline must be explained, not accepted as a new golden.
  • Screenshot/font fixtures: record the exact font files and hashes used for every visual evidence run. Do not commit font files or screenshots of Microsoft software.

6.3 Accessibility and input

0.37 makes unnamed input widgets a kittest failure (#8589) and improves popup/menu/panel trees (#8581, #8607, #8608, #8612). GridCraft has many custom-painted controls and several TextEdits without labels. The names and roles exercised by the existing harnesses land in G1 with the shared strict harness; the broader coverage below lands in G3. Required work:

  • crates/ui-egui/src/text_box.rs's text-box editor uses the translated Text Box accessible name in every catalog; the i18n unit test checks it alongside the Name Box, Cell Editor, Font Size and Sheet Name names.
  • crates/ui-egui/src/dialogs.rs::form_fields: wrap each label + field in ui.label_inputs_by(label.id, |ui| …) or call .labelled_by(label.id) on the field. Do this for text, password and number fields.
  • crates/ui-egui/src/formula_bar.rs: give the Name Box and formula bar explicit accessible_name/labelled_by; keep their existing Ids.
  • crates/ui-egui/src/ribbon.rs::size_combo: the TextEdit has no label; add .accessible_name("Font Size") (translated) and keep control_keytip.
  • crates/ui-egui/src/tabs.rs: the tab rename TextEdit gets the sheet name as its accessible name.
  • crates/ui-egui/src/widgets.rs: icon-only controls already use on_hover_text, which 0.37 maps to a description/name; verify with unnamed_widgets() rather than assuming.
  • crates/ui-egui/src/grid.rs: scrollbars, freeze handles, header dividers and fill-handle areas are custom interactions. Give each a role/name only where it is a real control (scrollbar Role::ScrollBar; other clickable overlays Role::Button with a name), and keep decorative overlays out of the tree. Do not invent Role::Unknown nodes for every painted rect.
  • Popup/menu roles: 0.37 exposes menu items/submenus; ensure context menus, filter menus, fill menu and cell context menu have correct names and that hidden popups disappear from the tree.
  • Focus/keyboard: Tab/Shift+Tab, Enter/Space, Escape and arrow keys must trigger the same commands as pointer clicks; focus must not move when language/theme changes or a pane is hidden.
  • IME: preedit, commit, cancel and replacement produce one edit/undo step. Test E21 incoming delete-surrounding and the complete E21b contract (section 5.3) in the formula bar and text_box.rs: owner/context generation even when unavailable, available-empty vs unavailable, committed cache clear/disable-enable on owner transition, final-pass None authoritative, current-epoch serial mismatch applying incoming edits once while outgoing state is deferred, and obsolete epochs invalidating the batch; plus deletion-only batches, delete+commit, preedit exclusion, reversed selection, emoji/combining/RTL byte indices, focus loss/password clearing and two windows. Native IME behavior (including a Wayland Vietnamese/predictive trace) still needs real Windows/macOS/Linux evidence; E21 alone is not surrounding-text acceptance.

Accessibility acceptance:

  • Harness::unnamed_widgets() is empty for the main window and each reviewed dialog.
  • Named inputs expose role, value, selected/checked, disabled and focus states.
  • A deliberate mutation (remove one name) makes the check fail; revert it before landing.
  • Screen-reader native smoke test on Windows/Linux (Orca/AT-SPI) if available; otherwise record the gap.

6.4 Clipboard, PRIMARY and files

  • One routing design for two clipboard owners. GridCraft's Services owns an arboard handle for HTML and plain text (apps/gridcraft/src/main.rs:150-163; wayland-data-control on Linux/FreeBSD), while egui/egui-winit owns the text clipboard used by TextEdit copy/paste. After E4/W15P both must not compete for the same Wayland selection:
    • Grid copy/cut keeps using the one arboard data-control handle, because winit's selected clipboard API has no multi-MIME HTML offer; it publishes HTML+plain text. TextEdit copy/paste and PRIMARY go through egui/egui-winit (and W15P for PRIMARY). GridCraft must not create a second arboard handle or a second wl_data_device for the plain-text path.
    • Paste reads HTML first through arboard, then the current plain text (arboard or the egui clipboard), with one defined owner order and no stale re-read. A grid copy followed by a TextEdit paste, and a TextEdit copy followed by a grid paste, must both work on Wayland.
    • Create the arboard handle lazily and keep it owned by Services for the app's lifetime; record which protocol each path uses, and prove that an HTML paste from a browser and a plain-text paste from another app each work after the other path has taken ownership.
    • Password fields never publish text or PRIMARY; no unchanged-selection reclaim; canceled/slow transfers must not leave a half-published owner.
  • PRIMARY (W15P): #8428 makes Label/TextEdit selections publish PRIMARY and middle-click paste into a TextEdit. The contract is the winit bounded-transfer one: a fresh triggering serial from either a pointer press/release or keyboard input on the focused seat (never a keyboard-only serial rule and never an arbitrary first seat); synchronous ownership claim on that input turn while only later serving/reads are asynchronous; bounded async reads with a 2 s total deadline, 16 MiB cap, at most 4 read workers, 2 writer threads and queue depth 8; a live owner-specific completion waker; canceled or stale results discarded; password fields never publish. Test: select formula text → middle-click in the in-cell editor at a position → text is inserted there, one undo step; pointer and keyboard triggers, focus movement, owner/seat changes, empty/absent offers, self-paste, trickle/oversized/canceled transfers and event-loop responsiveness; non-PRIMARY platforms unaffected. Native gates are wlroots/Hyprland, KWin and Mutter, not one compositor only.
  • Event::PasteImage and Event::MiddleClickPaste are new. GridCraft deliberately ignores both in the grid: there is no image-clipboard pipeline or cell-picture insert-from-clipboard command, and inserting at an unchosen anchor/size would be a new feature. A clipboard with a text representation still arrives as Event::Paste; PRIMARY middle-click paste belongs to egui's TextEdit. The match arms are explicit and commented, so a future image-paste feature must choose behavior rather than inherit a silent no-op.
  • File dialogs (rfd), drag-and-drop and fmv-macos-events are native workflows; kittest cannot prove them. Use the Windows clone and a macOS session with real evidence. The web picker uses the async inbox; test cancel/retry with two dialogs so an inbox cannot cross-deliver.

6.5 Viewports, GPU and screenshots

  • One root viewport only. No show_viewport_deferred, no child-window title/focus/close tests.
  • ViewportCommand::Screenshot is the only viewport command affected by the callback change; the other commands (InnerSize, Focus, Close, CancelClose, Title, StartDrag, Maximized) stay.
  • GPU: preserve cell_pictures/floating_pictures texture caches. 0.37's texture manager and max_texture_side behavior must not change image sizes or leak textures. Test texture IDs, upload deltas, eviction at 64 MiB, images larger than max_texture_side, and drop on workbook close.
  • E2/E3: verify the Windows fallback still triggers on a failed renderer/surface. The fork's surface_fit test is a dependency test; GridCraft adds a visible large-window/resize workflow on the owned Windows clone where the adapter limit permits it.
  • 0.37 color emoji changes texture sampling. Keep TextureOptions semantics for cell pictures and icons (nearest/linear, wrap mode); do not accept a clamp-only optimisation as a replacement for Repeat/MirroredRepeat.
  • An issued screenshot command requests a real paint of a hidden window: the candidate marks ActionRequested::Screenshot (and RequestPaintWhileHidden) as wants_paint, and eframe treats that as visible for painting. But issuance is UI-only, so a hidden window must first be made to run ui: while a request is queued or settling with a capturable size, logic sends ViewportCommand::RequestPaintWhileHidden; only then does issue_screenshots emit the paint-requesting Screenshot command, after which no further hidden paint request is needed. A hidden or occluded window that still reports its current content size can complete this way; a minimized window has no capturable size, so it sends none and its request stays queued until restore and otherwise reports the 8 s timeout. Do not reuse an old rect for a minimized window: issuance and drain fail closed on the fresh geometry. Do not assert a hidden/occluded screenshot timeout. Test the real path: issue ui.screenshot while hidden/occluded and assert one image/reply; then minimize, issue another request, restore, and assert it completes (or, without a restore, assert the documented timeout). Use ui.focus only when focus itself is part of the workflow. A timeout covers "no frame was presented" (event loop stalled/closing) or "no capturable size", not "window hidden". Update the existing error text if it tells users to make the window visible. Synthetic input also runs only in a UI pass and follows the one-unapplied-step rule in section 3.1.
  • Capture identity stays strict across resize/DPI/zoom/visibility/occlusion. Keeping zoom and the occlusion flags in the identity is intentional even when the expected physical size is unchanged: a bounded retry (two attempts) followed by an explicit error is preferable to combining pixels from different layout generations. This bounded failure is accepted behaviour, not a relaxation target.
  • Native qualification for a real hidden/occluded capture and a minimized/restore request remains required. This plan describes the intended behaviour; no native hidden/minimize/restore run has been performed for this source.

6.6 Printing: keep the existing synchronous construction, measure it

SheetApp::submit_print runs file.print synchronously (lib.rs:595-611), and the engine builds the whole PDF in build_pdf before returning bytes (crates/engine/src/cmd/print.rs:1600-1621). This is existing baseline behavior, not a 0.37 regression: opening Print settings is lazy (a menu file.print opens the backstage when a native printer is configured), while the Print button and ui.printSubmit block the UI while the PDF is generated and handed to the worker. The migration does not move PDF generation to a background engine unless new behavior requires it or a predeclared budget says so. Required gate:

  • Measure the three phases separately on the same workbook: settings-open latency (no PDF), PDF generation (file.print/build_pdf) and submission (printing.begin/worker hand-off), plus the frame that contains them. Use the 100k-row fixture and a representative print area.
  • Define the candidate acceptance as no regression against the recorded baseline within 9.3's thresholds; a regression is fixed in the print path or explicitly budgeted and reviewed.
  • Test a hidden/minimized print submission and feedback polling once through logic; print discovery/worker completion must not depend on a painted ui.
  • If a future requirement moves generation off-thread, it needs its own command/undo/worker ownership design, cancellation and a regression test; it is not part of this migration.

7. Native, web and platform workflows

7.1 Native Linux

  • Build/test via task.py run --kind build on a fresh allocation; record source SHA, lock hash, feature set, rustc, font package versions and evidence path.
  • Native measurement is staged, not assumed: (1) managed build/test and offscreen/kittest render, (2) provisioned X11 with a named window manager or Wayland with a named compositor, (3) interactive workflow with real input. No DISPLAY/WAYLAND_DISPLAY is inherited on this server, and the host's i915//dev/dri/renderD128 access is availability evidence only, not a qualified display or GPU backend. Record the chosen backend and driver per row.
  • In the provisioned session run gridcraft --sample sales --control 7979, drive the control channel, take native screenshots, exercise wheel/trackpad, Ctrl+wheel zoom, clipboard/PRIMARY, IME (if installed), file picker cancel and window resize. Record compositor and scale factor.
  • FreeBSD: run the existing FreeBSD workflow/row; verify the Linux/FreeBSD arboard wayland-data-control cfg and winit configuration do not change the build or clipboard behavior.
  • Software renderer (llvmpipe) and real GPU are separate rows; neither is accepted until measured with identified adapter/driver.

7.2 Windows

  • Allocate one uniquely named craft-windows clone; use its own SSH/VNC ports. Check allocator capacity first: a full copy needs its reservation, and an overlay is allowed only with an adequate budget. If neither fits, the row stays blocked and no existing VM is stopped or moved. Build the exact task checkout with a separate CARGO_TARGET_DIR and run cargo test --workspace (or the repository's build/test script for the checkout) through guest-exec.py, submitting each guest command with task.py run --kind vm under the clone's task ID.
  • Use the interactive scheduled-task launcher for the GUI; verify the app is on the active console with Explorer. Drive --sample sales --control 7979, edit/save/reopen, open a password-protected fixture, use the printer settings (discovery may have no printer), resize/minimize, and capture screenshots.
  • Measure renderer construction separately from first frame because of #8736 (FXC DX12 shader compile). GridCraft forces FXC, so this is on its default path. Time a fresh-process egui_wgpu::Renderer::new probe (or the wgpu_core=trace bracket between create_pipeline_layout and create_render_pipeline) separately from the first presented frame; record both.
  • The baseline VM is software-rendered (Microsoft Basic Render Driver); report it as such, not as a physical-GPU result. Keep the Windows ARM64 packaging workflow in the matrix; it is not a substitute for a native x64 GUI/IME run.

7.3 macOS

  • Build/test on a real macOS host with the macOS CI gate; cross-compilation is not acceptance.
  • Cover AppKit native menu, fmv-macos-events double-click/Open With, close/quit with unsaved work, on_exit workaround, Command shortcuts, IME (Korean/Japanese/Chinese), trackpad wheel/momentum, Retina scale 1x/2x and Metal/wgpu. This environment is not available on the server; record it as a rollout blocker until a real run exists.

7.4 Web

  • Staged browser evidence: (1) wasm compile in CI, (2) a pinned headless browser run where a pinned Chromium/Firefox is provisioned, (3) interactive WebGPU/WebGL2 with recorded browser and adapter identity. No host browser is inherited at planning time; an unavailable runtime is an explicit blocker, not a compile-only pass.
  • cd apps/gridcraft-web && trunk build --release and trunk serve --release; test in at least Chromium and Firefox with WebGPU where available and ?webgl forcing WebGL2.
  • Workflows: ?sample=sales, edit/save/download/reopen, browser file picker cancel/retry, dropped file, paste HTML/plain text, language switch (including CJK tofu behavior), interface scale, resize, and keyboard layout (E10).
  • cargo xtask wasm only proves compilation. Browser behavior, IME and clipboard require a real browser run; record versions and screenshots/logs.
  • Record raw and compressed .wasm size; the 0.37 family may change code size.

7.5 CI and commands

Baseline and candidate commands (all new jobs use maintenance/task.py run/status/finish, a unique --task, and a fresh --job-id). Example for the repository gate:

python3 /home/codex/work/maintenance/task.py run \
  --job-id gridcraft-egui037-ci-01 --kind build --task gridcraft-egui037 \
  --source /home/codex/work/CraftApps/egui037-migration-20261022/gridcraft \
  --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/gridcraft \
  -- bash -c 'cargo xtask ci'

Inside that managed job (never as a direct host command), the candidate checks are:

cargo xtask ci
cargo test --workspace --locked
cargo test -p gridcraft-ui-egui --all-targets
cargo check -p gridcraft-web --target wasm32-unknown-unknown --locked
cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png
cargo tree -i egui
cargo tree -i winit --target all
cargo metadata --format-version 1 --locked

cargo xtask ci already runs fmt, clippy -D warnings, workspace tests, assets, layers, wasm, parity and keytips. Add a dependency-identity check to CI (a small script or xtask step) that fails if any egui-family package or winit comes from a second source/revision, or if a branch/path patch exists. Test the check with a deliberately wrong pin.

8. User-observable regression assertions

8.1 Geometry and hit testing

Use Geo::new, grid.rect, grid.cells_rect, screen_cell_rect and ui.inspect:

  • 1200×800 and 640×420, ppp 1.0 and 2.0, light/dark, en/es/ja/zh, RTL on/off.
  • Click the centre of A1/B2 and drag C3:F7; selection matches; frozen panes and split panes keep headers fixed; scrollbar thumbs map to the same scroll.
  • Ribbon tab/height, formula bar, status bar, tabs and central grid boundaries stay within 0.5 pt of the baseline where the style pin is intended to preserve them.
  • Right-to-left sheet mirrors columns and hit tests; outline buttons and ruler drags keep sides.
  • Interface scale 75/100/200% keeps ctx.zoom_factor() and geometry consistent.

8.2 Fixtures

Fixtures are built in code and never committed as large binaries:

  • Session::new() + file.new {sample: "sales"|"budget"|"grades"} for the three samples.
  • A 100,000-row × 6-column workbook built through range.setValues in 10,000-row chunks, plus formulas G=B*C+D, a chained running sum, SUM/SUMIFS/XLOOKUP and a sort, mirroring crates/engine/examples/perf.rs.
  • A two-sheet workbook with a formula being pointed across sheets, a right-to-left sheet, frozen panes, merged cells, a table, a chart, comments/notes, validation and a cell picture.
  • Password-protected and corrupt-font fixtures through gridcraft-xlsx's test-support feature and a temporary file; no password is written to logs or evidence.
  • 128 small generated PNGs for the picture cache, plus one image larger than max_texture_side.
  • Pinned UI text in en/es/ja/zh (and Greek/Cyrillic/Hebrew where a face exists) so snapshots and accessibility names are deterministic.

Record the fixture builder's source hash and the exact parameters in every evidence run.

8.3 Screenshots and pixel probes

  • Produce before/after PNGs with examples/snapshot at 1440×900, scale 1 and 2, light/dark, Home/Insert/View, sales/budget/grades, formula autocomplete (GRIDCRAFT_LOCALE_SCREENSHOT), table headings (GRIDCRAFT_TABLE_HEADINGS_SCREENSHOT) and representative dialogs.
  • Store them in durable evidence, not as unreviewed repo assets. Record font files/hashes, OS, renderer, DPI and viewport.
  • Add a small kittest render test with fixed harness size/ppp/theme that probes known pixels: window background, ribbon fill, selected cell fill, grid line, header fill, selection outline, formula bar background. Use exact RGB for flat tokens and a small tolerance for antialiased edges. The test must fail if a probe is moved by 3 px or a token colour changes; verify with a deliberate mutation, then revert.
  • Do not bulk-refresh any golden. Review every changed image and explain it (style pin, font, renderer, or a real regression).

8.4 Accessibility

  • unnamed_widgets() empty for main window, Backstage, Find/Replace, Format Cells, Sort, Data Validation, print settings and Paste Special.
  • Query roles/names/values/selected/disabled/focus for: ribbon buttons, tab list, sheet tabs, name box, formula bar, font size box, dark-mode checkbox, dialog fields, OK/Cancel, filter menu.
  • Keyboard: Tab order reaches every input; Enter/Space activate the same command; Escape closes the right popup; focus survives language/theme change and pane show/hide.
  • A deliberate missing name or wrong role makes the assertion fail.

8.5 Input, IME and text

  • Event::Text, Key, PointerButton, MouseWheel (with MouseWheelSource::Unknown, Wheel, Trackpad, Momentum), touch pan and pinch zoom.
  • Wheel zoom ladder (grid::wheel_zoom_step) at 100/121/99/400%, fine-grained trackpad fractions, Ctrl held vs released, momentum phase; no latch from a missing TouchPhase::End.
  • In-cell editor: type, Enter/Tab/arrow, Escape, multiline Alt+Enter, click-away commit, formula point mode, F4, autocomplete; one undo step per commit.
  • Regression for #8763: select text in the formula bar, then right-click → Copy (or click a custom toolbar action that reads the text selection); the action must see the selection that existed when the click started (or the app must restore it). The final P8 fork's P7c fixes (selected #8763/#8748) must satisfy this case. Only add a local workaround for a proven app-specific difference, with a test and removal condition; PR #8764 itself is open/unmerged.
  • Bidi/grapheme: Arabic/Hebrew mixed cells, combining marks, emoji clusters, selection and caret.
  • IME: preedit/commit/cancel/replacement in the formula bar and in-cell editor produce one undo step; E21 delete-surrounding and the complete E21b contract are checked together; a deletion-only batch, delete+commit, current-epoch serial mismatch vs obsolete epoch, owner-generation change, empty-vs-unavailable, committed clear, final-pass None, password clearing and two windows are separate cases.
  • Clipboard/PRIMARY as in 6.4.

8.6 Persistence and lifecycle

  • Edit → save → close → reopen; compare values/formulas/styles/merges/tables/freeze/RTL/print and UiState/Session.prefs.
  • Undo/redo after reopen? (Undo history is not persisted; assert it is empty as today.)
  • Hidden/minimized autosave; close with unsaved work asks; app.quit does not ask; printing busy cancels close.
  • Control ui.screenshot and ui.inspect across resize/minimize/focus; a hidden/occluded queued screenshot reaches issuance through RequestPaintWhileHidden, then completes from the issued command's own paint request when a current size is reported (no artificial timeout); a minimized request stays queued until restore and otherwise reports the 8 s timeout.
  • Window title updates on document rename/dirty change, including while hidden.

9. Performance measurement and gates

9.1 Baseline before production change

Record the baseline at b8771d51fb4da60bd48c13cf313d6d6014dc5e66 with the current lock and the reviewed craft-libs base 3832c47; the earlier ac8da7d4/d27646a5 pair stays as historical evidence only. No candidate numbers may be reported without a paired current baseline. Capture exact SHA, lockfile hash, features, fonts, fixture, OS, renderer/driver, DPI, viewport, profile, rustc, hardware and load. Use paired runs, alternate order, 10 warmups and at least 30 timed iterations for steady operations, 20 fresh processes for startup, and at least 1,000 frames for frame distributions. Report raw samples and the paired median with a confidence interval for 30-iteration operations; a 30-sample p95 is the 29th observation and is not a tail estimate, so do not use it as an acceptance threshold. Use p95 from an expanded tail run (at least 1,000 samples) and p99 only from at least 1,000 frames; report the sample count with every percentile. The first baseline report and every investigated regression include an equivalent stock-0.37 control, measured in the same harness alongside 0.36 and the fork. Stock 0.37 is a measurement control, never a production stage; where an operation cannot compile unchanged on stock, use a pinned minimal adapter and record the equivalence limit. Also record peak RSS, texture uploads/allocation counts where practical, and idle CPU/repaint count. Never report compile duration as UI performance.

9.2 Scenes

Scene Operation Measurement
Startup fresh process → first painted frame / control channel ready wall time, split font setup, wgpu renderer creation, first frame
Grid scroll sales/100k-row synthetic workbook, 1,000 frames of vertical/horizontal scroll frame distribution, grid_ms, tessellation time, vertices/indices, uploads
Cell edit type/commit 1,000 cells via control channel command latency, frame CPU, undo memory
Recalc crates/engine/examples/perf on the same workbook engine-only latency, recalc cell count (not a UI number)
Dialog/ribbon open/close Format Cells, switch tabs, type filter CPU frame time, paint counts
Text tessellation fixed 120-ASCII-label scene + mixed color-glyph row per-tessellate time/instructions; reproduce #8731
Fonts 20 cold starts with the same font set discovery/build/set_fonts time, peak RSS
Memory 100k-row workbook + 128 pictures peak RSS, texture bytes, cache evictions
Idle visible and hidden, 10 s logic-tick and painted-frame counts, CPU, no busy loop
Print settings open, file.print PDF generation, submission three separate latencies plus the containing frame; 100k-row fixture
Windows startup renderer construction vs first frame #8736, FXC DX12, software WARP separately
Web release wasm load + first frame + 1,000 scroll frames raw/compressed wasm, browser frame timing

A benchmark harness should be added before the migration, not after:

  • crates/ui-egui/examples/frame_bench.rs: build a deterministic SheetApp with a synthetic workbook, call logic/ui in ctx.run_ui, then time ctx.tessellate separately; emit JSON samples and vertex/index counts. No GPU required.
  • apps/gridcraft opt-in GRIDCRAFT_FRAME_LOG=<path>: a small logger called from both SheetApp::logic and SheetApp::ui appends one JSON line per invocation with an explicit phase ("logic" or "ui"), timestamp, perf.frame_ms/grid_ms where available, viewport size, scale and visible() as data (never as a phase inference); the app adds eframe::Frame::info().cpu_usage when it has a Frame. Hidden logic-only ticks therefore have samples, and a separate 10 s idle probe records process CPU and tick counts. No behavior change when unset.
  • egui_kittest wgpu render loop for offscreen GPU frame time and texture upload deltas; record adapter info from a small wgpu::Instance probe.

9.3 Thresholds and noise policy

Freeze these before candidate runs (stricter repo budgets win):

  • Investigate a repeatable >5% median regression (paired CI above the noise floor excludes noise) in any scene, or >10% p95 only where p95 comes from the expanded tail run; >5% peak-RSS increase; any new idle busy-loop or printing underrun; any loss of an existing absolute budget. A 30-sample p95 is reported but never used as a gate.
  • For sub-millisecond measurements, require the relative increase to also exceed 0.05 ms or corroborate with allocation/instruction counts.
  • Text tessellation: compare 0.36.2, stock 0.37.0 and the fork on the same scene. If #8731 reproduces (expected from the upstream report), the row-level color-glyph fast path must land in the fork before GridCraft accepts the migration; do not accept a 60% text-tessellation regression as “expected 0.37 cost”.
  • Windows startup: measure renderer construction separately. If #8736's reported cost reproduces as a repeatable >5% total startup regression, evaluate the fork shader fix with equivalent wrap arithmetic; clamp-only is not valid. The upstream ~40 ms figure is a report, not our measurement.
  • Reject noisy comparisons as inconclusive and rerun; never auto-widen a threshold or tolerance after seeing a failure. Document any pre-measurement exception and its review.

10. Publication stages, dependency DAG and rollback

10.1 Stages

Stage Content Depends on Rollback
P0 This plan; evidence notes none delete plan branch
S1 Shared craft-libs CL-1 for the eleven craft-ui consumers: port only 8614e4a API hunks + 1764bca README onto 4919088, final approved egui/winit pins (blocked until those refs exist), registry compatibility, release SHA P0 review, final refs selected revert CL-1; keep old d27646a5
S2 Complete reviewed/published storytold egui P8 (E1–E21 + E21b, P2b async W15P routing, P7 held-key pairing, P7b/E21b, P7c selected #8763/#8748 fixes, justified perf fixes) and completed winit (W17–W27 + W15P + W27 lifecycle), immutable approved SHAs. No stock 0.37 or intermediate stage P0 review, fork review, final refs selected and published revert fork pins; keep 0.36.2/0.36 craft set
CL-2 craft-libs provisional capture-slot API in crates/ui/src/capture.rs; cancellation/out-of-order/shutdown stress; lazy-print tests P0 review revert helper with its adapters
CL-2F Shared capture API freeze for the eleven craft-ui consumers after reviewing the DesignCraft and GridCraft CL-2 pilot adapters CL-2, G1 (Grid adapter), DesignCraft adapter keep CL-2 revision; no app change
G0 Baseline harnesses on ac8da7d4: frame bench, phase-tagged frame log, 0.36-compatible geometry/screenshot tests, benchmark evidence (0.37-only assertions arrive in G1/G3) P0 remove harnesses; no production behavior change
G1 Atomic dependency/compile migration: root patches, manifests (native eframe clipboard), lock, craft-ui/craft-print bump, CL-2 slot adapter, E21b/W15P consumption, Role/ID/FontData/screenshot-callback source changes including drain_screenshots, timeout and the basic reply, strict shared harness with required names/fonts, style pin S1+S2+CL-2, G0 baseline revert G1; restore baseline lock/manifests
G2 Lifecycle: autosave/title/printing in logic, adversarial screenshot byte-budget/discard/timeout/epoch tests, hidden synthetic-input paint G1 revert G2; G1 still builds
G3 Remaining accessibility names/roles, pixel probes, focus/ID tests, broader IME/RTL coverage G1 (G2 if same files) revert G3
G4 Platform/performance acceptance (staged): Linux X11/Wayland, Windows VM, browser, macOS; print and CL-2 evidence; fix findings G2+G3+CL-2F revert G4 fixes; retain evidence
G5 Docs: architecture, ui-parity, control protocol notes, release notes G4 revert docs

Implementation does not start until P0 has a fresh independent adversarial review, the complete S1/S2 revisions are fixed and published, and CL-2's provisional API exists. The final production storytold SHAs for egui P8/winit are unknown until that approved implementation; their absence blocks S2/G1 rather than being filled with a candidate SHA or a stock/intermediate stage. Each app PR is reviewable on its own; do not land an intermediate dependency bump that leaves main unbuildable. A consumer already carrying an incompatible craft-ui bump must put that bump in the same atomic dependency stage as the egui P8 landing. G1 is the only atomic “must compile and pass CI” PR: it includes the shared strict harness, every accessible name and pinned font needed by the existing suite (no suite-wide allow_missing_glyphs/with_accessibility_check(false)), and the CL-2 adapter. G2/G3 can be separate commits/PRs once G1 is green. CL-2F freezes the shared capture API only after both initial adapters are reviewed. G4 is validation plus targeted fixes, not a feature grab; rerun the affected performance and visual checks after any fix.

10.2 Compatibility pin and lockfile checks

Before each publication:

  • cargo metadata --locked and cargo tree show one egui-family source/revision and one winit.
  • Root [patch.crates-io] entries use full SHAs; no branch/path.
  • craft-ui and craft-print resolve to one approved craft-libs SHA that contains CL-1, CL-2's provisional-or-frozen capture slot and any CL-3 contract fixes GridCraft consumes. CL-1 and CL-2F are published for the eleven sharing consumers; GridCraft/DesignCraft use the CL-2 provisional pilot and their adapters precede CL-2F. Do not add a GridCraft-local capture channel/token map while the shared slot is the selected design, and do not leave a mixed craft-ui/egui stage.
  • The lockfile has no second egui/eframe version and no stale accesskit_consumer duplicates except ones proven necessary by an unrelated dependency.
  • cargo check --locked in a clean checkout works; CI's dependency check fails on a missing patch.
  • docs/ version statements that mention 0.36 are updated only after validation; do not rewrite historical citations.

10.3 Rollback

Rollback restores the entire coherent set: Cargo.toml, all manifests, Cargo.lock, root patches, source adaptations, craft-ui/craft-print revision and any test harness changes. Do not “roll back” by changing only the egui version string. Keep the old lockfile, the 0.36.2 registry crates and craft-libs d27646a5 reachable. No persistence format changes are planned; ui.json, prefs.json, XLSX and control JSON remain compatible. If a persistence change becomes necessary, specify backward compatibility and preserve unreadable data.

11. Original planning risks and remaining acceptance criteria

Current disposition (2026-10-24): exact canonical revisions and the coherent capture API in items 1–2 are selected as recorded at the top of this plan. The representative strict harness checks in item 9 passed in final full CI07. Earlier candidate/PR states below are planning history, not current pin blockers. Native/platform/IME, screen-reader and performance acceptance remain separate; the final qualification update records what actually passed.

  1. Final production fork revisions. aiongg/egui/aiongg/winit are review candidates, not production sources. The exact immutable storytold egui P8 (including P2b async W15P routing, P7 held-key pairing, P7b/E21b, P7c's selected #8763/#8748 fixes and justified perf fixes) and completed winit (W15P + W27 lifecycle) SHAs are not selected at planning time. Pinning is blocked until the approved implementation publishes them; record the full SHAs in 1.1 and verify --locked before S2/G1. No stock 0.37 or intermediate stage is accepted. Without E2/E3, #761's crash/fallback behavior is not qualified; without E4/W15P and E19/E21/E21b, Wayland clipboard, PRIMARY, non-Latin and IME behavior regress.
  2. craft-libs CL-1 and CL-2 exact revisions. PR 199's tree is stale; CL-1 is the hunk port onto 4919088, not a rebase. GridCraft cannot bump craft-ui/craft-print until CL-1 and CL-2's provisional capture slot exist with clean tests and a coherent lock. CL-1 and CL-2F are the shared gate for the eleven craft-ui consumers; GridCraft/DesignCraft are the CL-2 pilot and their adapters are reviewed before CL-2F freezes the API. Any incompatible existing craft-ui bump must land in the same atomic dependency stage.
  3. PRIMARY and HTML coexistence. W15P must land in the approved winit revision, and the 6.4 routing must be proven on wlroots/KWin/Mutter: grid HTML+plain-text copy through the one arboard data-control handle, TextEdit copy/paste and PRIMARY through egui-winit/W15P, no second arboard handle or wl_data_device, password exclusion and owner handoff intact. Native evidence is required.
  4. E21b outgoing IME text. The final canonical fork includes the shared producer and Wayland adapter; the original candidate lacked them. GridCraft-specific acceptance still requires the complete shared contract: owner generation present even when unavailable, available-empty distinct and explicitly sent as an empty payload, cache clear plus committed disable/enable on owner/unavailable transition, final completed pass including None authoritative, a current-epoch serial mismatch applying incoming edits once and deferring outgoing state until a match while obsolete epochs invalidate, and no full-document allocation for the new bounded payload. Formula-bar/in-cell surrounding text, W27 delivery, deletion+commit, preedit exclusion, focus/password clearing and two-window isolation remain gates; E21 alone is not enough.
  5. #8763/#8748 TextEdit selection and hidden-child regressions. 0.37 collapses a focused TextEdit's selection before the clicked widget runs. The final egui P8's P7c owns the selected #8763/#8748 fixes and regression coverage; GridCraft verifies its formula-bar/context-action paths under section 8.5. A local workaround requires a demonstrated app-specific need and removal condition; PR #8764 itself is open/unmerged.
  6. #8731 text tessellation. Stock 0.37 is reported ~60% slower for plain text; stock is the measurement control. If reproduced, the fork needs the justified row-level fast path in P8 before GridCraft accepts. GridCraft renders a large text grid, so this is a release blocker, not a note.
  7. #8736 DX12 shader compile. FXC is GridCraft's default. Measure renderer construction and first frame; decide on the fork shader fix without weakening wrap modes. The upstream figure is a report, not our measurement.
  8. #8512 system fonts. Do not adopt the new provider without a measured startup/coverage comparison; GridCraft's per-workbook font binding and script fallback must remain.
  9. Strict harness names/fonts. G1 must pass egui_kittest's Panic/accessibility defaults using pinned fonts and complete accessible names, not a global opt-out. If a specific fixture cannot be pinned, record the gap and the per-test exception; do not make CI depend on host fonts.
  10. Staged platform evidence. Native Linux needs a provisioned X11/Wayland display and compositor; Windows needs an owned clone that fits the allocator budget and remains software-rendered until a qualified GPU run; browser needs a pinned runtime; macOS and physical Windows GPU/audio need real hosts. Host /dev/dri/renderD128 access is not display/GPU acceptance. These stay explicit rollout blockers until recorded.
  11. AccessKit tree changes. Role/tree/name changes may alter native screen-reader output. kittest assertions are necessary but not sufficient; native AT-SPI/UIA/VoiceOver smoke tests are required where available.
  12. Print scope. PDF construction remains synchronous on the UI path as it is today. A measured candidate regression is fixed or explicitly budgeted; moving generation to a background engine is a separate, designed change.
  13. No generic compatibility layer. craft-ui owns the capture slot because multiple apps need the same completion lifecycle. GridCraft keeps its timeout, paths, tokens, JSON and animation policy and does not add a local channel alternative.

Acceptance for G1 is: cargo xtask ci passes at the exact approved P8/winit revisions with actual test counts and the strict shared harness (no global glyph/accessibility opt-out); cargo check --locked passes for native and wasm; one egui/winit source; E21b/W15P/CL-2 APIs are consumed; drain_screenshots, timeout and the basic screenshot reply work, including a hidden/occluded queued capture admitted through RequestPaintWhileHidden (a minimized request waits for restore or times out); the historical compile errors are gone; and the G1 smoke tests (open/edit/undo/save/reopen, screenshot, language switch, resize) pass. Acceptance for the migration as a whole adds G2–G4 evidence, the visual/geometry/accessibility checks, paired performance results against the recorded baseline, print and capture measurements, and no unresolved blocking unknown above.

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/gridcraft main for this migration is b8771d51fb4da60bd48c13cf313d6d6014dc5e66 (the checkout in /home/codex/work/CraftApps/egui037-migration-20261022/gridcraft-implementation). The earlier bdf8b8cc… and ac8da7d4… SHAs and line anchors above are retained as historical review evidence; use b8771d51fb4da60bd48c13cf313d6d6014dc5e66 and its exact lockfile for current-main integration and paired measurements. Shared craft-libs integration now pins canonical 25da1314; 3832c47 is its historical integration base. The original refs remain reachable. These unpublished immutable pins still require an approved publication and clean remote-fetch verification.

Add Ctrl+8 outline symbols/headings combinations to geometry and hit tests; consume reveal_from once; include Auto Fill Options, accounting fill and Ctrl+click hyperlink regressions. Pin fonts when using the now host-font-dependent snapshot example.

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.

Explicit accessibility qualification update — 2026-10-24

Full CI07 is successful evidence for the final formatted runtime source, including the seven representative integration tests that explicitly call check_accessibility() on rendered ribbon/locale/pane/menu/dialog/editor/backstage states. The real SheetApp capture Harness also passed checks before issuance and after manual callback drain/repaint, preserving callback count, request token, one-reply and released-reservation assertions. The CI06 unnamed controls were repaired with visible localized label relationships or precise semantic names; roles and behavior assertions were preserved. The four source-file differences from the fresh accessible-name review are formatter-only. Full CI07 passed all ten stages with 2,249 passed, 0 failed and the existing release timing experiment ignored. CI05 remains historical pre-repair evidence; CI06 remains a failed historical run.

This is callback and representative UI-tree coverage, not GPU/native hidden-window, native screen-reader or every-state accessibility acceptance. The canonical egui fork includes outgoing IME surrounding-text support and its Wayland adapter; application-specific formula-bar, in-cell and text-box owner transitions, stale deletion/commit handling and native composition remain separate qualification gates. Native open/edit/undo/save/reopen, language/resize, clipboard/PRIMARY, hidden/minimize/restore/readback and browser/macOS/Windows workflows remain open, as do paired performance, renderer/print and font/WASM size acceptance. Required CI success permits local commit review; it does not authorize a push, tag or PR publication. Every publication still needs fresh explicit human approval, followed by clean remote-fetch verification of the exact pins.

Revision: 2026-10-24 — bound CI07, review union and final canonical source; no production changes.