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

1338 lines
98 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `ViewportId`s, 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:
```rust
// 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:
```text
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 `Id`s.
- `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:
```sh
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:
```sh
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.