124 KiB
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.tomlandxtask/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/lightcrafthas directwinit = "0.30"withx11only oncfg(all(unix, not(target_os = "macos")));apps/lightcrafthasrfdonly on non-wasm;zbusonly on Linux;muda,objc2,objc2-app-kit,objc2-foundationonly on macOS;apps/lightcraft-webputs eframe/egui/wasm-bindgen/web-sys undercfg(target_arch = "wasm32");lightcraft-ui-eguihasrust-version = "1.95"and dev-dependencyegui_kittestwithwgpuandsnapshot.
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::logicandApp::uiare both called inside theContext::run_uiclosure, so both run once per layout pass (a discard can produce more than one).raw_input_hookis called once per hostupdate, beforerun_ui(and beforerun_logicon a hidden logic-only update), not per pass. - Web (
eframe/src/web/app_runner.rs): the same —logicthenuiinside therun_uiclosure, per pass. - Headless (
crates/ui-egui/src/headless.rs:320-325):Headless::stepcallsHeadlessView::run(raw, |ui| { app.logic(ui.ctx()); app.ui(ui) }), which isctx.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_passtakes the output withmem::take;run_dynrestores the count only immediately before the next pass, andrun_logicdoes not restore an old index). During a second pass it is 1, 2, …. ViewportInfo::visible()derives from minimized/occluded and isNonewhen unknown. A hidden window can still run a full UI pass:egui-winit'sActionRequested::ScreenshotandPaintWhileHiddenboth reportwants_paint(), and eframe treats that asis_visiblefor the pass (wgpu_integration.rs:687-704,glow_integration.rs:680-693). In that forced paintvisible()still reportsSome(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:
- One consumer per queue.
drain_controltakescontrol_rxout, drains withtry_recv, puts it back; replies are sent exactly once. The screenshot slot istaken once. Worker results carry a generation id and are applied once. There is no "run the queue again" path. current_pass_index() == 0guards once-per-host-frame work: dropped-file consumption, input-reading halves (pump_ui_input) and perf update, andimport::tick's batch commit. A hiddenrun_logicalso 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 inlogicbehind 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", useContext::cumulative_frame_nr()(one perrun_ui), not the pass index and notcumulative_pass_nr.- Input lives in the UI phase.
shortcuts::handle, dropped-file handling and the external-editor focus check move out oflogic_innerintoui_pass. A hiddenrun_logictick can no longer re-read the last pass'sdropped_files(today it would spawn the same import again) and cannot consume a queued key. - Only refreshed window state is safe in
logic.run_logicrefreshesRawInput::viewports/RawInput::focusedonly;InputState::focused,InputState::timeand events stay from the last pass. Readi.viewport()/i.raw.focusedinlogic, andi.focused/i.timeonly in the UI phase. - No freshness inference from visibility.
Some(false)+ a runningApp::uiis a normal forced paint (pending screenshot orRequestPaintWhileHidden);Noneis normal for headless andRawInput::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). - First call / first frame.
tick_nowis initialised at construction and every timer is anOption/deadline comparison that is true on the first tick, soHeadless::stepat time0.0andctx.run_ui(RawInput::default(), …)(whose input time isprev + predicted_dt, not 0) both run their first logic work. Add explicit tests for both, plusvisible() == Noneand a forced hidden paint (§6.1). - No
will_discardprediction. 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_nowfor:import::tick(additionally gated bycurrent_pass_index() == 0so a discarded pass cannot commit twice),lightroom_import::tick,recheck, slideshow playback (panels/slideshow.rs:157-183; auto-paused whileViewportInfo::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 whennow >= 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) andpanels::toast(panels/mod.rs:80) readtick_now, notctx.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 inlogicand today usesctx.input(|i| i.time); give it the same monotonic deadline. Headlessexposes its simulated time through the same clock (a test hook; the currentHeadless::timestarts at0.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::pumpis called twice in one pass (lib.rs:1139and: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_inboxandload_originalsdrain queues on every call (correct — they are consumed queues), whileimport_droppedre-readsraw.dropped_fileson 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 inraw_input_hook.panels::denoise::pump(denoise.rs:45-52) andpanels::faces::pump(faces.rs:705-712) readctx.inputkeys/pointer/i.timeto pace background work. Split each intopump_background(download/index worker results, monotonic deadlines — safe inlogic, including hidden) andpump_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::savestays guarded by its ownlast_savedeadline, 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_gestureslist with their original params andadmitted_epoch = layout_epoch;layout_epoch(a monotonic counter, not a hash) is incremented at the end of every completedApp::uipass. A request resolves, using that pass'sapp.widgets/app.image_rect, at the end of the first pass wherelayout_epoch > admitted_epoch. If no UI pass has happened since admission, the request stays pending and the app sendsViewportCommand::RequestPaintWhileHidden(whenctx.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_hookis called beforerun_uiand beforerun_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 ofui_pass, never byvisible()) has consumed that step. The step remains in eframe'spending_raw_inputacross 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
syntheticis non-empty (a drag has many moves; a click is move+down+up) the app sendsRequestPaintWhileHiddenagain 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.deliveredflag 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.textandui.scrollneed no geometry but do need a UI pass to be consumed (ctx.request_repaint()alone only runsrun_logicwhile hidden), so they follow the same request-paint/one-reply pattern.ui.screenshotis not a gesture, but it has two phases. Once theViewportCommand::Screenshotis processed,ActionRequested::Screenshot::wants_paint()makes the integration paint a hidden window. A capture that is merely queued or settling inlogichas not reached that phase: while hidden it needsRequestPaintWhileHiddenfromlogic(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_epochis au64incremented once at the end of every completed, nondiscardedApp::uipass (the same placeui_passesadvances).PendingGesturerecordsadmitted_epoch, an admission-timeGestureFrame(view, active photo, library generation, dialog discriminant, left/right/presets/filmstrip switches), a monotonicdeadline_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.zoomandui.clipboardare queued. Their existing parsing/event construction moves to onecontrol::gesture_events(&mut app, ctx, method, params)function, called only after a completed layout newer than admission. Widget ids andapp.image_recttherefore 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_passafterlayout_epoch += 1. Before computing geometry the request'sGestureFramemust 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-framelayout_epochadvance 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) answersno completed UI frameat 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.syntheticis 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 sendingRequestPaintWhileHiddenwhile any gesture is unresolved or resolved-but-undelivered; a visible run keepsrequest_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
GestureFramecancellation 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 perrun_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
Weakreference to the app-owned completion slot; the app owns the strong slot and a live, owner-specific completion waker. The callback receives pixels only: noContextclone, 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 aContextreference cycle;Weaklets cancel/close drop the app state and makes a late result harmless. A pending capture keepsrequest_repaint_after(16 ms), which also runsrun_logicon 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_pointor 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 checkedwidth * height * 4before 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
Absenttarget must still be absent; a file created after admission (ordinary file, imported original, hard link or symlink) is refused, never replaced; - an
Existingtarget must still be the same directory entry and must passOriginalGuardagain 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_newservice (lightcraft_engine::export::write_file_new→safe_file::write_new): it prepares a complete temp file and publishes it withcreate_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 configurewrite_newfor 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::runretainsViewportCommand::Screenshotcallbacks and callscallback.complete(Arc::new(self.paint()))after the pass;Headless::stepdeletes itsViewportCommand::Screenshot(user_data)arm (it no longer compiles in 0.37) and no longer pushesEvent::Screenshot. Completion happens exactly once, in the headless host. headless_hostremains the CLI/headless flag;WebAppdoes 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/pngstay LightCraft policy inlightcraft-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_fontsandmonochrome_emoji_fontson the workspaceeguidependency, and keep eframe defaults off in both apps while adding nativeclipboard(§2.1). This is a LightCraft root/workspace decision, not inherited from kittest: kittest's normaleguidependency isdefault-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 throughcargo tree -e features/cargo metadata, not through the lockfile (§5.3); a consumer test root without the workspace features would otherwise see an emptyFontDefinitions::default(). - Tests: assert
FontDefinitions::default()containsHack,Ubuntu-Light,egui-icons,NotoEmoji-Regularandemoji-icon-font(epaint/src/text/font_definitions.rs:128-159; the family key isUbuntu-Light, notUbuntu); 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-705compares againstFontDefinitions::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 toassets/ATTRIBUTION.md(nothing new is committed).CRAFT_FONTS_DIRstays the app's font input; a future bundled font belongs in craft-fonts. - Keep
egui_system_font_provider/system_fontsoff 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
MissingGlyphPolicystaysTofu; kittest keepsPanic(HarnessBuilderdefaults in 0.37 aremissing_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 viaResponse::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) fromi18n::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::MouseWheelinitializers incontrol.rs,headless.rs, examples and tests getsource: egui::MouseWheelSource::Unknown;ui.scrollaccepts 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/filmstripMouseWheelUnit::Linepath and trackpad/momentumTouchPhase::Start/Endcycles, which the point/move-only API could not represent.docs/control-protocol.mddocuments the three optional keys. Unit tests assert wheel/line/start, trackpad/point/end and the backward-compatible omitted defaults.raw_input_hookkeeps the §4.5 one-step-per-UI-pass contract; a logic-only tick must not drain another step.Event::PasteImageandMiddleClickPasteare 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, andegui::IMEOutputcarries no logical text. The complete shared contract is in egui plan P7b andime-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 whatPlatformOutput::appenduses (replaceime.or(self.ime)); descriptor, owner and payload are one atomic value; - focus epochs and protocol serials are tracked separately: a current-epoch
donewith 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
TextEditchange-detection/undo clones are a separately measured baseline, not rewritten here; - built-in
TextEditproduces 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 customIMEOutputproducer: itsTextFieldwraps the built-inTextEditand other panels useTextEditdirectly, so it inherits the producer and must test the inherited behaviour. The family's custom editors (FilmCraftpanels/graphics.rsand the others inime-surrounding-gap.md) need real logical-model/preedit/UTF-8-index adapters before family acceptance;Noneplaceholders 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
clipboardfeature (§2.1), normal cut/copy/paste throughui.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.2PrimarySelectionManagerState/device/source/offer; never a second regularwl_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-WouldBlockpoll 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=webgpuand 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-
selector 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 perImageDeltaand texture wrap. kittest usesRendererOptions::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::logicbecomesself.0.logic(ctx);then the native menu andPrefsWriter::tick(both on the monotonic clock).App::uistays oneself.0.ui(ui). Do not callself.0.logicagain fromui.PrefsWriter::tickmust not recordSeen::ofwindow geometry from a hiddenrun_logicpass whosecontent_rectis 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, thenself.app.logic(ctx), thensave(own monotonic deadline), then the bench step (UI phase).WebApp::uiremains oneself.app.ui(ui)plus the first-frame log.import_droppedmoves into the UI phase and consumes the queue filled once byWebApp::raw_input_hook, so neither a later pass nor a hidden tick can re-import the same drop. - Keep
raw_input_hookonce 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 intoui, where it would repeat on multipass. - Keep
PrefsWriterandUiStateJSON unchanged. The only visible ID change is egui memory/panel layout;UiStatedocking, 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 historical8614e4ashape and the1764bcaREADME hunk); those commits and the earlier49190884278f16c9a9f5823d7698a8ffb5deb062main are history, not the base to apply again. - PR 199 head
b68de8c5f4f1c060904d211a7b1320aa0f7bce78and the audit mergeec8840af8c5fda695cf195cdd05e5ae078415c07misscraft-platformand revert newer security/settings/docking/printer fixes. Never merge either as a tree. - Keep the
craft_ui::capture::CaptureSlotconsumer 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/epaintfamily source and onewinitsource, all at the expected git URL and full revision; no registry copy beside them;craft-uiandcraft-docpathat the reviewed revision, not a branch or a path; - resolved features from
cargo metadata+cargo tree -e featuresfor every accepted root (native, headless/CLI, tests, wasm):eguihasdefault_fontsandmonochrome_emoji_fonts; nativeeframehasclipboard; the wasm root does not accidentally pullwgpu;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, oldcraft-ui, machine-local path, feature-lessegui(the last one only through the metadata/tree mechanism — a lockfile-only check cannot catch it); wasm-bindgenin the lock matches the CLI (xtask/src/web.rsalready 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:1161request_discardpath 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.clickWidgetwith 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 receivesRequestPaintWhileHiddenwhile 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_logiccalls 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 invokesraw_input_hookleaves 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() == 0each 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::stepat simulated time0.0runs logic and lays out; a harness that callsctx.run_ui(egui::RawInput::default(), …)runs logic;visible() == Noneis 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
RequestPaintWhileHiddenbefore 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 throughHeadlessView::runandHeadless::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.pointerwithspace: "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
UiStateJSON round-trips and docking/floating geometry survives unchanged. - Docking and second window. Real drag → float → close → reopen and
ui.dockinvalid 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 belowMIN_PHOTO_WIDTH; - the
resizable_sidetest asserts the drag edge is live through the correctedpid(and fails if the panel is constructed with a pre-hashedIdSalt); - 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.rsto the rolesis_inputuses (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)plusctx.accesskit_node_builder(resp.id, |node| node.set_expanded(open)). They must not useWidgetInfo::selected, which fills AccessKitToggledrather thanExpanded. A unit test assertsis_expanded()istrue/falseforwidgets::section_header; the same helper is used bywidgets::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::uniquepreservesId::new's hash, but a wrongIdSalt/scope_idin 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_sidewith the old exactIdor a pre-hashedIdSalt: the drag test fails to see the resize widget / does not store the width;- one unnamed input in a scratch kittest harness:
check_accessibilitypanics; - one unsupported glyph:
MissingGlyphPolicy::Panicpanics andTofudoes not; - a hidden
ui.clickWidgetwhile 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_bench200 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_latencyon 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_benchwith blocked rendering for allocation counts; a long menu open with wheel/arrow scroll, 1,000 actual frames. - Opening photos:
open_timelinewith 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 additiveperf.framesonly 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 webraw and compressed WASM size (raw, gzip, brotli; separately record the effect ofmonochrome_emoji_fonts),?benchfirst 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
- 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/8b35388bare comparison references only; no stock or intermediate stage is a production dependency. - 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 theTextEditproducer and W27 adapter is not in the inspected candidate. LightCraft's built-inTextEditconsumers inherit it, and the family's custom producers (e.g. FilmCraftpanels/graphics.rs) need their own logical-model adapters before family acceptance. - 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.
- 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
3832c47ef68a0e6214f2c1e85f3526b6adcb686ebase; PR 199's head and the audit merge are stale trees and8614e4a/1764bcaare already contained in that base. - 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.
- 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.
- 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.
- 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. - PRIMARY/clipboard on GNOME/Mutter needs a real compositor; a control-channel paste is not a substitute.
- macOS: no real hardware evidence for menu, IME, Metal/GL, termination or AccessKit teardown; an explicit rollout blocker, not a pass.
- Web workflow: no control server and no
ui.screenshotcommand; browser capture plus observablecommandstate is the acceptance path unless a separate download/writer API is separately reviewed and approved. - Visual deltas: 0.37 style/scrollbar/menu changes may alter reviewed screenshots; no bulk golden refresh.
- Lock/feature drift:
accesskit/kittestmovement,wasm-bindgenCLI matching, Nix git fetching and a secondegui/winitsource must pass the dependency check. - 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 isa4f9a17f64a6bae61350fb85804c9df0d9865916, notmain. - 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.rsrole 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_uipanic 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_kittestsource. - 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.mdand 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
eguiis now0.37withdefault-features = falseandfeatures = ["default_fonts", "monochrome_emoji_fonts"];eframeandegui_kittestare0.37(egui_extrasis declared at0.37although LightCraft still does not use it). - Native
eframeadds the separate 0.37clipboardfeature; web keepsdefault_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_037asserts theHack,Ubuntu-Light,egui-icons,NotoEmoji-Regularandemoji-icon-fontfaces 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.tomland 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) andUi::id()→Ui::scope_id()across the UI crate and tests.WidgetType→egui::Rolewith the documented variant mapping (CollapsingHeader→DisclosureTriangle,SelectableLabel→Button,ComboBoxstaysComboBox).Panelsalts:resizable_sidenow passes the original salt toPanel::left/right, derives the persisted key withui.scope_id().with(id), and checks the resize drag on that key (the old code passed a prebuiltIdand would be salted twice). 0.36 exact-IdPanelStateentries fortop_bar,bottom_bar,tool_stripandlibrary-problem-bannerare removed once at startup; this resets only egui panel memory, neverUiState, docking JSON, catalog or XMP data.ScrollArea::State.offsetis private: menu arrows store a one-framevertical_scroll_offsettarget, and the right panel readsclamped_offset().Event::MouseWheelinitializers carrysource;ui.scrollaccepts optional"source","unit"and"phase"with the validated values and defaults from §4.9.ComboBoxhas the documented lifetime; atoms takeIdSalt(Atom::custom,WidgetAtomResponse::rect).ViewportCommand::Screenshotnow uses the one-shot callback andEvent::Screenshot/UserDataare gone. Both headless entry points answer the callback with their painted frame exactly once.- The fork's
Context::set_languageis fed fromi18n::language()(the AccessKit root language and bidi runs follow the UI locale; command ids and user data stay logical).
Lifecycle and capture stage (L2b)
LightcraftAppnow has a monotonicAppClock(web_time::Instant, neveri.timeorDate.nowfor timing;now_msremains wall-clock only forlibrary-restored-<ms>names). Headless hosts feed it their simulated time; the firstHeadless::stepruns at 0.0.logickeeps queues, worker polls and time-gated work. Import committing is gated oncurrent_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.uiis split intoui_pass(first pass only: fresh input, dropped files, external-editor focus, shortcuts, perf) anddraw_window(presentation), thenfinish_ui_pass(completed, nondiscarded pass; clears the synthetic in-flight gate). A hiddenrun_logicnever reaches it.- Dropped files are taken once in
raw_input_hookinto an app queue and consumed only in the UI phase; web consumes the same queue inWebApp::ui(it needsbytes_async). Native and web tests cover that a hidden logic tick leaves the queue intact. raw_input_hookinjects 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 withRequestPaintWhileHiddenwhile 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,WebAppnotices/save and the nativePrefsWriteruse 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-
Idstate); - 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 incrates/ui-egui/src/lib.rsmust 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 nodirectionbuilder 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. accesskit0.24 → 0.25 andkittest0.4 → 0.5 churn is not pinned here; check that the Linuxzbus = "=5.19.0"pin still resolves to one zbus source, and thatwasm-bindgenin the generated lock matches the CLI.RequestPaintWhileHiddenhas 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_uistatements intests_lifecycle_037.rsbind theirFullOutput(let _ = …) so-D warningsno longer seesunused_must_use. - P2.1: a settled capture is issued at a forced instant (
SHOT_FORCE_BUSY_MS, three seconds from admission) even whenrenderer.in_flight()or the merge is busy; the hard deadline (five seconds) stays the only timeout path.shot_readyis 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 wherei.timefroze while hidden. - P2.6: the “0.37 has no
visible()” rationale was wrong.lib.rsandapps/lightcraft/src/main.rsnow useViewportInfo::visible(); §4.1/§4.2 above are corrected. - P3.1:
import::tickis gated bycurrent_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
Veccopy;Rgba8::from_pixelsfills the image's own buffer once.MAX_SHOT_WORKING_SET_BYTESdocuments the bound: reserved images (≤ 256 MiB) + oneRgba8copy + 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.rsnow measuresfirst_frame_msandthumbs_done_msfromWebAppconstruction (monotonic_ms()origin), not navigation start. Oldlightcraft-benchsamples 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::stepno longer carries a secondViewportCommand::Screenshotcompletion arm;HeadlessView::runis 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 namesWebAppas 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_gesturescompared 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 indrop_gestures_stale_before_injectionimmediately 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_saltpublishes the selected text as the node's value but no accessible name, and kittest 0.37's strict accessibility check rejects it.widgets::name_combonow 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.