Files
lightcraft/docs/egui-037-migration-plan.md
T

124 KiB
Raw Blame History

LightCraft: egui 0.37 migration plan

Current reconciliation (2026-10-25): this tree incorporates main 31ec62cd0387abecd8f2a2da034aee591447897d. Managed CI egui037-light-main31-ci-20261025-0829 passed all eight stages, including strict lint, HEIF and WASM checks: 5,086 passing test executions, zero failures and 42 ignored across 89 summaries. The prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus 137, models 8, fonts 2, GPU 1, system 1); these are coverage gaps. Independent reconciliation review found no source blocker; its stale-status and pending-CI findings are addressed by this record. Native/browser GUI, platform, packaging/licence coverage and paired performance checks remain open. Human review and human merge are required; publication needs separate approval.

Historical landing status before this reconciliation, 2026-10-25: the migration integrates main 38f8d2508700fa4938295e865d3fc21be07266ba and uses egui 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc and craft-ui 25da1314d443171b4adeaad2cd9454b273f35c6f. Cargo.lock SHA-256 is 6336b4aa2ac9df57cb8feaafb097b30fa7228ef427f5e213fac1a2a7875e4181; the independent craft-docpath pin remains selected in the lockfile. Release workflows use craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83.

Fresh static review verified preservation of the captured main and the final dependency graph. The final managed CI run egui037-lightcraft-library-frame-ci-20261025-0551 passed all eight stages. Including its focused library tests, the log records 5,083 passing test executions, zero failures and 42 ignored across 142 summaries. The CI prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus137, models8, fonts2, GPU1, system1); these are coverage gaps. Earlier passing counts below qualify their historical snapshots only. The icon-font attribution URL now identifies the selected egui source; font bytes are unchanged. Visible GUI, browser/platform runtime, native IME, final packages and paired performance remain separate acceptance gates. Human review and human merge are required.

The review packet separately records inherited default-font notice coverage and Inter licence staging gaps. Nix font-pin drift is fixed as described below; the other inherited gaps are not established as fixed by the migration or by source-level notice routing checks.

Current status note (2026-10-25): flake.nix now points at craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83, matching CI, the release workflow and the standalone packaging helpers, and flake.lock was regenerated by Nix 2.26.3 using nix flake update craft-fonts against the canonical GitHub revision. Only the craft-fonts node changed; nixpkgs stayed pinned. Linux x86_64 flake evaluation passed with nix flake check --no-build --no-write-lock-file (job egui037-lightcraft-nix-eval-20261025-0495). No Nix package build, executed formatting derivation, or other architecture is claimed. The inherited gaps above (egui default-font licence coverage for Hack, Ubuntu-Light and NotoEmoji-Regular; the Inter OFL packaging omission) are not migration-introduced, and this landing updates the craft-fonts notices and attribution text only.

Font notice and Inter staging follow-up (2026-10-25): the inherited gaps above are now addressed at source level only. NOTICE reproduces the pinned upstream texts verbatim for Hack Regular (MIT and the Bitstream Vera License), Ubuntu Light (Ubuntu Font Licence 1.0) and Noto Emoji Regular (SIL OFL 1.1), next to the existing egui-icons/emoji-icon-font block; assets/ATTRIBUTION.md gains rows for epaint_default_fonts::HACK_REGULAR, UBUNTU_LIGHT and NOTO_EMOJI_REGULAR; packaging/env.sh copy_font_licences now always stages assets/fonts/OFL-Inter.txt as OFL-Inter.txt before the optional craft-fonts OFLs (Linux, macOS app and CLI, FreeBSD and web use it), and the Flatpak from-source manifest installs it directly. The release Flatpak bundle repackages the Linux tarball and so inherits its documents; Windows and Nix already staged Inter. Managed staging checks verified the three upstream licence texts byte-for-byte in NOTICE, copied Inter with and without an optional craft-fonts input, and confirmed that a missing Inter licence makes staging fail. These checks exercise the shared document-copy helper; actual release artifacts (deb/rpm/tar, macOS app and CLI, FreeBSD, web zip, Flatpak, MSI/portable zip, Nix) and their installed notice contents remain unverified.

Library-check results now remain visible for the frame in which they arrive, so a zero-delay refresh cannot immediately replace a fresh missing-library answer. The scheduling fence uses the root UI frame number across all layout passes; focused regressions cover the single-pass and forced-multipass cases with isolated temporary directories. Fresh static review accepted the delivery-frame behavior.

The original planning and earlier qualification record follows. Its pins, hashes, paths and passing counts are historical.

Current implementation status — 2026-10-24

The implementation is now pinned to egui 173cc7375d35dc5fbc5b5d0994fdf787f547c5d1, winit ae48c756a3b81125eb796c0cc6dccf60990d1181, craft-libs c488caa9fbc2fb1a86e4674887e9b80b598ebbfa, craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83 and craft-i18n e5bfe19b95d30f50f250985bdc3aeec47c05af09. Cargo-generated lock SHA-256: 2b6c3550997fd4962df7865ec038cc409604aafaaf2267fa416ad9d98df91da9. These are exact local reviewed revisions; public fetchability still requires the separately approved foundation publication. Earlier planning and source-only validation statements below are historical and are superseded by this status.

The mandatory cargo xtask ci passed all eight stages (fmt, strict clippy, HEIF, tests, parity, layers, assets and WASM). Its log reports 4,747 passing test invocations, zero failures and 40 ignored tests. The CI prerequisite report separately lists 149 checks that returned without exercising their checks: corpus 137, models 8, fonts 2, GPU 1 and system 1. These are coverage gaps, not qualified behavior. Canonical source identities were resolved through local mirrors; no public remote-fetch acceptance is claimed.

Fresh review and regression coverage include capture-parent symlink retargeting: completion re-resolves the lexical parent and refuses a changed destination before encoding or writing. The two standalone packaging helpers now default to the same craft-fonts pin as release CI; explicit font-directory overrides and existing checkouts retain their behavior.

The current-main integration includes 4ae3089420cb57d4c839e51a735d4f48271cbf17 and preserves its catalog hardening and other changes. Independent review caught a bad-chain memoization error: a descendant visited before a broken ancestor could be detached unnecessarily. The repair caches only proven valid chains and removes the offending parent edge, preserving valid descendants. Regressions cover child-before-cycle, child-before-missing-parent and a valid 513-folder chain through actual catalog snapshot/reopen paths. All nine focused catalog repair tests passed before the full eight-stage CI run on the merged source. The only formatting change after the source review wraps one let-chain condition; the lockfile remains unchanged.

Actual visible UI, native platform/IME/PRIMARY, browser behavior, final release-package notices and paired performance acceptance remain open. This application branch has not been published.

Historical plan and review revisions

Status: Revision R3.2 — R3.1 plus the 2026-10-24 implementation-status amendments: control-gesture delivery no longer re-checks the state snapshot after the gesture's own event has applied (freshness is still checked before resolution and immediately before injection), every dropdown now publishes a semantic accessible name, and the craft-libs reference moved to the local 3832c47… reconciliation base. The R3.1 concrete implementation notes for control-gesture delivery, screenshot path identity, ui.scroll units/phases, the hidden-paint predicate and disclosure Expanded state remain in force. R3 replaced R2 (SHA-256 385e46055494cc24b58e41ee08982c876eb423df314b0abffcdc28ac81cf9d24) after the independent review in plan-reviews/lightcraft-v2.md. Every review finding is resolved inline in the section that owns it; §13 records each disposition, including the parts rejected. No production code, manifest, lockfile, test, asset or build was changed and no test, build, benchmark or GUI run was performed for R3: every source statement below is static inspection of the pinned trees. No executed-validation, GPU-pass or browser-pass claim was made at that planning stage.

No push, pull request, release, tag, comment or other publication is authorized. Every future push or new pull request needs explicit human approval of the exact content and the validation results first.

This plan covers only storytold/lightcraft at 23e181283cb448205d865e8cd288b56afe987986 (branch codex/egui-037-plan-20261022 in the assigned checkout). It does not migrate the egui fork, winit, craft-libs or a sibling app; those are separate plans, separate review units and separate publication gates.

The root AGENTS.md/CLAUDE.md and the nested docs/user/AGENTS.md/CLAUDE.md were read. This plan changes no user documentation, asset, contributor metadata or build artefact; the only file it adds is this plan.

The historical diagnostics in /home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/lightcraft.errors and lightcraft.log are clues from an earlier checkout. They are not current source, not a build of this checkout and not acceptance evidence. They show 17 library errors and 26 library-test errors plus 243 warnings from the fork trial; the source audit below found additional semantic changes (panel IDs, capture correlation, dropped-file replay) that a compiler diagnostic cannot show.

1. Exact baseline and provenance

Item Revision or value
LightCraft source baseline 23e181283cb448205d865e8cd288b56afe987986 (Release 0.13.0)
LightCraft lockfile Cargo.lock, format version 4
LightCraft Rust baseline workspace rust-version = "1.90"; lightcraft-ui-egui declares 1.95; inspection toolchain rustc 1.99.0
Current egui family egui, epaint, ecolor, emath, eframe, egui-wgpu, egui-winit, egui_glow, epaint_default_fonts, egui_kittest all 0.36.2
Current winit 0.30.13 from crates.io, checksum a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d
Current accessibility/test stack accesskit 0.24.1, accesskit_consumer 0.35/0.36/0.38, kittest 0.4.0, egui_kittest 0.36.2
craft-libs reconciliation base 3832c47ef68a0e6214f2c1e85f3526b6adcb686e
craft-libs historical inspection 49190884278f16c9a9f5823d7698a8ffb5deb062; PR 199 8614e4a + README 1764bca
Target upstream release egui 0.37.0, peeled commit 841a328ba2dd8f6996beb7189ebb7fc00e668a2a; the annotated tag object SHA is not the commit SHA
Comparison-only egui tree aiongg/egui craft/0.37 at a4f9a17f64a6bae61350fb85804c9df0d9865916 (E1–E21)
Comparison-only winit tree aiongg/winit craft/0.30.13-lang at 8b35388bd719e8cc86a95971ba0bc026c9a8f058; storytold base ffae5198770d7730e718043f035a772b7dbc3b1b
craft-libs stale trees PR 199 head b68de8c5f4f1c060904d211a7b1320aa0f7bce78 and audit merge ec8840af8c5fda695cf195cdd05e5ae078415c07
Language work craft-libs issue/PR 198 is a separate proposal, not a merged dependency and not included here

Pin policy for this consumer. a4f9a17f (egui) and 8b35388b (winit) are comparison and inspection references only. LightCraft must pin the final reviewed storytold egui and winit revisions by immutable full SHA once the fork and winit series are published. Those revisions do not exist yet, so this plan states no SHA for them and does not guess one. Every stage that needs them is explicitly blocked (§9, §10). LightCraft's production dependency is the complete reviewed and published storytold egui P8 revision: E1–E21 plus P2b (E4 regular/PRIMARY with bounded asynchronous W15P transfer completion), P7 held-key release pairing, P7b/E21b outgoing IME surrounding text, and P7c (the selected #8763/#8748 fixes and justified performance fixes). Its companion is the complete published storytold winit revision with W15P and the W27 IME lifecycle. No stock 0.37 or intermediate fork stage is a production dependency; stock 0.37 is only the measurement control. A published revision missing any of these is not acceptable for the stages named below.

2. Dependency and entry-point inventory

All manifests in the checkout were inspected:

  • root Cargo.toml ([workspace], [workspace.dependencies], [workspace.lints], profiles);
  • apps/lightcraft/Cargo.toml, apps/lightcraft-cli/Cargo.toml, apps/lightcraft-web/Cargo.toml;
  • all 29 crates/*/Cargo.toml and xtask/Cargo.toml.

There is one workspace, one lockfile, no nested workspace, no Cargo vendor/ directory and no [patch] section today. crates/raw/src/vendor/ is a set of in-tree raw-decoder modules (Sony, Nikon, Canon, Fujifilm, …), not a Cargo vendor directory and not egui-related; it is preserved and out of scope. There are no vendored egui/winit/egui-wgpu copies. .cargo/config.toml only defines the xtask alias. egui_extras is declared in the workspace but unused and absent from the lock graph; do not add it to production just to patch it. egui_plot, egui_dock and egui_inspection are unused. craft-ui is the only shared UI dependency; craft-docpath is not egui-related.

Target egui entry point Notes
lightcraft (native) apps/lightcraft/src/main.rs:60 impl eframe::App; logic calls LightCraftApp::logic, ui calls LightCraftApp::ui eframe features default_fonts, wgpu, glow, accesskit, wayland, x11 today; native menu, GPU probe/fallback, control server (MAX_CONNECTIONS = 16, MAX_LINE = 4 MiB)
lightcraft-web apps/lightcraft-web/src/web.rs:652 impl eframe::App for WebApp; start at :726 eframe default_fonts, glow; browser-loaded Chinese face; workers; OPFS/IndexedDB; command() → LightcraftApp::run, no control server
lightcraft-cli apps/lightcraft-cli/src/main.rs: snapshot at :767, render, mcp headless Headless/HeadlessView; no eframe
lightcraft-ui-egui crates/ui-egui/src/lib.rs:1077 LightCraftApp::logic, :1441 LightCraftApp::ui; headless.rs:133 HeadlessView::run, headless.rs:311 Headless::step the UI crate and its integration tests
crates/ui-egui/tests accessibility.rs, docking_workflows.rs, shutdown.rs kittest harnesses and AccessKit assertions
examples drag_latency, grid_bench, open_timeline, thumb_request_bench existing UI/perf harnesses

Platform-specific declarations that must stay coherent with the selected winit/egui:

  • apps/lightcraft has direct winit = "0.30" with x11 only on cfg(all(unix, not(target_os = "macos")));
  • apps/lightcraft has rfd only on non-wasm; zbus only on Linux; muda, objc2, objc2-app-kit, objc2-foundation only on macOS;
  • apps/lightcraft-web puts eframe/egui/wasm-bindgen/web-sys under cfg(target_arch = "wasm32");
  • lightcraft-ui-egui has rust-version = "1.95" and dev-dependency egui_kittest with wgpu and snapshot.

2.1 Feature policy (explicit, per root)

egui 0.37 has no default default_fonts (egui/Cargo.toml: default_fonts off by default), and 0.37 moved the monochrome emoji faces out of default_fonts into monochrome_emoji_fonts (epaint/src/text/font_definitions.rs:127-183 inserts NotoEmoji-Regular/emoji-icon-font only under that cfg). eframe 0.37's default feature set includes clipboard, and its egui-winit dependency is default-features = false, so an app that turns eframe defaults off must add clipboard itself (eframe/Cargo.toml:142-144; egui-winit/Cargo.toml:44-46, clipboard.rs).

Root Features after migration Why
workspace egui default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] every resolution root needs the bundled faces: CLI snapshot, headless harnesses and unit tests never see eframe's features. Monochrome emoji are needed because user keywords/filenames carry emoji (tests_keywording.rs:558-583 uses 🙂) and 0.36's default_fonts supplied them. The workspace feature is what supplies LightCraft roots; egui_kittest's own dev-dependency enables them only for upstream kittest's tests and does not propagate
lightcraft (native) eframe: default_fonts, clipboard, wgpu, glow, accesskit, wayland, x11; egui: workspace monochrome_emoji_fonts clipboard restores the real OS clipboard; without it egui-winit simulates an in-process clipboard and cut/copy/paste, RequestPaste and the E4/W15P Wayland path are unavailable
lightcraft-web eframe: default_fonts, glow; egui: workspace monochrome_emoji_fonts browser clipboard is not the egui-winit clipboard feature; the browser path is qualified separately
lightcraft-cli, lightcraft-ui-egui inherit the workspace egui features headless rendering and kittest must resolve the same faces

monochrome_emoji_fonts adds roughly 1 MB to the binary (its own doc comment); the size impact is a measured deliverable for both native and WASM (§7.2). Adding both faces must not weaken MissingGlyphPolicy: production stays Tofu, kittest keeps its Panic default.

kittest does not supply these faces. Its normal [dependencies] egui uses the workspace entry with default-features = false; only egui_kittest's own [dev-dependencies] enables default_fonts/monochrome_emoji_fonts (egui_kittest/Cargo.toml:39-42,65-68), and a dependency's dev-dependencies do not propagate to consumers. LightCraft's workspace/root feature selection is therefore what gives every CLI, headless and kittest root its faces; verify each produced graph with cargo tree -e features, as §5.3 requires.

Current source/test gates (source-declared, not executed here): thousands of #[test] annotations; cargo xtask ci runs fmt, clippy --workspace --all-targets -D warnings, the HEIF feature check, cargo test --workspace, GPU probe/backends, parity, layers, assets and the wasm check (xtask/src/main.rs:295-366). CI matrix files also cover FreeBSD (with craft-fonts), Nix, Windows ARM64, packaging and release. assets/ATTRIBUTION.md matches every image, font, icon and media path; migration screenshots stay in task evidence rather than the repository unless separately approved and attributed.

3. What actually changes in 0.37 (and what does not)

The app already receives a root egui::Ui through App::ui and Context::run_ui on 0.36.2, and App::logic/App::ui already exist in 0.36.2. Do not describe that split or root-Ui pass-through as introduced by 0.37, and do not mechanically rewrite update into ui where no update exists. eframe 0.36.2 already calls app.logic(ui.ctx()) before app.ui(ui) in each pass and calls run_logic when hidden; the 0.37 source at a4f9a17f has the same shape (see §4.1). The real incompatibilities and semantic risks are these. In the table, egui/src/... means crates/egui/src/... in the egui checkout, and egui_kittest/src/... means crates/egui_kittest/src/...; LightCraft paths remain repository-relative.

Change Effect in LightCraft Source anchor
WidgetType replaced by accesskit::Role (egui::Role), alias deprecated WidgetInfo constructors and tests must use roles; CollapsingHeader becomes DisclosureTriangle, SelectableLabel becomes Button, Checkbox becomes CheckBox egui/src/lib.rs 0.37 role alias/table; PR #8555
Id::new deprecated in favor of Id::unique; AsIdSalt/IdSalt are explicit 221 egui::Id::new occurrences on 218 lines plus three bare Id::new calls (menu_level.rs:19, canvas_keys.rs:15,20, imported with use egui::Id) and 8 Ui::id() calls; the remaining *Id::new spellings are LayerId/FontId/TextureId, unrelated. Id::new and Id::unique hash identically, so persisted egui state does not reset egui/src/id.rs; egui/src/ui.rs:363-381; PRs #8534/#8586/#8542
Panel constructors take impl AsIdSalt; panel ID is parent_ui.scope_id().with_salt(self.id_salt), and Panel::new stores IdSalt::new(id_salt) This is a semantic change from 0.36's exact Id. Code that removes/reads PanelState must use the same parent-salted key, computed from the original salt source. Passing an already-built IdSalt to Panel::left/right hashes it again (IdSalt::new(IdSalt) is a distinct salt — the id_salt.rs unit test debug_format_nested_salt proves nesting is not identity) egui/src/containers/panel.rs:284-300, 974-975; egui/src/id_salt.rs; PR #8592
default_fonts is no longer an egui/epaint default; monochrome emoji moved behind monochrome_emoji_fonts The CLI, headless harnesses and any graph without eframe's features lose the bundled faces and the monochrome emoji; FontDefinitions::default() becomes empty and the font-count test changes meaning egui/Cargo.toml; epaint/src/text/font_definitions.rs:127-183; PR #8583; §2.1
eframe clipboard is a separate feature Turning eframe defaults off silently selects egui-winit's simulated clipboard eframe/Cargo.toml:142-144,168-170; egui-winit/src/clipboard.rs
ViewportCommand::Screenshot takes a one-shot ScreenshotCallback; UserData/Event::Screenshot are gone issue_screenshots/collect_screenshots move to the CL-2F completion slot; the callback runs on the renderer thread, is Send + 'static, and must not touch app state egui/src/data/screenshot_callback.rs; viewport.rs; PR #8489
Event::MouseWheel gains source: MouseWheelSource Every initializer in control.rs, tests, examples and headless helpers needs a source; use Unknown for the control channel egui/src/data/input/event.rs:171-195; mouse_wheel_source.rs
scroll_area::State.offset is private; clamped_offset/unclamped_offset are the API menu_level reads .offset and writes st.offset.y; right reads .offset. Use clamped_offset() for reads and a next-frame scroll target for writes; no private-field workaround egui/src/containers/scroll_area.rs:100-148
Kinetic scrolling/rubber-banding and MouseWheelSource are new Menu/wheel/scroll behavior can differ; ui.scroll must carry a source; tests cover wheel vs trackpad/momentum and overscroll scroll_area.rs 0.37
ComboBox is ComboBox<'a>; Atom::custom takes IdSalt; WidgetAtomResponse::rect takes IdSalt widgets::combo_box needs a named lifetime; arrow_button uses IdSalt::new("arrow") consistently containers/combo_box.rs:45-101; atomics/atom.rs:98; atomics/widget_atom.rs:228-240
Ui::id renamed scope_id; UiBuilder::id renamed scope_id Replace the 8 ui.id() uses; child UIs use scope_id; id_salt remains for local children ui.rs:363-381; ui_builder.rs:59-82
Role/AccessKit tree and strict kittest checks kittest 0.37 panics on an input widget with no accessible name and on a missing glyph; HarnessBuilder defaults are missing_glyph_policy: Panic, check_accessibility: true, and the check runs after every settle and before every snapshot egui_kittest/src/builder.rs:46-60; accessibility.rs:26-42; PRs #8589/#8611
TextEdit click-away selection regression in 0.37 text_field's custom Cut/Copy menu and toolbar actions that read the selection can see a collapsed range; keep the existing memo/store logic and add the #8763 regression upstream #8763, open; PR #8764 open/unmerged
Style defaults are roomier, TextEdit layout changes LightCraft pins spacing/button padding/corner radii in theme::apply_kind, but menus, tooltips, scrollbars and plain dialogs can shift; review geometry/pixels, never auto-refresh PRs #8617/#8445; theme.rs:397-448
PRIMARY selection support New middle-click paste and PRIMARY publishing; verify separately from the normal clipboard, through E4 + W15P PR #8428; E4; #8657
egui_extras/egui_kittest have independent version numbers Do not infer versions from egui; egui_plot is unused crate manifests at 0.37.0

Not a 0.37 migration item: Context::root_ui/run_pass (new in 0.37) are not needed by LightCraft's App::ui, which already receives the root Ui. Never call ctx.root_ui from inside run_ui, App::ui or root_ui; upstream #8535 documents the panic. Use the passed &mut Ui. Likewise, do not claim App::logic is new or replace App::ui with a Context bridge.

4. Concrete source design

4.1 How the hosts actually call the app

Verified statically at a4f9a17f (and unchanged in shape from 0.36.2):

  • Native (eframe/src/native/epi_integration.rs): App::logic and App::ui are both called inside the Context::run_ui closure, so both run once per layout pass (a discard can produce more than one). raw_input_hook is called once per host update, before run_ui (and before run_logic on a hidden logic-only update), not per pass.
  • Web (eframe/src/web/app_runner.rs): the same — logic then ui inside the run_ui closure, per pass.
  • Headless (crates/ui-egui/src/headless.rs:320-325): Headless::step calls HeadlessView::run(raw, |ui| { app.logic(ui.ctx()); app.ui(ui) }), which is ctx.run_ui(raw, …); again per pass.
  • Hidden: the integration calls Context::run_logic, which updates only window/focus state; events, input time and layout remain from the last UI pass.
  • After a completed run_ui, current_pass_index() is 0 (ContextImpl::end_pass takes the output with mem::take; run_dyn restores the count only immediately before the next pass, and run_logic does not restore an old index). During a second pass it is 1, 2, ….
  • ViewportInfo::visible() derives from minimized/occluded and is None when unknown. A hidden window can still run a full UI pass: egui-winit's ActionRequested::Screenshot and PaintWhileHidden both report wants_paint(), and eframe treats that as is_visible for the pass (wgpu_integration.rs:687-704, glow_integration.rs:680-693). In that forced paint visible() still reports Some(false).

Consequences: (a) visible() and fresh egui time are not a way to tell UI work from logic work; (b) current_pass_index() == 0 is a valid known-UI-boundary test for "first pass of this run_ui", and it is also true during run_logic, so a hidden tick is never suppressed by it; (c) a second pass has empty input events (InputState::begin_pass replaces events with new.events), so event-driven code cannot run twice, while time/state-gated code can.

4.2 The tick contract (no TickPhase, no visibility classifier)

R1's TickPhase { First, Later, Hidden } and its time/visibility classification are removed. They had no defined first-call, visible() == None or forced-hidden-paint branch, and their stale-current_pass_index rationale was false. The replacement is a small, explicit split with a monotonic clock and consumed queues:

impl LightcraftApp {
    /// Background/queue work. Called before layout on every host pass (native and web
    /// inside `run_ui`, headless in `Headless::step`) and from `Context::run_logic`
    /// while hidden. It may read `ctx.input(|i| i.viewport())` and `i.raw.focused` only
    /// (those `RawInput` fields are refreshed by `run_logic`; `i.focused` and `i.time`
    /// are not). Never events, dropped files or keys.
    pub fn logic(&mut self, ctx: &egui::Context) {
        i18n::set_language(self.ui.language);
        panels::library_problem::logic(self);
        panels::notices::logic(self);
        if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {
            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
        }
        let now = self.tick_now();                  // monotonic; see 4.3
        self.drain_control(ctx);                    // control_rx.take() + try_recv: consumed once
        self.renderer.poll(ctx, &mut self.session);
        merge::poll(self, ctx);
        import::poll_scan(self, ctx);
        sync::poll(self, ctx);
        sync::poll_run(self, ctx);
        tasks::poll(self, ctx);
        import::tick(self, ctx, now);               // time-gated; second pass is a no-op
        lightroom_import::tick(self, ctx, now);
        recheck::tick(self, now);
        self.slideshow_tick(ctx, now);
        self.auto_import(ctx, now);
        self.retry_sidecars(ctx, now);
        self.scan_devices(ctx, now);
        self.preview_build_status(ctx, now);
        self.save_status(ctx, now);
        self.pump_background_queues(ctx, now);      // faces/denoise/model/focus worker results
        self.collect_captures(ctx);                 // completion slot: take once
        self.issue_captures(ctx, now);              // admit/issue; deadlines use `now`
        self.session.persist_if_dirty();
        if !self.synthetic.is_empty() {
            ctx.request_repaint();                  // visible gesture steps
        }
        if self.needs_paint_while_hidden(ctx) {     // pending gesture, queued capture or settle
            ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
        }
    }

    /// Input and once-per-layout-frame work: the known UI phase.
    fn ui_pass(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        if ctx.current_pass_index() == 0 {
            self.take_dropped_files();              // from the queue filled once by raw_input_hook
            self.pump_ui_input(&ctx);               // the input-reading halves of faces/denoise
            self.update_perf(&ctx);
        }
        self.sync_theme(&ctx);
        self.apply_settings(&ctx);
        if self.fonts_ready {
            shortcuts::handle(self, &ctx);           // fresh events only
        }
        self.check_external_editor(&ctx);
        self.resolve_pending_gestures();             // after this pass's layout, see 4.4/4.5
    }
}

Rules that the implementation and its tests must keep:

  1. One consumer per queue. drain_control takes control_rx out, drains with try_recv, puts it back; replies are sent exactly once. The screenshot slot is taken once. Worker results carry a generation id and are applied once. There is no "run the queue again" path.
  2. current_pass_index() == 0 guards once-per-host-frame work: dropped-file consumption, input-reading halves (pump_ui_input) and perf update, and import::tick's batch commit. A hidden run_logic also reports index 0, so the same gate lets import work advance on a hidden tick. It is not the gate for the other background timers: Lightroom import, slideshow, auto-import, sidecar retry, device scan, recheck, toasts and autosave live in logic behind monotonic deadlines. A wall-clock gate cannot substitute for the pass index: a discarded first pass that takes longer than the throttle would re-run it inside the same host frame. Where an operation needs "how many frames have actually run", use Context::cumulative_frame_nr() (one per run_ui), not the pass index and not cumulative_pass_nr.
  3. Input lives in the UI phase. shortcuts::handle, dropped-file handling and the external-editor focus check move out of logic_inner into ui_pass. A hidden run_logic tick can no longer re-read the last pass's dropped_files (today it would spawn the same import again) and cannot consume a queued key.
  4. Only refreshed window state is safe in logic. run_logic refreshes RawInput::viewports/RawInput::focused only; InputState::focused, InputState::time and events stay from the last pass. Read i.viewport()/i.raw.focused in logic, and i.focused/ i.time only in the UI phase.
  5. No freshness inference from visibility. Some(false) + a running App::ui is a normal forced paint (pending screenshot or RequestPaintWhileHidden); None is normal for headless and RawInput::default(). Visibility must not be used to tell UI work from logic work. It is used deliberately for user-facing pacing: a minimized/occluded (Some(false)) slideshow pauses instead of advancing and requesting full-size slide renders, and resumes with its remaining time on unhide (P2.5 in the post-port review).
  6. First call / first frame. tick_now is initialised at construction and every timer is an Option/deadline comparison that is true on the first tick, so Headless::step at time 0.0 and ctx.run_ui(RawInput::default(), …) (whose input time is prev + predicted_dt, not 0) both run their first logic work. Add explicit tests for both, plus visible() == None and a forced hidden paint (§6.1).
  7. No will_discard prediction. A later widget may request a discard after logic has already run; correctness comes from one-consumer queues and idempotent side effects, not from guessing.

4.3 Hidden-time accounting: one monotonic clock, painting stays egui time

run_logic leaves ctx.input(i.time) frozen, so any timer read from it in logic is stale. Introduce an app clock and use it for everything that must progress while hidden:

/// Seconds since the app was constructed. Monotonic (`web_time::Instant`): never goes
/// backwards, even when the window is restored after hours hidden.
fn tick_now(&self) -> f64;
  • Use tick_now for: import::tick (additionally gated by current_pass_index() == 0 so a discarded pass cannot commit twice), lightroom_import::tick, recheck, slideshow playback (panels/slideshow.rs:157-183; auto-paused while ViewportInfo::visible() == Some(false)), auto-import (lib.rs:1154-1187), sidecar retry (lib.rs:1190-1214), device scan, screen-profile refresh (lib.rs:1316-1322), denoise/faces/focus/model timers, autosave/prefs cadence, capture settle/fallback/timeout deadlines, and toast expiry. Each stores a next deadline and advances it only when now >= deadline, so a second pass in the same host frame is a no-op.
  • Toasts: store the deadline as a monotonic app-clock value; save_status (lib.rs:785-808), preview_build_status (lib.rs:756-781) and panels::toast (panels/mod.rs:80) read tick_now, not ctx.input(i.time). A hidden toast then keeps its remaining time instead of expiring the instant the window is shown.
  • Keep egui time for painting, animation, gestures and widget interaction (all of which only run in the UI phase).
  • The web host's safety::take_notices (apps/lightcraft-web/src/web.rs:654-656) runs in logic and today uses ctx.input(|i| i.time); give it the same monotonic deadline.
  • Headless exposes its simulated time through the same clock (a test hook; the current Headless::time starts at 0.0).

4.4 Duplicate-work inventory (narrowed)

The second pass has empty events, so shortcuts::handle, dropped-file handling and collect_screenshots-style event scans cannot run twice from one gesture. The real duplicate candidates are state/time-gated calls that logic runs on every pass (a monotonic deadline makes the second pass in a host frame a no-op), and one call that is literally duplicated today:

  • panels::faces::pump is called twice in one pass (lib.rs:1139 and :1228);
  • import::tick, lightroom_import::tick, slideshow timers, auto-import, sidecar retry, device scan, recheck and the toast/status updates are time-gated, so a slow second pass could fire them again;
  • WebApp::run_inbox and load_originals drain queues on every call (correct — they are consumed queues), while import_dropped re-reads raw.dropped_files on every call and must not run in a hidden tick or a later pass; it moves to the UI phase and consumes an app queue filled once in raw_input_hook.
  • panels::denoise::pump (denoise.rs:45-52) and panels::faces::pump (faces.rs:705-712) read ctx.input keys/pointer/i.time to pace background work. Split each into pump_background (download/index worker results, monotonic deadlines — safe in logic, including hidden) and pump_ui_input (fresh key/pointer activity and its timestamp — UI phase only). A hidden tick must never pace work from the previous pass's pointer state.
  • WebApp::save stays guarded by its own last_save deadline, now on the monotonic clock.

There is no evidence that event-driven handlers need multi-pass guards, so do not add them.

4.5 Control gestures while hidden (geometry, paint requests, one reply)

Today ui.clickWidget/ui.dragWidget/ui.hoverWidget read app.widgets from the previous pass, ui.pointer maps through the previous app.image_rect, and raw_input_hook injects one pointer event (or one up-to-release key run) per host frame. A hidden request can therefore click stale geometry, and a multi-event gesture can stall after one RequestPaintWhileHidden.

New contract:

  • Geometry is resolved only after a fresh layout. Control requests that need geometry are admitted into a pending_gestures list with their original params and admitted_epoch = layout_epoch; layout_epoch (a monotonic counter, not a hash) is incremented at the end of every completed App::ui pass. A request resolves, using that pass's app.widgets/ app.image_rect, at the end of the first pass where layout_epoch > admitted_epoch. If no UI pass has happened since admission, the request stays pending and the app sends ViewportCommand::RequestPaintWhileHidden (when ctx.input(|i| i.viewport().visible()) != Some(true)) so the next host iteration paints. If the session has never laid out a UI, reply with an explicit "no current UI frame" error at the deadline instead of inventing geometry.
  • One unapplied step survives repeated hidden ticks. raw_input_hook is called before run_ui and before run_logic (epi_integration.rs:338-365,413-430), so a hidden logic-only tick can invoke it. The hook injects at most one queued gesture step and marks it in flight; it must not inject another step until a completed UI pass (tracked by a UI-pass counter incremented at the end of ui_pass, never by visible()) has consumed that step. The step remains in eframe's pending_raw_input across hidden ticks unchanged. This prevents two hidden ticks from batching a press and release into one later pass, and preserves key-press-through-release semantics.
  • Each gesture step keeps painting. While synthetic is non-empty (a drag has many moves; a click is move+down+up) the app sends RequestPaintWhileHidden again after each step is consumed, so hidden gestures complete instead of waiting for the user. The hook still injects only one pointer move or one key sequence per host update.
  • Exactly one reply, after delivery. The reply is deferred until the gesture's final synthetic event has been injected into a UI pass (a pending_gesture.delivered flag set at the end of that pass), or until the monotonic deadline, when it replies with an error. A dropped sender gets no reply; a canceled/closed request is removed before replying. This makes a successful reply mean "the UI saw the gesture", not "events were queued".
  • ui.key, ui.text and ui.scroll need no geometry but do need a UI pass to be consumed (ctx.request_repaint() alone only runs run_logic while hidden), so they follow the same request-paint/one-reply pattern.
  • ui.screenshot is not a gesture, but it has two phases. Once the ViewportCommand::Screenshot is processed, ActionRequested::Screenshot::wants_paint() makes the integration paint a hidden window. A capture that is merely queued or settling in logic has not reached that phase: while hidden it needs RequestPaintWhileHidden from logic (the same predicate as pending gestures) to reach a UI pass that issues the command. Test both the issued-command forced paint and the queued/settling hidden case; do not expect a missing issued-command paint to fail.

Implementation amendment (2026-10-24, before code). The app owns the queue described above:

  • layout_epoch is a u64 incremented once at the end of every completed, nondiscarded App::ui pass (the same place ui_passes advances). PendingGesture records admitted_epoch, an admission-time GestureFrame (view, active photo, library generation, dialog discriminant, left/right/presets/filmstrip switches), a monotonic deadline_ms, the original method and params, its reply sender, and the computed result once resolved. At most 32 may be pending; a 33rd is refused by a normal control error. The deadline is 5 s from admission (GESTURE_TIMEOUT_MS).
  • ui.clickWidget, ui.dragWidget, ui.hoverWidget, ui.pointer, ui.move, ui.click, ui.drag, ui.key, ui.text, ui.scroll, ui.zoom and ui.clipboard are queued. Their existing parsing/event construction moves to one control::gesture_events(&mut app, ctx, method, params) function, called only after a completed layout newer than admission. Widget ids and app.image_rect therefore always come from that fresh pass, never the previous registry. A widget that no longer exists, a missing image rect, an invalid key/unit/phase or a removed text focus answers once with the existing error, after that fresh pass (not at admission).
  • Resolution happens in finish_ui_pass after layout_epoch += 1. Before computing geometry the request's GestureFrame must still equal the current frame. A view, active-document, library, dialog or panel-layout change between admission and resolution cancels the request with one error instead of retargeting the new UI. The per-frame layout_epoch advance that makes the layout fresh is expected and is not itself a cancellation. A request that never sees a completed UI pass (hidden with no paint) answers no completed UI frame at its deadline.
  • Resolution appends the gesture's synthetic events and marks the request resolved. It is not answered yet. The reply is sent when app.synthetic is empty after a completed nondiscarded pass (for a zero-event action such as a clipboard paste request, after the next completed pass), so {"ok": true} means the UI consumed the last event. A hidden run keeps sending RequestPaintWhileHidden while any gesture is unresolved or resolved-but-undelivered; a visible run keeps request_repaint.
  • The queue is fail-closed on timeout: the request is removed, its reply error is sent once, and queued synthetic input is cleared so a late event cannot click after an error. This can discard another queued gesture's still-undelivered events; those requests are answered with their own timeout/cancel error rather than being retargeted. The queue is intentionally not a general scheduler: immediate commands (engine.execute, ui.set, ui.inspect, ui.resize, ui.zoomFactor, screenshot admission) keep their current direct semantics.
  • Limit: the GestureFrame cancellation covers the navigation/modal/panel state that can change the meaning of an automation id. It cannot prove that a widget with the same id kept the same semantic target across an arbitrary catalog refresh inside the same frame; the fresh layout and the explicit view/document snapshot are the contract, not a per-widget identity guarantee.

4.6 Screenshot capture: the shared CL-2F weak completion slot

Anchors: crates/ui-egui/src/lib.rs:909-987 (drain_control, issue_screenshots), :1034-1057 (collect_screenshots), :1502-1525 (PendingShot), control.rs:432-439, headless.rs:133-174 and :310-349.

craft-libs selects one narrow completion-slot helper (crates/ui/src/capture.rs, CL-2) after the DesignCraft and GridCraft adapters, then freezes the public API (CL-2F). LightCraft is one of the craft-ui capture consumers and adopts that helper rather than keeping the local mpsc::Receiver/token design; names and signatures come from the reviewed CL-2F revision, not from this document. Every creative capture consumer adopts the CL-2F completion contract: craft-ui-consumers-exact.json shows eleven apps whose TOML declares craft-ui (LightCraft among them). All eleven require craft-libs CL-1 and the frozen CL-2F API in the same atomic dependency stage as egui/winit; only DesignCraft/GridCraft pilot the provisional CL-2 API, and their adapters land before CL-2F freezes it. DeckCraft, FigCraft and ArtCraft Native use contract-compatible local adapters without adding the crate, and all fourteen capture consumers remain in scope. (The earlier "thirteen craft-ui consumers" wording was wrong; it is not repeated here.)

LightCraft's adapter (app-owned policy, shared completion transport):

  • One record per admitted request, owned by the app: monotonic request id, path, reply sender, headless, admission/deadlines (settle ≥3 painted frames and ≥120 ms; compositor fallback 2 s; hard timeout after admission), frames waited, reserved bytes, and a snapshot of capture-time geometry (content_rect, viewport_pixels, pixels_per_point, UI zoom). "Frames" means host frames (Context::cumulative_frame_nr(), incremented once per run_ui), not passes, so a multipass frame counts once and does not make a screenshot fire early. Take the geometry from the refreshed viewport information (i.viewport().inner_rect, native_pixels_per_point) when present, otherwise from the last laid-out content rect; a hidden window has no new layout, so both are the last known size, and the reserved bytes are revalidated against the returned image.
  • Issue with a callback holding only a Weak reference to the app-owned completion slot; the app owns the strong slot and a live, owner-specific completion waker. The callback receives pixels only: no Context clone, no &mut LightCraftApp, no encoding, file I/O or command execution. On completion it locks the slot, stores the image, unlocks, and only then invokes the waker; it never wakes itself from inside the lock or re-enters the app. A late or canceled callback finds no upgradable slot and is a no-op. The renderer owns the callback until it fires, so this is not a Context reference cycle; Weak lets cancel/close drop the app state and makes a late result harmless. A pending capture keeps request_repaint_after(16 ms), which also runs run_logic on a hidden native window, so the woken poll collects completion while hidden.
  • Completion: take the slot once, release the byte reservation with a checked subtraction, and bind the image to the issuance UI geometry, DPI and zoom epoch. Any change to logical viewport size, surface pixels, pixels_per_point or UI zoom between issue and save cancels and retries (or replies with an error at the deadline) — even if the geometry changes back to the original values before the image arrives. Never combine old pixels with new geometry, and never accept an image merely because its dimensions happen to match. Reject an image larger than the reservation and release its bytes, then encode/write on the UI thread and reply exactly once. Derive the effective scale from the returned image dimensions and the recorded logical viewport (E2 surface fitting can shrink the image); record that with the result. Additive result fields (scale, viewport) are optional and backward compatible.
  • Admission is bounded before issue using the shared capture defaults: at most 4 outstanding requests, at most 128 MiB per image, and at most 256 MiB aggregate reserved in-flight/completed bytes. App-specific caps may be stricter, never looser. Require 0 < width,height <= max_texture_side (16 384 at inspection), compute the reservation with checked width * height * 4 before issuing, and revalidate it after any geometry/DPI/zoom change before issue. Reserve the worst surface the request can produce — the full logical viewport × pixels_per_point × UI zoom × 4 bytes — not the smaller E2-fitted size, and keep completed-but-not-yet-drained images counted until they are taken (in-flight and completed bytes are both checked before issue). The renderer's own readback allocation is accounted separately in peak RSS; a post-callback size check alone does not bound peak memory.
  • Cancel/close/drop/timeout: remove the record first; the late callback's upgrade() fails and the image is dropped with no second reply and no panic. On shutdown all reservations return to zero. Tests assert retained bytes reach zero after drain/cancel.

Implementation amendment (2026-10-24, before code): path identity at write time. Admission pins the destination as a WriteTarget owned by the app record: WriteTargetState::Absent (the directory entry did not exist) or Existing(FileIdentity) (device and inode on Unix; canonical path, length and creation time on Windows, whose volume/file-index metadata is still unstable; canonical/len/mtime elsewhere; taken from symlink_metadata so a link replacement is a different entry). Admission still runs the library OriginalGuard. Immediately before encoding/writing, both the renderer callback completion and the CPU/headless fallback call revalidate_write_target:

  • an Absent target must still be absent; a file created after admission (ordinary file, imported original, hard link or symlink) is refused, never replaced;
  • an Existing target must still be the same directory entry and must pass OriginalGuard again against the current catalog, so a photo imported at that path while the capture was queued is refused;
  • an existing allowed screenshot destination keeps its normal overwrite behavior. An absent destination is written through the new host write_new service (lightcraft_engine::export::write_file_new → safe_file::write_new): it prepares a complete temp file and publishes it with create_new/hard-link semantics, so the final TOCTOU window cannot replace a file that appeared. The bounded temp file is encoded bytes only. An existing destination uses the existing atomic writer after revalidation. Failure to configure write_new for an absent target is an explicit error (fail closed), not a fallback to the destructive writer. Limits: an existing destination can still be swapped by another process in the instant between revalidation and the atomic rename; closing that would require an OS conditional-replace primitive. The lexical parent is now re-resolved before completion and compared with its admitted canonical parent; a retargeted directory symlink is refused. The deterministic tests cover an absent path that becomes an ordinary file, an absent path that becomes a catalogued original through a hard link, an existing original/symlink refused at admission, an existing allowed overwrite, and an existing target replaced by a new entry while queued, and a parent symlink retargeted between admission and completion (neither destination receives a screenshot).
  • Both headless entry points change: HeadlessView::run retains ViewportCommand::Screenshot callbacks and calls callback.complete(Arc::new(self.paint())) after the pass; Headless::step deletes its ViewportCommand::Screenshot(user_data) arm (it no longer compiles in 0.37) and no longer pushes Event::Screenshot. Completion happens exactly once, in the headless host.
  • headless_host remains the CLI/headless flag; WebApp does not set it, and the web path has no control server (§4.11, §8).
  • Boundary: the shared piece is only the completion slot. Request ids, paths, settle delays, deadlines, reply schema and Services.write/png stay LightCraft policy in lightcraft-ui-egui; do not grow a generic screenshot framework in craft-ui for LightCraft.

4.7 Panels, IDs and docking

Anchors: crates/ui-egui/src/panels/mod.rs:52-77 (resizable_side), panels/bottombar.rs:24,371, panels/strip.rs:19, panels/topbar.rs:57, panels/library_problem.rs:169, crates/ui-egui/src/docking.rs:430, shortcuts.rs:339.

resizable_side must pass the original salt source to the panel and derive the persisted key from the same parent scope:

// `Panel::new` stores `IdSalt::new(id)`; `Panel::id` is `parent_ui.scope_id().with_salt(store)`.
// `Id::with(salt)` is exactly `with_salt(IdSalt::new(salt))`, so this is the panel's real key.
let pid = ui.scope_id().with_salt(egui::IdSalt::new(id));
ui.ctx().data_mut(|d| d.remove::<egui::containers::panel::PanelState>(pid));
let panel = if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
// ... show ...
if !ui.ctx().is_being_dragged(pid.with("__resize")) {
    return None;
}

Why this exact form: Panel::left(salt) with an already-built IdSalt would store IdSalt::new(salt) and derive parent.scope_id().with_salt(IdSalt::new(salt)), which is not the pid above. The resize_widget_id is panel_id.with("__resize"), so the drag check matches only when pid is the panel's real key. Do not add a public panel_state_id()-style method to production code: the mapping is a local detail, and a public helper would invite callers to use a key that the panel never stores. Tests derive the key themselves from the same parent_ui.scope_id() and salt, or assert on the returned response rect and the panel:* widget registration.

Scope of the risk, honestly: at this revision resizable_side has no production caller (the shipped side panels are craft-ui DockArea panels and the Panel::top/bottom/right bars). The bug is latent, not user-visible today; fix it anyway, and keep the mutation test so a future caller cannot reintroduce it. The bottombar fixture currently reads PanelState::load(&ctx, Id::new("bottom_bar")) (bottombar.rs:371); under 0.37 the key is ctx.viewport_id().root_ui_id().with_salt(IdSalt::new("bottom_bar")) in that harness (the fixture shows the panel in the root Ui), or read the response rect instead. Audit every Panel::top/bottom/left/right id whose value is persisted or queried; fixed-size bars can keep string salts, but tests must not assume the old exact key.

The first compatibility PR also migrates the old panel state explicitly: 0.36 stored PanelState under the exact Id, 0.37 stores it under the parent-salted key, so old entries are unreachable. Do not try to read them: at start, remove the legacy exact-Id PanelState entries for the known panel salts (top_bar, bottom_bar, tool_strip, library-problem-banner and any resizable_side caller) with data_mut(|d| d.remove::<PanelState>(legacy_id)), document the one-time panel-width/layout reset in the stage notes, and assert in a test that the new keys are used. No user-visible document, workspace JSON, catalog or XMP data is migrated; this is egui memory only.

Second window: it already uses ctx.show_viewport_immediate, and CentralPanel::show takes &mut Ui in both 0.36.2 and 0.37, so this is not a 0.37 rewrite. Verify under 0.37 that embedded and native callbacks both register view:secondWindow, render the active photo and close correctly. #8748 (running an overdue window's UI without painting so deferred children open/close while the root is hidden) is closed upstream but not present in the inspected fork source (no PassMode::Overdue): P7c in the required P8 revision owns the selected fix. The second window while the root is hidden/minimized remains an explicit real-platform test, not an assumed fix. Do not call root_ui from the viewport callback.

Docking stays craft_ui::docking::DockArea. LightCraft is an LTR application and must state that policy explicitly at both call sites (docking.rs:430, shortcuts.rs:339) with the reviewed DockArea::direction(DockDirection::LeftToRight) builder from craft-libs §7.7 (exact name from the reviewed revision). Defaults must not be relied on. Persisted split first/second, axes, ratios, floating rectangles and logical tab order must be unchanged, and painting, clipping, hit testing, AccessKit order and drag insertion must use the same rectangles. UiState.docking format is not migrated; verify save/reopen and ui.dock behavior in tests/docking_workflows.rs, plus that LTR geometry is byte-identical to the current behavior.

4.8 Fonts, language, accessibility

Anchors: crates/ui-egui/src/theme.rs:260-332 (install_fonts_with_chinese, font_definitions_with_chinese), i18n.rs:218-223, text_field.rs:180-235, widgets.rs:1004-1020, tests/accessibility.rs, tests_keywording.rs:558-583.

  • Enable default_fonts and monochrome_emoji_fonts on the workspace egui dependency, and keep eframe defaults off in both apps while adding native clipboard (§2.1). This is a LightCraft root/workspace decision, not inherited from kittest: kittest's normal egui dependency is default-features = false, and only its own private dev-dependencies enable the fonts, which do not propagate. Verify every root (native, CLI/headless, kittest, wasm) resolves them through cargo tree -e features/cargo metadata, not through the lockfile (§5.3); a consumer test root without the workspace features would otherwise see an empty FontDefinitions::default().
  • Tests: assert FontDefinitions::default() contains Hack, Ubuntu-Light, egui-icons, NotoEmoji-Regular and emoji-icon-font (epaint/src/text/font_definitions.rs:128-159; the family key is Ubuntu-Light, not Ubuntu); assert U+1F642 🙂 and U+1F44D 👍 are paintable in the Proportional family and that an emoji keyword/filename renders without tofu; keep the existing font-count relation (theme.rs:700-705 compares against FontDefinitions::default(), so adding the two default emoji faces keeps the relation valid — re-check, do not silently update).
  • Font fixtures for measurement/edge cases come from the egui plan §10.6 pins: craft-fonts 2481a8a324b986a45954e90045ed1f8f2efaaf3c (Arabic/Thai/CJK SC/Japanese, manifest-recorded SHA256 + OFL) and the test-only Hebrew variable font and Noto Color Emoji v2.047 with their recorded SHA256 and licences. Fetch them into the task's fixture directory; copy no font file into this repository and do not add them to assets/ATTRIBUTION.md (nothing new is committed). CRAFT_FONTS_DIR stays the app's font input; a future bundled font belongs in craft-fonts.
  • Keep egui_system_font_provider/system_fonts off unless a separate measured decision adopts the startup/coverage trade-off: the product policy is bundled Inter + craft-fonts and must stay deterministic. #8512's startup cost is a separate workload if that decision is revisited.
  • Production MissingGlyphPolicy stays Tofu; kittest keeps Panic (HarnessBuilder defaults in 0.37 are missing_glyph_policy: Panic, check_accessibility: true). All existing harnesses are therefore strict from the first green compatibility stage: every unnamed input and every missing glyph must be fixed (names via Response::labelled_by/accessible_name/ ui.label_inputs_by, real fallback faces), never by disabling the check globally. A deliberate tofu/unnamed probe opts out locally in that test only.
  • Wire the fork's Context::set_language (context.rs:2629) from i18n::language() on start and when it changes, so the AccessKit root language and bidi runs follow the locale. Command ids, catalog data and user strings stay logical; this is presentation only. The in-process i18n remains the source of truth. Do not import craft-libs #198 or any font asset.
  • Keep TextField's custom menu and selection memo; add the #8763 click-away regression (§6.3).

4.9 Text input, IME, RTL, clipboard and PRIMARY

  • Event::MouseWheel initializers in control.rs, headless.rs, examples and tests get source: egui::MouseWheelSource::Unknown; ui.scroll accepts optional "source": "unknown"|"wheel"|"trackpad"|"momentum", "unit": "point"|"line"|"page" and "phase": "start"|"move"|"end"|"cancel". Omitted values keep the pre-0.37 request meaning: Unknown, Point, Move. Every value is validated before an event is queued; an invalid value is a control error and changes nothing. This lets tests drive the grid/filmstrip MouseWheelUnit::Line path and trackpad/momentum TouchPhase::Start/End cycles, which the point/move-only API could not represent. docs/control-protocol.md documents the three optional keys. Unit tests assert wheel/line/start, trackpad/point/end and the backward-compatible omitted defaults.
  • raw_input_hook keeps the §4.5 one-step-per-UI-pass contract; a logic-only tick must not drain another step. Event::PasteImage and MiddleClickPaste are not app shortcuts — keymap.rs's recorder ignores them.
  • E21b/P7b is a new blocking prerequisite, not existing E21 behaviour. Candidate egui-winit never calls WindowExtWayland::set_ime_surrounding_text, and egui::IMEOutput carries no logical text. The complete shared contract is in egui plan P7b and ime-surrounding-gap.md; LightCraft must take all of it, not a weaker subset:
    • owner/context-generation identity is present even when surrounding text is Unavailable. An available empty document is distinct from unavailable and is sent explicitly as an empty payload, not omitted;
    • changing owner or available/unavailable support clears the cached text and commits the protocol's disable/enable boundary (not merely empty text); same-owner ordinary updates preserve composition; the old preedit is canceled, not inserted into the new editor; there is no cross-window bleed;
    • the final completed pass's descriptor, including an authoritative None, is what PlatformOutput::append uses (replace ime.or(self.ime)); descriptor, owner and payload are one atomic value;
    • focus epochs and protocol serials are tracked separately: a current-epoch done with a mismatched serial applies incoming edits once and defers outgoing state until a matching serial; an obsolete owner/epoch invalidates its pending batch. Never drop valid incoming edits;
    • the payload is bounded (≤4000 UTF-8 bytes, valid boundaries, preedit excluded, byte cursor/anchor, explicit unavailable when a selection cannot fit) and the new payload path allocates no full-document clone. Existing TextEdit change-detection/undo clones are a separately measured baseline, not rewritten here;
    • built-in TextEdit produces the payload after delete/commit/cursor updates (logical model text, preedit removed, byte indices remapped) and the Wayland adapter sends it through W27. LightCraft has no custom IMEOutput producer: its TextField wraps the built-in TextEdit and other panels use TextEdit directly, so it inherits the producer and must test the inherited behaviour. The family's custom editors (FilmCraft panels/graphics.rs and the others in ime-surrounding-gap.md) need real logical-model/preedit/UTF-8-index adapters before family acceptance; None placeholders are not a completed port.
  • IME tests: outgoing surrounding text immediately after deletion-only and delete+commit batches; preedit excluded; reversed selection; supplementary/combining/RTL text; cursor movement; a very long line and an over-limit selection (explicit unavailable, not a truncated claim); available empty vs unavailable; field and two-window switches; owner transition with committed disable/enable; password/no-IME clears; focus loss/reset/late serial; a current-epoch serial mismatch applying incoming edits once while outgoing waits, and a stale-epoch batch discarded; multi-pass final output including authoritative None. One native Wayland Vietnamese/predictive trace checks the outgoing protocol plus editor text and one undo transaction.
  • Bidi: a mixed Arabic/Hebrew + Latin filename and keyword stays logical in catalog/XMP while painted text/caret/selection follow visual order, with no double bidi and no tofu when the craft-fonts face is present. The UI stays LTR; do not enable Style::right_to_left.
  • Clipboard: native clipboard feature (§2.1), normal cut/copy/paste through ui.clipboard, the text-field menu, and PRIMARY middle-click. E4 and W15P are one compatibility unit: the inspected E4 uses an arboard fallback and does not provide the required winit-owned per-seat PRIMARY path. Require the complete P8/W15P revision: PRIMARY has its own protocol object on the existing connection/event queue (SCTK 0.19.2 PrimarySelectionManagerState/device/source/offer; never a second regular wl_data_device), and accepts a fresh trigger serial from either a pointer press or keyboard focus on the focused seat — do not impose a keyboard-only rule. Selection claims are synchronous while the trigger serial is fresh; serving and transfer are asynchronous. P2b replaces blocking reads/writes on the egui event path with caller-owned bounded async transfer handles: a total 2 s deadline (not a per-WouldBlock poll timeout), a 16 MiB byte cap, at most four read workers and two writer workers with queue depth 8, an owner-specific live completion waker, and cancellation/discard of stale results when focus, target, owner or input generation changes. Test on wlroots/KWin/Mutter: keyboard- and pointer-triggered selection with valid per-seat serials, middle-click paste, password exclusion, no reclaim of an unchanged selection, owner/seat change, empty/absent offer, self-paste, slow trickle, oversized offer, cancellation, and UI event-loop responsiveness during a slow transfer. A compositor limitation is recorded, not papered over with a control-channel substitute.

4.10 GPU and renderer behavior

Anchors: apps/lightcraft/src/window_gpu.rs:104-200 (choose, base_options, probe), main.rs:917-940, crates/ui-egui/src/softpaint.rs, crates/ui-egui/src/render.rs.

  • Keep the Windows probe/fallback design: DX12 → Vulkan → GL → glow, with error scopes and a non-panicking wgpu error handler. Renderer::new(device, format, RendererOptions) is unchanged in the inspected source; re-run the probe tests and the Windows startup matrix.
  • E2/E3 (surface fit and configure/renderer error scopes) are required for very large or multi-monitor surfaces; test a window larger than the device limit via LIGHTCRAFT_GPU_LIMITS=webgpu and the fork's unit tests. A surface failure must be a typed error and the next renderer/CPU fallback, never a panic.
  • #8736 (DX12 FXC shader-compile cost) is measured as its own workload: the probe's pipeline construction and eframe's first frame separately, fresh processes. A clamp-only shader change is not a valid fix (it changes Repeat/MirroredRepeat semantics); evaluate the upstream/float-select or pipeline-constant design with equivalent wrap tests.
  • #8731 (plain-text tessellation) is its own workload: a text-heavy fixture (grid metadata, menu labels, panel rows) with mixed color-glyph rows, baseline/stock-0.37/fork. Do not cache or alter texture wrap/filter semantics to hide it.
  • Photo textures remain TextureOptions::LINEAR; softpaint's CPU mirror preserves nearest vs linear per ImageDelta and texture wrap. kittest uses RendererOptions::PREDICTABLE; check #8441's nearest-filter fix did not change preview/texture semantics.
  • On DPI/zoom/resize, verify textures are updated/freed, the screenshot size contract stays viewport_pixels/resized_viewport, and the softpaint shadow context uses the same UI zoom.

4.11 Host adapters and persistence

  • Native App::logic becomes self.0.logic(ctx); then the native menu and PrefsWriter::tick (both on the monotonic clock). App::ui stays one self.0.ui(ui). Do not call self.0.logic again from ui. PrefsWriter::tick must not record Seen::of window geometry from a hidden run_logic pass whose content_rect is stale: keep the last known placement.
  • Web WebApp::logic: safety::take_notices (monotonic), run_inbox (every call: consumed queue), load_originals (every call: drains a queue), the worker budget, then self.app.logic(ctx), then save (own monotonic deadline), then the bench step (UI phase). WebApp::ui remains one self.app.ui(ui) plus the first-frame log. import_dropped moves into the UI phase and consumes the queue filled once by WebApp::raw_input_hook, so neither a later pass nor a hidden tick can re-import the same drop.
  • Keep raw_input_hook once per host frame; it takes synthetic events and dropped files into app-owned queues, and the in-flight step gate in §4.5 prevents repeated logic-only ticks from draining more than one synthetic step. Do not move injection into ui, where it would repeat on multipass.
  • Keep PrefsWriter and UiState JSON unchanged. The only visible ID change is egui memory/panel layout; UiState docking, settings, recent libraries, keymap and library catalog formats are not migrated. Test edit → save → close → reopen and undo semantics with the existing library tests.

5. Dependency graph, pin and lockfile acceptance

5.1 Intended graph

lightcraft / lightcraft-web / lightcraft-cli / lightcraft-ui-egui
  -> egui 0.37.0 (published storytold revision, immutable SHA)
     + epaint, epaint_default_fonts, ecolor, emath
  -> eframe 0.37.0, egui-winit, egui-wgpu, egui_glow
  -> egui_kittest 0.37.0 (dev) -> kittest 0.5.0 (registry), accesskit 0.25.x
  -> winit 0.30.13 (published storytold revision, immutable SHA)
  -> craft-ui from the reviewed craft-libs revision (stock-egui-0.37 source compatible, CL-2F)

An incompatible craft-ui bump in any consumer is part of the same atomic dependency stage as the egui/winit graph; never a separate version step that leaves the suite half-migrated.

Root [patch.crates-io] must name every used package at the same full SHA: egui, eframe, egui-winit, egui-wgpu, egui_glow, egui_kittest, epaint, epaint_default_fonts, ecolor, emath, and winit. Add egui_extras only if it enters the graph. Do not patch egui_plot, egui_dock, egui_inspection or egui_system_font_provider unless used. Never use a branch name or a machine-local path in a publishable manifest.

craft-ui's own [patch] does not propagate: LightCraft's root patch is what gives eframe/egui-winit the fork. A graph that resolves winit from crates.io while egui-winit expects the fork API is a failure. craft-ui must compile against the stock egui 0.37 source API (PR 199's role/scope_id/IdSalt edits) so the shared crate stays publishable; fork-only APIs stay out of craft-ui. Pin craft-ui to the reviewed craft-libs revision and verify its serde feature under LightCraft's root patch.

5.2 craft-libs: use the reconciled base, never the stale trees

  • Current local shared-library base: 3832c47ef68a0e6214f2c1e85f3526b6adcb686e. It already carries the egui 0.37 API port (the historical 8614e4a shape and the 1764bca README hunk); those commits and the earlier 49190884278f16c9a9f5823d7698a8ffb5deb062 main are history, not the base to apply again.
  • PR 199 head b68de8c5f4f1c060904d211a7b1320aa0f7bce78 and the audit merge ec8840af8c5fda695cf195cdd05e5ae078415c07 miss craft-platform and revert newer security/settings/docking/printer fixes. Never merge either as a tree.
  • Keep the craft_ui::capture::CaptureSlot consumer adapter on the reconciled base, preserve every newer fix, and re-run craft-ui's own tests and visual fixtures after the port. The CL-2F helper and the exact immutable publish SHA remain central-publication gates; neither is guessed here.

5.3 Checks

Add an xtask dependency check (for example xtask/src/deps.rs, invoked by cargo xtask ci before clippy). The check takes its expected graph from a mode/fixture: L1 runs it in 0.36.2 baseline mode and is green today; L2a flips the expected graph to the 0.37 P8 graph in the same atomic dependency commit. The negative fixtures run in both modes. Cargo.lock records packages, versions and sources but not resolved features, so it can only prove identity:

  • lockfile/metadata identity: exactly one egui/epaint family source and one winit source, all at the expected git URL and full revision; no registry copy beside them; craft-ui and craft-docpath at the reviewed revision, not a branch or a path;
  • resolved features from cargo metadata + cargo tree -e features for every accepted root (native, headless/CLI, tests, wasm): egui has default_fonts and monochrome_emoji_fonts; native eframe has clipboard; the wasm root does not accidentally pull wgpu; craft-ui's features resolve as intended. Record the raw output in the evidence;
  • negative fixtures, each of which must fail: missing patch, branch pin, second registry egui, old craft-ui, machine-local path, feature-less egui (the last one only through the metadata/tree mechanism — a lockfile-only check cannot catch it);
  • wasm-bindgen in the lock matches the CLI (xtask/src/web.rs already checks this).

Run cargo tree -d, cargo tree -i egui@0.37.0, cargo tree -i winit@0.30.13, cargo metadata --locked, cargo check --locked --workspace --all-targets and the wasm graph under the same pin in managed jobs (§7.1). A clean checkout must fetch every approved revision and pass --locked without local overrides. Recheck remote heads before merge; an immutable SHA is required even if the remote branch moved. Record the lockfile SHA-256 and keep the 0.36.2 lockfile for rollback. accesskit moves to 0.25.x, kittest to 0.5.0; review that churn. Nix's cargoLock/ allowBuiltinFetchGit must fetch the git sources; run nix build if available, otherwise record the explicit unavailable gate.

6. Regression assertions

6.1 Functional and lifecycle

  • One command/undo step per user action under multipass. Use the existing right.rs:1161 request_discard path plus a test-only forced discard, press one rating/flag key, and assert one catalog op/undo entry, one toast, one repaint request and identical settings. Repeat through the control channel.
  • Control gesture delivery and screenshot paint predicate (implementation amendment). A queued ui.clickWidget with a stale registry answers only after a completed fresh layout; an id that disappears answers once; a view/dialog/document change between admission and resolution answers once with a cancel error; a hidden gesture receives RequestPaintWhileHidden while unresolved or undelivered and replies only after its last synthetic event is consumed; a 5 s deadline answers once and clears the queued events. A screenshot that is only queued/settling (sent_at_ms == None) requests a hidden paint; one already issued (sent_at_ms == Some) does not, while its 2 s compositor fallback and hard timeout checks still run every logic tick.
  • Hidden logic continues. Repeated ctx.run_logic calls while hidden advance import/Lightroom-import/recheck/task/autosave/persist/retry/slideshow work, do not consume a queued key/text/drop, and do not replay the last pass's dropped files. A hidden tick that invokes raw_input_hook leaves one queued synthetic step in flight; repeated hidden ticks must not inject further steps or batch press+release, and the next full pass consumes exactly one. Cover: a completed multipass frame followed by hidden ticks (current_pass_index() == 0 each time, so nothing is skipped); visible() == Some(false); a forced hidden paint with pending input and with a pending screenshot; restore with buffered events (each consumed once); a worker completion between passes.
  • First frames. Headless::step at simulated time 0.0 runs logic and lays out; a harness that calls ctx.run_ui(egui::RawInput::default(), …) runs logic; visible() == None is treated as normal; the first forced hidden paint processes input.
  • Screenshot capture. Two outstanding requests get their own image and exactly one reply; a queued/settling hidden request receives RequestPaintWhileHidden before issue while an issued screenshot forces its own paint; complete out of order; drop/cancel then complete the callback (no reply/file/panic; reservation back to zero); 2 s compositor fallback and hard timeout; an issued hidden-window request completes via the screenshot's own forced paint while a queued/settling one first needs the paint request; headless request completes through HeadlessView::run and Headless::step (both, once); a resize between issue and completion retries or errors rather than mixing geometry; oversized dimensions are rejected before issue.
  • Control gestures while hidden. ui.clickWidget, ui.dragWidget, ui.pointer with space: "image", and a multi-step drag each complete while the viewport reports hidden, using the first fresh layout after admission, send a paint request per step, and reply exactly once after the last event was delivered. A widget that no longer exists replies with an error once.
  • Save/reopen. Edit exposure/keywords/mask, save/persist, close, reopen the same synthetic library; assert document meaning, selection/workspace and undo. Assert UiState JSON round-trips and docking/floating geometry survives unchanged.
  • Docking and second window. Real drag → float → close → reopen and ui.dock invalid requests preserve the selected tool/layout (tests/docking_workflows.rs); explicit LTR direction leaves saved rectangles and tab order unchanged; the embedded second window renders the active photo and closes; the native second window focus/close/reopen is a real-platform test.

6.2 Geometry, hit-testing and pixels

At 1400×900 and 900×700, scales 1.0/1.5/2.0, dark/light themes:

  • top bar 42 pt, bottom bar 48 pt, tool strip 48 pt, default side widths from UiState; panel order top → banner → strip → central → side panels; canvas rect equals the remaining rect within 0.5 pt;
  • dragging the left/right inner edge by ±120 pt changes the stored width within 2 pt and the registered panel:* rect within 1 pt; the photo area never falls below MIN_PHOTO_WIDTH;
  • the resizable_side test asserts the drag edge is live through the corrected pid (and fails if the panel is constructed with a pre-hashed IdSalt);
  • grid/filmstrip/menu scroll offsets and hit rects agree after wheel, drag and resize; menu arrows scroll and stop at bounds;
  • dialogs are centered and fit; a long translated title/label does not overflow (tests_dialog_fit, tests_labels, tests_masking_layout).

Pixel comparisons use 1600×1000 at 1× and 2×, deterministic Session::with_demo() and pinned fonts. Keep reviewed baselines in task evidence, never UPDATE_SNAPSHOTS=force; compare with a predeclared metric: region geometry exact ±0.5 pt; count of pixels differing by >2 LSB and mean absolute difference per region; a change in the top bar, side panels, canvas or menu requires review. Determinism alone is not acceptance.

6.3 Accessibility and keyboard

  • The strict kittest checks are on in every harness from L2a: no unnamed input, no missing glyph. Extend tests/accessibility.rs to the roles is_input uses (DisclosureTriangle, Splitter, Tab, inputs) and to the second-window embedded tree. Sliders have value/range; dialogs have titles; hidden controls disappear. Custom disclosure rows use the actual 0.37 API: WidgetInfo::labeled(Role::DisclosureTriangle, enabled, label) plus ctx.accesskit_node_builder(resp.id, |node| node.set_expanded(open)). They must not use WidgetInfo::selected, which fills AccessKit Toggled rather than Expanded. A unit test asserts is_expanded() is true/false for widgets::section_header; the same helper is used by widgets::flyout_row, the panel fold headers and the sidebar section headers. (An accessibility-tree run over the Edit/Keywords views remains part of the broader accessibility suite, not this focused state test.)
  • Tab order stays top-to-bottom in panels; screen-reader Increment/Decrement/SetValue and Enter/Space/arrow actions trigger the same commands as the pointer.
  • Focus/edit/drag state does not transfer when the language changes, a panel hides, rows are inserted, or a menu opens. Id::unique preserves Id::new's hash, but a wrong IdSalt/scope_id in a container can still move state.
  • Text field: select range → click-away action uses that range (#8763); Esc restores; Return/away commit; IME preedit/commit/cancel one edit/undo; bidi caret/selection in logical and visual order; emoji keyword/filename renders without tofu.

6.4 Mutation/negative controls

Bounded probes that fail before the fix, kept as unit-level negative tests where cheap:

  • a capture slot completed for the wrong request: no file/reply; a late callback after cancel: no reply/panic and released bytes;
  • a deliberately duplicated command path under request_discard: the one-undo test fails when the first-pass guard is removed;
  • resizable_side with the old exact Id or a pre-hashed IdSalt: the drag test fails to see the resize widget / does not store the width;
  • one unnamed input in a scratch kittest harness: check_accessibility panics;
  • one unsupported glyph: MissingGlyphPolicy::Panic panics and Tofu does not;
  • a hidden ui.clickWidget while the widget registry is stale: the request resolves only after a fresh layout (test fails if the old geometry is used).

7. Performance protocol

7.1 Baseline, candidate and control

Build the baseline 23e18128, the stock 0.37 control and the fork candidate in separate managed task.py run --kind build allocations with distinct source/evidence paths (§8). Use the same Rust toolchain (1.99.0 at inspection), --release, same features (denoise default; heif separately), same CRAFT_FONTS_DIR revision (craft-fonts 2481a8a), same corpus files, same profile (including debug = "line-tables-only"), same renderer/driver, viewport and DPI. Record full commit, lock SHA-256, resolved features, fonts, fixtures, OS, CPU, RAM, GPU adapter/driver, renderer backend, DPI and whether the GPU is enabled. Alternate execution order; never benchmark concurrently with a build or another noisy workload.

Fixtures: Session::with_demo() (deterministic), a 10,000-photo synthetic library from the grid_bench generator, and the pinned 24 MP corpus raw (cargo xtask corpus --download; gitignored). No corpus or CRAFT_FONTS_DIR was present at inspection, so these are explicit prerequisites.

Comparisons. Report baseline 0.36.2 → candidate, and stock 0.37 → candidate wherever an equivalent workflow exists in stock 0.37, so a fork-induced regression is not confused with a 0.37-induced one. The stock control uses the same fixtures, profile, fonts and environment.

7.2 Operations and sample counts

  • Warmups: at least 10 warmup iterations per steady operation, discarded.
  • Steady operations: at least 30 timed iterations (slider drag, menus, control operations, opening photos), 10 repetitions for cold/warm open.
  • Startup: 20 fresh processes per side, to first UI frame and to thumbnails settled; separate process start, renderer/probe construction, font setup and first frame.
  • Frame distributions: at least 1,000 actual painted frames before any p50/p95/p99 claim (not synthetic logic ticks, not 20/30/50 samples), counted from Context::cumulative_frame_nr() or the equivalent painted-frame counter. Report median/p95/p99 with the actual frame count.
  • Grid: grid_bench 200 and 10,000 photos, 60 unchanged frames, both grid modes, 1× and 2×; median/p95, per-frame UI allocations, cells visited, layout rebuilds.
  • Slider drag: drag_latency on the 24 MP raw, 27 controls, TICKS=30, CPU and GPU separate; median/p95 draft texture latency and full render after release. Existing budgets: ≤16 ms draft, ≤60 ms loupe; export ≤1 s/24 MP JPEG.
  • Thumbnails/menus: thumb_request_bench with blocked rendering for allocation counts; a long menu open with wheel/arrow scroll, 1,000 actual frames.
  • Opening photos: open_timeline with pinned raws, warm and cold, 10 repetitions.
  • Control/UI operations: ui.screenshot (headless), ui.inspect, grid scroll and panel drag, measured request → reply over 30 samples.
  • Separate workloads, never one blended number: (a) #8731 text tessellation — text-heavy fixture, plain text vs mixed color glyphs; (b) #8736 DX12 — fresh-process pipeline/probe construction and first frame on Windows DX12; (c) #8512 font startup — font discovery/setup time in a fresh process with and without the bundled faces.
  • Idle: 60 s visible and 60 s minimized/hidden; process CPU time, cumulative_frame_nr (an additive perf.frames only in the instrumentation stage), repaint count, GPU work and RSS. A new busy loop or a repaint while hidden beyond requested work is a failure.
  • Web: cargo xtask web raw and compressed WASM size (raw, gzip, brotli; separately record the effect of monochrome_emoji_fonts), ?bench first frame, thumbnails, eight slider steps and release render, 10 fresh page loads per side, one browser/version and a software/ANGLE equivalent if available.

Record peak RSS, allocation/texture-upload counters, cold vs warm caches and idle CPU separately.

7.3 Predeclared acceptance and noise policy

Shared suite thresholds (do not loosen them): investigate a repeatable >5 % median or >10 % p95 latency regression, >5 % peak-RSS increase, any new idle busy loop/repaint, any missed slider/loupe/export budget, or a visual/document regression. App-specific gates may be stricter. Apply a documented noise floor: for sub-millisecond values require the relative increase to exceed 0.05 ms or corroborate with stable instruction/allocation counts. The stock 0.37 equivalent control is required in the first report and for every regression decision, so a 0.37-induced change is not attributed to the fork; stock 0.37 remains a control, never a production stage. Reject noisy comparisons as inconclusive and rerun; do not widen thresholds after seeing a failure. #8731/#8736/#8512 figures are upstream reports, not our measurements; compile duration is not UI performance.

8. Platform evidence and provisioning gates

Every row is a provisioned environment gate, not a claim that the current host or a previous run passes. A row without provisioned hardware/OS/browser is recorded as unresolved, never as a pass.

Platform Required workflow Acceptance
Linux X11 Managed native run of the exact candidate with --control PORT, control script, native screenshot, edited/saved/reopened library; both themes, 1×/2×; renderer recorded. Requires a real X11 session (none is inherited by this server shell) Real window; CPU and available GPU reported separately
Linux Wayland Same on a named compositor (wlroots/KWin/Mutter) provisioned for the task; clipboard/PRIMARY, drop, IME, hidden second window E4 + W15P matrix from §4.9; any compositor limitation recorded if reproduced
Windows Owned craft-windows clone (own disk, UEFI, TPM, ports, 128 GiB reservation), native build of the exact candidate, visible grid/detail/edit workflow, screenshots, save/reopen, FXC/DX12 probe, software-renderer baseline and real GPU if present Start-up/first frame measured; no panic/black window; DX12→Vulkan→GL fallback
Web cargo xtask web --serve in a provisioned browser environment; browser automation drives observable command(...) actions (thumbnail/detail/slider/save state) and captures the canvas with the browser's screenshot facility; console errors checked; ?bench Real browser workflow; wasm module and CJK/emoji assets present; not just cargo check. There is no control server and ui.screenshot is not a LightcraftApp::run command (web.rs:609-644 → self.app.run), so a browser capture is the only screenshot path unless a separate reviewed download/writer API is added
FreeBSD Repo workflow with craft-fonts, if a runner is provisioned Compile/test evidence or explicit unresolved gate
macOS Real macOS hardware (not cross-compilation) for menu/IME/Metal/GL/termination/AccessKit teardown Separate unresolved blocker

No claim is made that this server has no GPU: read-only inspection finds an i915 PCI 8086:3E96 render device and readable/writable /dev/dri/renderD128. That is availability evidence only, not a qualified Vulkan/OpenGL/compositor result. No display session is inherited, and Windows baseline rendering remains software-rendered.

Headless rendering is not proof of native focus, IME, file picker, compositor clipboard, screen reader or GPU presentation. A windowed ui.screenshot that logs the "rendering headlessly" fallback does not satisfy native acceptance.

9. Reviewable PR stages, dependency DAG and rollback

P0 (external; BLOCKED until published)
  storytold egui immutable revision P8: E1-E21 + P2b (E4 regular/PRIMARY with bounded
    async W15P transfer) + P7 held-key pairing + P7b/E21b + P7c (#8763/#8748 and
    justified perf fixes), reviewed and published
  storytold winit immutable revision: complete W15P + W27 lifecycle (+ W15/W25/W26 series)
  craft-libs CL-1 revision, then CL-2 provisional capture slot
  CL-2 -> DesignCraft/GridCraft adapters -> CL-2F API freeze
        |
L1  baseline harness/instrumentation + baseline-aware xtask dependency check + evidence scripts
    (0.36.2 expected-graph mode; works today; no product UI change, no new pins)
        |
L2a coherent graph + compile migration (manifests, lock, patch, Id/Role/scope/ComboBox/Event
    source, default_fonts + monochrome_emoji_fonts + native clipboard, CL-2F screenshot
    callback/admission/drain/timeout/basic reply handling, scroll offset read/write, strict kittest naming/glyph fixes, all -D warnings)
    -- atomic; no unbuildable intermediate commit on main. BLOCKED on P0.
        |
L2b lifecycle/root-UI hardening (tick split, monotonic clock, dropped-file queue, panel IDs,
    hidden gesture queue, CL-2F capture stress tests, both headless hosts, second-window/docking tests)
    -- needs CL-2F (DesignCraft/GridCraft are the CL-2 pilots).
        |
L2c fonts/accessibility/language/IME/clipboard hardening (set_language, #8763, E21b/W27 tests,
    W15P/E4 PRIMARY matrix)
        |
L2d measured visual/perf acceptance, docs updates, platform evidence and final review

P0 is explicitly blocked: the final approved storytold immutable SHAs are unknown until the fork and winit series are published, and this plan does not invent them. a4f9a17f/8b35388b are used only to inspect behavior. L2a–L2d cannot be prepared as merge-ready changes until P0 exists. L1 can start now in 0.36.2 expected-graph mode; L2a switches the dependency check to the 0.37 P8 graph. L2a is one atomic dependency stage: the complete egui/winit P8 graph plus the required craft-ui revision (CL-1 and CL-2F for the eleven craft-ui consumers; DesignCraft/GridCraft CL-2 pilot adapters precede CL-2F). Any incompatible existing craft-ui bump belongs in that same atomic stage. Stock 0.37 is only the measurement control, not a production stage.

Expected file ownership: L1 adds xtask/src/deps.rs (plus dispatch), test/bench harness scripts under tools/egui037/ and non-product tests; L2a changes Cargo.toml, Cargo.lock, the three app manifests, crates/ui-egui/Cargo.toml and the mechanical compile sites in crates/ui-egui/src/{lib.rs,widgets.rs,control.rs,headless.rs,menu_level.rs,panels/*} plus apps/lightcraft/src/main.rs where an API changed; L2b owns lib.rs, panels/mod.rs, panels/{faces,denoise}.rs, panels/second.rs, headless.rs, control.rs, apps/lightcraft/src/main.rs, apps/lightcraft-web/src/web.rs and lifecycle tests; L2c owns theme.rs, i18n.rs, text_field.rs, widgets.rs, tests/accessibility.rs and the IME/clipboard tests; L2d owns only reviewed visual/perf evidence and progress documentation.

L2b–L2d are separate dependent PRs; they may be implemented on separate branches after L2a but merge one at a time, re-running cargo xtask ci after each merge. Do not land a dependency bump that leaves main unbuildable, and do not combine unrelated cleanup with the migration.

Rollback is the whole previous coherent set, not an egui version string: revert the stage range and restore the 0.36.2 manifests/lockfile plus craft-ui 5c874d51... and registry winit 0.30.13; keep the old lockfile, patch block, package IDs and source SHA in evidence; keep fork/winit/craft-libs revisions reachable. No persistence migration: UiState, catalog journal/snapshot, XMP, presets and library paths are unchanged; only egui memory/panel layout may reset. No release, tag, branch deletion or cache cleanup until the migration is abandoned or complete and evidence is preserved.

10. Blocking unknowns and acceptance criteria

  1. Published pins (blocking). The final approved storytold egui P8 (including P2b, P7, P7b/E21b and P7c) and the completed winit (W15P + W27) immutable revisions do not exist until the fork/winit series is published and reviewed; no SHA is assumed here. Stages L2a–L2d are blocked on them. a4f9a17f/8b35388b are comparison references only; no stock or intermediate stage is a production dependency.
  2. E21b/P7b (blocking). The complete shared contract — owner identity even when unavailable, available-empty distinct and explicitly sent, committed disable/enable clear, latest-pass authoritative None, current-epoch serial mismatch applying incoming edits once while outgoing waits, stale-epoch invalidation, bounded ≤4000-byte payload with no full-document clone — plus the TextEdit producer and W27 adapter is not in the inspected candidate. LightCraft's built-in TextEdit consumers inherit it, and the family's custom producers (e.g. FilmCraft panels/graphics.rs) need their own logical-model adapters before family acceptance.
  3. W15P + E4 + P2b (blocking for native PRIMARY). The inspected E4 has an arboard fallback, not the required winit-owned per-seat PRIMARY path. Require the complete published P8/W15P revision with bounded asynchronous transfers (2 s total deadline, 16 MiB, four readers/two writers/queue 8, owner-specific waker, stale-result cancellation), pointer- or keyboard-triggered fresh serials and the §4.9 native matrix.
  4. craft-libs CL-1/CL-2F. The capture slot exists only as a provisional CL-2 API until published; LightCraft requires the frozen CL-2F API, which only exists after the DesignCraft/GridCraft pilot adapters. The port sits on the reconciled 3832c47ef68a0e6214f2c1e85f3526b6adcb686e base; PR 199's head and the audit merge are stale trees and 8614e4a/1764bca are already contained in that base.
  5. Hidden viewport children. #8748's overdue-UI behavior is not in the inspected fork; P7c is required to carry the selected fix. The second window while the root is hidden/minimized still needs native Wayland/Windows evidence.
  6. DX12 startup (#8736) and text tessellation (#8731) are open upstream reports; measure on our fixtures as separate workloads and require a semantics-preserving fix if thresholds are breached. #8512 is a separate font-startup workload, not a reason to enable system fonts.
  7. Strict kittest findings. The number and identity of unnamed inputs/missing glyphs under 0.37 is unknown until the first L2a run; they are real fixes, not a reason to disable the checks.
  8. Fonts. Whether the bundled faces cover every shipped locale and script fixture without system fonts is a measured gate; CJK tests skip without CRAFT_FONTS_DIR, which is not release acceptance. Monochrome emoji add ~1 MB; measure native and WASM size.
  9. PRIMARY/clipboard on GNOME/Mutter needs a real compositor; a control-channel paste is not a substitute.
  10. macOS: no real hardware evidence for menu, IME, Metal/GL, termination or AccessKit teardown; an explicit rollout blocker, not a pass.
  11. Web workflow: no control server and no ui.screenshot command; browser capture plus observable command state is the acceptance path unless a separate download/writer API is separately reviewed and approved.
  12. Visual deltas: 0.37 style/scrollbar/menu changes may alter reviewed screenshots; no bulk golden refresh.
  13. Lock/feature drift: accesskit/kittest movement, wasm-bindgen CLI matching, Nix git fetching and a second egui/winit source must pass the dependency check.
  14. Performance environment: no display session, corpus or fonts are guaranteed on this host; Linux/Windows/browser results need the managed resources in §7/§8. The i915 render node is availability evidence only.

Acceptance requires a fresh independent adversarial review of this plan, then of each implementation at its exact revision, with resolved findings and hashes recorded. It also requires all repository gates, selected features, native Windows/Linux and browser workflows, the measured before/after report, accessibility and visual review, and explicit human publication approval. No unresolved correctness finding is downgraded to "later".

11. Research references (primary sources; proposals distinguished from merged behavior)

  • egui 0.37 changelog and release commit 841a328b; the inspected suite branch is a4f9a17f64a6bae61350fb85804c9df0d9865916, not main.
  • Lifecycle: epi_integration.rs, app_runner.rs, context.rs.
  • Callback screenshots: PR #8489, crates/egui/src/data/screenshot_callback.rs (merged).
  • Roles: PR #8555; egui/src/lib.rs role table (merged).
  • IDs/scope: PRs #8534, #8586, #8590, #8542; id.rs, id_salt.rs, ui.rs, ui_builder.rs (merged).
  • Panel salts: PR #8592; containers/panel.rs (merged).
  • Root UI/run pass: PR #8535; context.rs (root_ui panic documented) (merged).
  • Default fonts / emoji features: PR #8583; egui/Cargo.toml, epaint/src/text/font_definitions.rs.
  • Strict accessibility/missing glyphs: PRs #8589, #8611, #8601; egui_kittest source.
  • PRIMARY selection: PR #8428 (merged); Wayland clipboard #8657 (open draft) and winit #4729 (open), paired with E4/W15P.
  • Bidi/RTL: #8577 (open) and the fork's E5/E12/E13/E16/E17/E20/E21 series.
  • TextEdit click-away selection: issue #8763, PR #8764 (open/unmerged at inspection).
  • Text tessellation: issue #8731 (open); DX12 FXC pipeline: issue #8736 (open); system-font startup: #8512 discussion.
  • Hidden/overdue viewport children: issue #8748 (closed upstream; absent from the inspected fork).
  • IME surrounding text gap: ime-surrounding-gap.md and the egui plan §E21b/P7b, winit W27.

12. Evidence to preserve

Retain: the baseline/candidate/control commit IDs, lock SHA-256 and package/source tables, cargo tree -d/-i and cargo tree -e features output, cargo xtask ci reports and test counts/skips, dependency-check negative fixtures, accessibility tree dumps, reviewed baseline/candidate screenshots and geometry JSON, performance raw samples and summaries (with environment and separate #8731/#8736/#8512 workloads), native/browser/Windows logs and screenshots, the exact published egui/winit/craft-libs revisions and their tests, and this plan's reviewed hash. Do not preserve build targets as evidence; they are rebuildable and are cleaned only after all checks and deliverables are saved.

13. Review disposition record

13.1 R1 review (lightcraft-v1.md)

# Finding Disposition Resolved in
1 Panel::left(salt) double-hashes; pid mismatch Accepted. Panel::new stores IdSalt::new(id_salt) and Panel::id = parent_ui.scope_id().with_salt(store); IdSalt::new(IdSalt) is a distinct salt (id_salt.rs nested-salt test). Fixed to pass the original source and derive pid with with_salt(IdSalt::new(id)); no public panel-state-id helper. Impact narrowed: the helper has no production caller at inspection, so the defect is latent today. §3, §4.7
2 eframe clipboard feature missing Accepted. Add clipboard to the native app and assert it through resolved features; headless clipboard is not native acceptance. §2.1, §4.9, §5.3
3 TickPhase has no first-frame/None/forced-hidden branch; stale-index premise false Accepted, with two sub-claims rejected. The stale-index claim and the has_visible_pass contradiction are correct. The specific example "RawInput::default() has time 0" is wrong: InputState::begin_pass uses new.time.unwrap_or(prev + predicted_dt), so the input time is predicted_dt; the real zero-time first frame is HeadlessView::raw_input(..., time = 0.0). The classifier is removed entirely rather than patched, so both cases are covered. §4.1, §4.2, §6.1
4 Hidden geometry-dependent controls use stale geometry; one paint request cannot carry a gesture Accepted. Geometry resolves only after a fresh layout_epoch; each gesture step requests a hidden paint; exactly one reply, after delivery. §4.5, §6.1
5 default_fonts loses 0.36 emoji faces Accepted. Enable monochrome_emoji_fonts; tests cover 🙂/👍 and the emoji keyword fixture; ~1 MB size is measured. §2.1, §4.8, §7.2
6 E4 does not provide the winit-owned PRIMARY path Accepted. W15P is a prerequisite with the full native matrix; no limitation is recorded as "solved". §1, §4.9, §10
7 E21b/W27 outgoing IME surrounding text missing Accepted. Added as a blocking prerequisite with the built-in-TextEdit inheritance tests and the family custom-producer requirement (FilmCraft graphics etc.). §4.9, §10
8 Web cannot invoke ui.screenshot Accepted. Web command() only reaches LightcraftApp::run; browser capture plus observable commands is the workflow; no new app API unless separately reviewed. §4.11, §8, §10
9 A Cargo.lock check cannot assert features Accepted. Identity from lock/metadata; features from cargo metadata + cargo tree -e features; feature-less fixture targets that mechanism. §5.3
10 DockArea::direction unspecified Accepted. Explicit LTR policy at both call sites with unchanged persisted geometry/tab order. §4.7, §6.1
11 Screenshot design does not adopt the shared completion slot Accepted. LightCraft requires the frozen CL-2F helper; request paths/deadlines/replies stay app-owned. §4.6, §9
12 Hidden-time accounting misses toasts/status and faces/denoise input Accepted. One monotonic clock for toasts/status/timers; faces/denoise split into background drain vs UI-phase input. §4.3, §4.4
13 Duplicate-work claim overbroad; web inbox table contradiction Accepted with correction. A second pass has empty events (InputState::begin_pass), so event handlers cannot duplicate; the real duplicates are time-gated ticks and the double faces::pump. Web inbox runs every logic call, dropped files only in the UI phase. §4.2, §4.4, §4.11
14 Headless::step still has the removed Event::Screenshot arm Accepted. Both HeadlessView::run and Headless::step are updated; exactly one completion. §4.6
15 Count/factual issues (221 id calls; web inbox table) Accepted. 221 egui::Id::new occurrences (218 lines) plus three bare Id::new, and 8 ui.id() calls verified in this checkout; the table/text disagreement is removed. §3, §4.4

13.2 R2 review (lightcraft-v2.md)

# Finding Disposition Resolved in
1 Hidden timers placed in ui_pass where hidden run_logic cannot reach them Accepted. import/lightroom_import/recheck/slideshow/auto-import/sidecar/device/toast/autosave ticks were moved back to logic, with monotonic deadlines so a second pass is a no-op; only UI input/drop/perf work stays behind current_pass_index() == 0. §4.2, §4.3, §4.4, §6.1
2 E21b/W27 omission of owner/serial/epoch rules Accepted. Restated the complete P7b/W27 contract: owner identity while unavailable, available-empty sent explicitly, committed disable/enable clear, latest-pass authoritative None, current-epoch mismatch applies incoming once and defers outgoing, stale epochs invalidate, bounded payload without a new full-document clone. §4.9, §6.3, §10
3 W15P required without bounded async transfer Accepted. P2b is now part of the P8 prerequisite: 2 s total deadline, 16 MiB cap, four readers/two writers/queue 8, owner waker and stale cancellation, with slow/canceled-transfer and responsiveness tests. §1, §4.9, §6.3, §10
4 PRIMARY matrix is keyboard-only Accepted. Fresh pointer-press or keyboard-focus serials are both valid; keyboard-only is explicitly rejected, and middle-click paste is tested. §4.9, §10
5 Hidden synthetic input can accumulate in pending_raw_input Accepted. Hook may run in logic-only, so it injects at most one step and marks it in flight until a completed UI pass consumes it; no second injection from repeated hidden ticks and no press/release collapse. §4.5, §6.1
6 L1 cannot both run on 0.36.2 and enforce the 0.37 check Accepted. The dependency check is baseline-aware; L1 runs in 0.36.2 expected-graph mode, and L2a flips it to the P8 graph atomically. §5.3, §9
7 Counts/detail inaccuracies Accepted, normalized. This checkout has 218 egui::Id::new lines plus three bare Id::new calls (221 lines, 224 occurrences; the review's line/occurrence wording was slightly mixed). The font key is Ubuntu-Light; the logic doc comment now matches per-pass host calls; the weak-slot reason is corrected (the renderer owns the callback, so this is not a Context cycle). §2.1, §3, §4.2, §4.6, §4.8

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/lightcraft main now resolves to d5e639b3d1ad079c39a924c1b65d0a96ee2134ac. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable.

No migration API redesign required by this delta; preserve the merged packaging, test and chrono changes where present.

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

Local implementation — stage L2a/L2b source migration (2026-10-23)

Status: source changes only, not validated here. No build, test, cargo metadata, fmt, clippy, GUI, browser, Windows or performance run was made in this worktree. The parent runs the managed jobs listed at the end. This section records exactly what changed and what is still open; it is not acceptance evidence.

Dependency and feature stage (L2a)

  • Workspace egui is now 0.37 with default-features = false and features = ["default_fonts", "monochrome_emoji_fonts"]; eframe and egui_kittest are 0.37 (egui_extras is declared at 0.37 although LightCraft still does not use it).
  • Native eframe adds the separate 0.37 clipboard feature; web keeps default_fonts + glow.
  • The workspace feature choice is what gives the CLI/headless and kittest roots their faces: egui_kittest's own dev-dependencies do not propagate. tests_lifecycle_037 asserts the Hack, Ubuntu-Light, egui-icons, NotoEmoji-Regular and emoji-icon-font faces and that U+1F642/U+1F44D are paintable.
  • No final storytold SHA, [patch] entry or lockfile was invented. The parent supplies the local path patch from ../PLANNING-CONTRACT.md / local-integration-config.toml and generates the lockfile. crates/raw/src/vendor/ is untouched.

API migration

  • egui::Id::new → egui::Id::unique (same hash, so egui state does not reset) and Ui::id() → Ui::scope_id() across the UI crate and tests.
  • WidgetType → egui::Role with the documented variant mapping (CollapsingHeader → DisclosureTriangle, SelectableLabel → Button, ComboBox stays ComboBox).
  • Panel salts: resizable_side now passes the original salt to Panel::left/right, derives the persisted key with ui.scope_id().with(id), and checks the resize drag on that key (the old code passed a prebuilt Id and would be salted twice). 0.36 exact-Id PanelState entries for top_bar, bottom_bar, tool_strip and library-problem-banner are removed once at startup; this resets only egui panel memory, never UiState, docking JSON, catalog or XMP data.
  • ScrollArea::State.offset is private: menu arrows store a one-frame vertical_scroll_offset target, and the right panel reads clamped_offset().
  • Event::MouseWheel initializers carry source; ui.scroll accepts optional "source", "unit" and "phase" with the validated values and defaults from §4.9.
  • ComboBox has the documented lifetime; atoms take IdSalt (Atom::custom, WidgetAtomResponse::rect).
  • ViewportCommand::Screenshot now uses the one-shot callback and Event::Screenshot/UserData are gone. Both headless entry points answer the callback with their painted frame exactly once.
  • The fork's Context::set_language is fed from i18n::language() (the AccessKit root language and bidi runs follow the UI locale; command ids and user data stay logical).

Lifecycle and capture stage (L2b)

  • LightcraftApp now has a monotonic AppClock (web_time::Instant, never i.time or Date.now for timing; now_ms remains wall-clock only for library-restored-<ms> names). Headless hosts feed it their simulated time; the first Headless::step runs at 0.0.
  • logic keeps queues, worker polls and time-gated work. Import committing is gated on current_pass_index() == 0 (once per host frame, and once per hidden logic tick), not on the clock; all other hidden-relevant timers (Lightroom import, slideshow, auto-import, sidecar retry, screen profile, denoise/faces/focus/model, autosave/prefs, toasts, captures) use the app clock.
  • ui is split into ui_pass (first pass only: fresh input, dropped files, external-editor focus, shortcuts, perf) and draw_window (presentation), then finish_ui_pass (completed, nondiscarded pass; clears the synthetic in-flight gate). A hidden run_logic never reaches it.
  • Dropped files are taken once in raw_input_hook into an app queue and consumed only in the UI phase; web consumes the same queue in WebApp::ui (it needs bytes_async). Native and web tests cover that a hidden logic tick leaves the queue intact.
  • raw_input_hook injects at most one synthetic batch, refuses to inject before fonts are ready, marks the batch in flight until a completed pass consumes it, and keeps a hidden window painting with RequestPaintWhileHidden while a batch, settlement or queued capture is pending.
  • Screenshot capture adopts craft_ui::capture::CaptureSlot (CL-2 local tree): four outstanding requests, 128 MiB per image, 256 MiB aggregate including completed-not-drained images, admission before issue, fresh native viewport geometry, a three-completed-frame/120 ms settle, a forced capture at 3 s even while renders are in flight, a 2 s compositor fallback and an 8.12 s admission deadline (settle + forced capture + fallback + headless settle). The strong slot stays in the app request record; the callback only holds a weak slot and never writes files or touches app state. A completion from an older geometry epoch or library generation is retried, never written; an image larger than its reservation is rejected; a cancel/timeout/late callback releases its bytes with a checked subtraction and replies once.
  • Web Bench::step, WebApp notices/save and the native PrefsWriter use the monotonic clock. The native menu and window-watch no longer record a hidden pass's stale content rect.

IME inventory and next step

LightCraft has no custom egui::IMEOutput producer: TextField wraps the built-in TextEdit and every other editor uses TextEdit, so it will inherit the P7b/E21b logical-model/preedit/UTF-8 producer once the reviewed egui revision is pinned. No None placeholder was added. The next step is to re-run the P7b outgoing-surrounding-text cases (deletion-only and delete+commit, preedit excluded, reversed selection, supplementary/RTL text, over-limit selection, available-empty vs unavailable, window/owner switch, password clear, stale serial) against that pinned revision. The incoming byte-deletion path is the built-in TextEdit path and is not reimplemented here; tests_lifecycle_037 asserts that TextField applies ImeEvent::DeleteSurroundingBytes exactly (the pinned fork's winit byte-range event).

Tests added (source only)

crates/ui-egui/src/tests_lifecycle_037.rs:

  • bundled monochrome emoji + explicit font-feature gate;
  • parent-salted panel state (and no legacy exact-Id state);
  • capture admission count/per-image/aggregate bounds before issue;
  • stale-geometry completion rejected before any file write, reservation retained for retry;
  • one synthetic batch until a completed pass, and dropped files waiting for the UI phase;
  • first headless frame at simulated time 0 and the app clock following the host.

Unresolved prerequisites and validation

  • Final published storytold egui P8 (E1–E21 + P2b + P7 + P7b/E21b + P7c) and winit W15P/W27 revisions, and the reviewed craft-libs CL-1/CL-2F revisions, do not exist as pins here.
  • This source stage calls craft_ui::capture::CaptureSlot (the provisional CL-2 module in the local craft-libs tree). The parent must provide a craft-ui revision that actually contains that module; if CL-2F renames or changes it, the thin app adapter in crates/ui-egui/src/lib.rs must be updated rather than copied into a second helper.
  • Native PRIMARY (W15P/E4), E21b/W27 outgoing IME, #8748 deferred viewport children and #8763 are inherited from those pins; none is claimed.
  • The explicit LTR DockArea::direction(DockDirection::LeftToRight) policy is not added here: the reviewed craft-ui revision in the local tree has no direction builder yet. Add it in the same dependency stage that freezes CL-1/CL-2F; persisted splits, axes, ratios, floating rectangles and logical tab order must stay unchanged.
  • accesskit 0.24 → 0.25 and kittest 0.4 → 0.5 churn is not pinned here; check that the Linux zbus = "=5.19.0" pin still resolves to one zbus source, and that wasm-bindgen in the generated lock matches the CLI.
  • RequestPaintWhileHidden has no web support in the shared fork: the browser path is unqualified and remains a pending fork fix. Headless rendering is not native focus/IME/picker evidence.
  • Strict kittest naming/missing-glyph checks may surface unnamed inputs or glyph gaps; those are real fixes, not reasons to disable the checks.
  • Visual/style deltas from roomier 0.37 defaults, Windows software rendering, macOS and native compositor/PRIMARY evidence are still separate gates.
  • Control gestures (ui.pointer, ui.clickWidget, ui.dragWidget, and the other input methods) now resolve only after a completed fresh layout and reply only after the final nondiscarded pass consumed their last event; the 5 s deadline is the fail-closed fallback. Limits are recorded in §4.5: cancellation covers the admission-time view/document/dialog/panel snapshot, not a per-widget semantic identity across an arbitrary catalog refresh in the same frame, and a timeout clears queued synthetic input rather than removing one gesture's events individually.

Baseline backports and performance operations

  • Backports this consumer depends on but does not own: egui P7c (#8763 click-away selection, #8748 overdue viewport children, the semantics-preserving text-tessellation fix) and E21b/P7b; winit W27 outgoing IME lifecycle and W15P bounded PRIMARY; craft-libs PR 199's API-port hunks (Role/scope_id/IdSalt) and the CL-2F capture helper on current main, never the stale PR tree.
  • Performance operations to measure after the pins exist, each as its own workload: menu/panel text tessellation (#8731), DX12/FXC first-pipeline startup (#8736), system-font startup only if the font policy is revisited (#8512), capture settle/issue overhead and hidden idle repaint/CPU. Record raw samples and the environment; do not treat compile time as UI performance or refresh visual goldens in bulk.

Parent-managed validation commands

Use one new resource (for example egui037-lightcraft-candidate-20261023) and a fresh job id per command. --locked only after the parent generates the lockfile.

python3 /home/codex/work/maintenance/task.py run \
  --job-id egui037-lightcraft-20261023-check-01 --kind build \
  --task egui037-lightcraft-candidate-20261023 \
  --source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \
  -- cargo check --workspace --all-targets
python3 /home/codex/work/maintenance/task.py run \
  --job-id egui037-lightcraft-20261023-lifecycle-01 --kind build \
  --task egui037-lightcraft-candidate-20261023 \
  --source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \
  -- cargo test -p lightcraft-ui-egui --lib tests_lifecycle_037
python3 /home/codex/work/maintenance/task.py run \
  --job-id egui037-lightcraft-20261023-ci-01 --kind build \
  --task egui037-lightcraft-candidate-20261023 \
  --source "$PWD" --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lightcraft-candidate \
  -- cargo xtask ci

Then inspect every status, record cargo tree -e features for the native, CLI/headless, kittest and wasm roots, run the accessibility/docking integration tests and the cargo xtask web browser workflow, and only afterwards consider the measured before/after workloads (menu text tessellation, DX12/FXC startup, capture settle cost, hidden idle repaint). Do not refresh visual goldens in bulk.

Post-port review repairs (2026-10-23)

Source-only repair of egui037-lightcraft-postport-review-20261023-root-v1.md. No build, test, fmt, clippy, cargo metadata, GUI, browser, Windows or performance run was made here; the parent submits the managed jobs.

  • P1.1: the four bare Context::run_ui statements in tests_lifecycle_037.rs bind their FullOutput (let _ = …) so -D warnings no longer sees unused_must_use.
  • P2.1: a settled capture is issued at a forced instant (SHOT_FORCE_BUSY_MS, three seconds from admission) even when renderer.in_flight() or the merge is busy; the hard deadline (five seconds) stays the only timeout path. shot_ready is a pure helper with a busy-renderer test.
  • P2.2: capture identity is the library generation only. catalog.revision (bumped by every applied edit/import/keyword op) no longer invalidates an in-flight image; the test applies one catalog op between issue and completion and still writes the image.
  • P2.3: a surface that grows after admission is checked as an image against the 128 MiB per-image cap and only its growth against the 256 MiB aggregate. The old code compared the delta to the per-image cap and named the delta as the image.
  • P2.4: a completion larger than its reservation but within the per-image cap is treated as a stale reservation (Wayland has no inner_rect), re-reserved and written, not rejected.
  • P2.5: a slideshow auto-pauses while ViewportInfo::visible() == Some(false), requests no playback repaint, and resumes on unhide with the slide's remaining time preserved. This replaces the 0.36 behavior where i.time froze while hidden.
  • P2.6: the “0.37 has no visible()” rationale was wrong. lib.rs and apps/lightcraft/src/main.rs now use ViewportInfo::visible(); §4.1/§4.2 above are corrected.
  • P3.1: import::tick is gated by current_pass_index() == 0 (once per host frame, and once per hidden logic tick), not by a wall-clock throttle. The wall-clock comment was false.
  • P3.2: the app retains the host's last RawInput::max_texture_side (default 16 384) and reports “no usable surface” and “over the host's per-side texture limit” as distinct admission errors.
  • P3.3: screenshot encoding no longer makes the extra flat RGBA Vec copy; Rgba8::from_pixels fills the image's own buffer once. MAX_SHOT_WORKING_SET_BYTES documents the bound: reserved images (≤ 256 MiB) + one Rgba8 copy + encoded PNG (each ≤ 128 MiB) + 64 KiB zlib/framing slack. Encoding stays on the UI thread, one image at a time. The MCP layer's decode/base64 transient is outside the app process path and remains bounded by its own 1600 px default.
  • P3.4: apps/lightcraft-web/src/bench.rs now measures first_frame_ms and thumbs_done_ms from WebApp construction (monotonic_ms() origin), not navigation start. Old lightcraft-bench samples are therefore not comparable; record the origin with every sample. web.rs's “first UI frame at perf_now()” log is a different origin in the same file and is diagnostic only.
  • P3.5: Headless::step no longer carries a second ViewportCommand::Screenshot completion arm; HeadlessView::run is the only headless completion point.
  • P4: the AccessKit language is cached instead of allocating through Context::language() on every logic pass; the wasm drop branch names WebApp as the queue consumer; the legacy panel eviction comment now says the old keys are unreachable anyway and only keeps egui memory tidy, with a test that the four legacy keys are removed and the parent-salted keys survive.

Still open from the review: P3.3's MCP decode/base64 transient (documented, not reserved), the web RequestPaintWhileHidden gap, #8748/#8763/E21b/W15P/W27, strict kittest findings, and all runtime/visual/performance acceptance. The tests above are deterministic source oracles, not runtime evidence.

Review-test repairs — 2026-10-24 (source only)

The managed review-test job egui037-lightcraft-candidate-20261024-review-tests-13 reported 1155 library passes, 106 failures, 4 ignored, plus 7 of 8 integration tests and the shutdown test passing. Grouping the failures gave two root causes, repaired here without relaxing a check:

  • Gesture delivery re-checked its own effect. reply_delivered_gestures compared the admission-time view/document/dialog/panel snapshot again after the batch had been consumed. Every click or key whose purpose is to change that state (opening a dialog, leaving the grid, closing a dialog, selecting another photo) was answered with “changed before delivery” although the input had done its job. Delivery now requires only that a completed, nondiscarded pass consumed the batch. Freshness stays fail-closed twice: at resolution (a later completed layout must match the admission snapshot) and in drop_gestures_stale_before_injection immediately before a waiting gesture's first batch is injected, so a hidden logic tick cannot retarget it; later batches of the same gesture may see its own partial effect, and a concurrently queued gesture is checked when its events reach the head. A changed snapshot before the fresh layout still cancels, and a stale pre-injection batch is cleared and answered with an error.
  • Unlabelled dropdowns. egui 0.37's ComboBox::from_id_salt publishes the selected text as the node's value but no accessible name, and kittest 0.37's strict accessibility check rejects it. widgets::name_combo now gives every dropdown a semantic label (copyright status, keyword set, proof profile, denoise limit, smart-album match/field/operator/value, export/contact-sheet/import/ settings/credits/print dropdowns), so no strict check was disabled.

One lifecycle test assumed the app opened in the photo grid while UiState defaults to Detail, so its “view changed” setup was a no-op; it now changes to PhotoGrid explicitly. New regression tests cover a delivered gesture that opens a dialog (stays ok) and a view change between resolution and injection (answers once with canceled and injects nothing).

No build, test, fmt, clippy, GUI, browser, Windows or performance run was made for this amendment; the parent runs the managed jobs. The 106 failures and the ComboBox finding are the only failures inspected in that output; any further finding is reported separately rather than folded in here.