diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -282,3 +282,6 @@ sync_github_name = false
 [people.jasallen]
 real_name = "Jason DeBoever"
 sync_github_name = true
+
+[people.mockydevs]
+real_name = "Don Omondi"
diff --git a/crates/gpu/tests/it/timing.rs b/crates/gpu/tests/it/timing.rs
--- a/crates/gpu/tests/it/timing.rs
+++ b/crates/gpu/tests/it/timing.rs
@@ -17,7 +17,7 @@ use std::time::Instant;
 use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
 use photocraft_doc::{Document, Layer, Size};
 use photocraft_geom::Rect;
-use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing};
+use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, Stats, timing};
 
 /// Pixels in `r`, as the compositor counts them.
 fn area(r: Rect) -> u64 {
@@ -92,8 +92,14 @@ fn document(size: Size) -> Document {
 
 /// Render `region` and return (submit ms, waited-for wall ms, the measurement).
 fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>) {
+    let (submit_ms, wall_ms, timing, _) = measure_with_stats(device, queue, comp, doc, region);
+    (submit_ms, wall_ms, timing)
+}
+
+/// [`measure`], plus the refresh's own statistics (how often it submitted, what it uploaded).
+fn measure_with_stats(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>, Stats) {
     let t0 = Instant::now();
-    comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");
+    let stats = comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");
     let submit_ms = t0.elapsed().as_secs_f64() * 1000.0;
     let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
     let wall_ms = t0.elapsed().as_secs_f64() * 1000.0;
@@ -106,9 +112,12 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do
         }
         let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
     }
-    (submit_ms, wall_ms, timing)
+    (submit_ms, wall_ms, timing, stats)
 }
 
+/// The reported GPU time of a GPU-bound 6 MP refresh is its execution, not its encode-and-submit:
+/// several times the submit time, within the waited-for wall clock and most of it, and scaling
+/// with the pixels refreshed (a quarter of the canvas costs well under the whole).
 #[test]
 fn gpu_time_tracks_execution_not_submission() {
     let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -120,19 +129,21 @@ fn gpu_time_tracks_execution_not_submission() {
     // Warm the residency and pipelines: the first refresh uploads every layer.
     let _ = measure(&device, &queue, &mut comp, &doc, full);
 
-    // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`,
-    // and CPU contention on a shared machine only ever inflates it — one run that happened to
-    // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison
-    // while the measurement itself was right. The GPU side is unaffected by that contention,
-    // so taking the least-disturbed encode tests the same property without the false failure.
+    // Best of five, by the shortest waited-for refresh. Everything below compares the GPU span
+    // with `submit_ms` and `wall_ms`, and contention on a shared machine only ever inflates those
+    // two: one run that happened to encode behind a busy scheduler (8.2 ms against a typical
+    // 1.0 ms) failed the comparison, and on an RX 5700 XT about one wait in five took ~11 ms
+    // against a typical 1.3 ms while the GPU span stayed at 1.1 ms. The span is unaffected by
+    // that contention, so taking the least-disturbed refresh (whose wall clock includes its
+    // encode) tests the same property without the false failure.
     let mut best: Option<(f64, f64, Option<GpuTiming>)> = None;
-    for _ in 0..3 {
+    for _ in 0..5 {
         let run = measure(&device, &queue, &mut comp, &doc, full);
-        if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) {
+        if run.2.is_some() && best.as_ref().is_none_or(|b| run.1 < b.1) {
             best = Some(run);
         }
     }
-    let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time");
+    let (submit_ms, wall_ms, timing) = best.expect("at least one of five refreshes reports a GPU time");
     let t = timing.expect("a hardware device with timestamp queries must report a GPU time");
     eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms);
 
@@ -177,40 +188,183 @@ fn an_unmeasured_compositor_reports_nothing() {
     assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one");
 }
 
+/// A refresh that flushes several command buffers under a tight memory budget reports one
+/// measurement with a span per command buffer. Its summed time stays within the waited-for wall
+/// clock and reaches at least 60% of the same composite's time in a single command buffer, which
+/// timing only the last command buffer would miss.
 #[test]
 fn a_refresh_that_submits_several_times_is_one_measurement() {
     let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
-    let Some((device, queue, mut comp)) = gpu() else { return };
+    let Some((device, queue, mut batched)) = gpu() else { return };
     // A budget far below the document's layer pages makes the refresh evict as it goes, which is
     // what makes it submit in batches: the measurement must span all of them, not just the last.
-    comp.set_memory_budget(24 << 20);
+    batched.set_memory_budget(24 << 20);
+    // The same composite with every page resident, in one command buffer: the reference for the
+    // GPU work the batched refresh's spans must add up to.
+    let mut whole = Compositor::new(&device);
+    assert!(whole.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build");
+    // Both draw every pass: a reused lower-stack composite would make either do less work.
+    batched.set_composite_cache_budget(0);
+    whole.set_composite_cache_budget(0);
     let size = Size::new(3000, 2000);
     let doc = document(size);
     let full = Rect::from_xywh(0, 0, size.width, size.height);
 
-    let _ = measure(&device, &queue, &mut comp, &doc, full);
-    let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full);
-    let t = timing.expect("a batched refresh is measured like any other");
-    eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms);
-    assert_eq!(t.px, area(full));
+    let _ = measure(&device, &queue, &mut whole, &doc, full);
+    let _ = measure(&device, &queue, &mut batched, &doc, full);
+    // Interleaved, best of six each: GPU clocks ramp and settle between refreshes, and the
+    // fastest run of each is the one at full clock, so the two are compared like for like. The
+    // order alternates by sample, so neither side always runs on clocks and caches the other has
+    // just warmed; an even count gives each order three samples.
+    let (mut one, mut sum) = (f64::INFINITY, None::<(GpuTiming, f64, f64)>);
+    for sample in 0..6 {
+        let resident_first = sample % 2 == 0;
+        let mut resident = if resident_first { measure(&device, &queue, &mut whole, &doc, full).2 } else { None };
+        let (submit_ms, wall_ms, timing, stats) = measure_with_stats(&device, &queue, &mut batched, &doc, full);
+        if !resident_first {
+            resident = measure(&device, &queue, &mut whole, &doc, full).2;
+        }
+        if let Some(r) = &resident {
+            assert_eq!(r.spans, 1, "a resident refresh of this size submits once");
+            one = one.min(r.ms);
+        }
+        let t = timing.expect("a batched refresh is measured like any other");
+        assert_eq!(t.px, area(full));
+        // Structure: every command buffer the refresh submitted is one span of the measurement.
+        assert_eq!(t.spans as usize, stats.flushes + 1, "{} flushes but {} timed command buffers", stats.flushes, t.spans);
+        // Upper bound: the spans run inside the refresh, so they can't exceed its waited-for
+        // wall clock.
+        assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);
+        eprintln!(
+            "sample {sample} ({} first): resident {}; batched {} command buffers, gpu {:.3} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, {} MB uploaded",
+            if resident_first { "resident" } else { "batched" },
+            resident.as_ref().map_or("not reported".to_owned(), |r| format!("gpu {:.3} ms", r.ms)),
+            t.spans,
+            t.ms,
+            stats.bytes_uploaded >> 20
+        );
+        if sum.as_ref().is_none_or(|(b, ..)| t.ms < b.ms) {
+            sum = Some((t, submit_ms, wall_ms));
+        }
+    }
+    let (t, submit_ms, wall_ms) = sum.expect("six batched refreshes were measured");
+    assert!(one.is_finite(), "the resident refresh reports a GPU time");
+    eprintln!("best: batched {:.2} ms in {} command buffers, resident {one:.2} ms in one", t.ms, t.spans);
     if t.spans < 2 {
         eprintln!("SKIP the sum: this refresh still fit one command buffer");
         return;
     }
-    assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);
-    // What this bound excludes is summing only the last command buffer. A correct sum measures
-    // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so
-    // no single buffer of the several carries much over half of it, and timing only the last
-    // would land near 40 %. 0.6 sits between the two with room on both sides for a machine
-    // whose split is less even than this one's.
+    // Lower bound. The batched refresh draws the same passes over the same pixels as the
+    // resident one, so its spans together carry at least that GPU work; timing only the last
+    // command buffer would report a fraction of it. (`a_split_measurement_sums_every_command_buffer`
+    // proves the sum itself with equal, known batches.)
+    //
+    // Not against the wall clock: a batched refresh evicts and re-uploads its pages, and
+    // `Queue::write_texture` uploads run in wgpu's own command buffer outside the spans by design
+    // (see `timing.rs`). On an integrated GPU the composite dominates the wait, but on a discrete
+    // one (RX 5700 XT) the composite took ~2 ms of a ~25 ms wait that was PCIe uploads and fence
+    // latency, so a `wall - submit` bound fails a correct sum there.
     assert!(
-        t.ms >= (wall_ms - submit_ms) * 0.6,
-        "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?",
+        t.ms >= one * 0.6,
+        "summed gpu time {:.2} ms over {} command buffers is well below the {one:.2} ms the same composite takes in one: is only the last command buffer timed? ({submit_ms:.1} ms submit, {wall_ms:.1} ms wall)",
         t.ms,
-        wall_ms - submit_ms
+        t.spans
     );
 }
 
+/// The sum itself, with known work: the same GPU work recorded into one command buffer, or split
+/// over several that are submitted one after the other (as a flushing refresh does), measures
+/// the same. Timing only the first or the last of four equal batches would report a quarter.
+#[test]
+fn a_split_measurement_sums_every_command_buffer() {
+    let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+    let Some((device, queue, _)) = gpu() else { return };
+    const BATCHES: usize = 4;
+    // One batch: a 64 MiB buffer copied four times (well inside every adapter's default limits),
+    // a few milliseconds on an integrated GPU and about one on a discrete one.
+    const BYTES: u64 = 64 << 20;
+    let buffer = |usage| device.create_buffer(&wgpu::BufferDescriptor { label: None, size: BYTES, usage, mapped_at_creation: false });
+    let (src, dst) = (buffer(wgpu::BufferUsages::COPY_SRC), buffer(wgpu::BufferUsages::COPY_DST));
+    let batch = |encoder: &mut wgpu::CommandEncoder| {
+        for _ in 0..4 {
+            encoder.copy_buffer_to_buffer(&src, 0, &dst, 0, BYTES);
+        }
+    };
+    let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries");
+    let new_encoder = || device.create_command_encoder(&Default::default());
+    let land = |timer: &mut timing::GpuTimer| {
+        let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
+        (0..8)
+            .find_map(|_| {
+                let t = timer.take();
+                if t.is_none() {
+                    let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
+                }
+                t
+            })
+            .expect("the measurement lands")
+    };
+    let one_buffer = |timer: &mut timing::GpuTimer| {
+        let mut encoder = new_encoder();
+        timer.start(&mut encoder, 1);
+        for _ in 0..BATCHES {
+            batch(&mut encoder);
+        }
+        timer.finish(&mut encoder);
+        queue.submit([encoder.finish()]);
+        timer.submitted();
+        land(timer)
+    };
+    let split = |timer: &mut timing::GpuTimer| {
+        let mut encoder = new_encoder();
+        timer.start(&mut encoder, 1);
+        for i in 0..BATCHES {
+            batch(&mut encoder);
+            if i + 1 < BATCHES {
+                let mut next = new_encoder();
+                timer.split(&mut encoder, &mut next);
+                queue.submit([std::mem::replace(&mut encoder, next).finish()]);
+            }
+        }
+        timer.finish(&mut encoder);
+        queue.submit([encoder.finish()]);
+        timer.submitted();
+        land(timer)
+    };
+
+    let _ = one_buffer(&mut timer);
+    let _ = split(&mut timer);
+    // Interleaved, best of six each, alternating which runs first by sample: neither side always
+    // runs on clocks and caches the other has just warmed, and each order gets three samples.
+    let (mut one, mut sum) = (f64::INFINITY, f64::INFINITY);
+    for sample in 0..6 {
+        let one_first = sample % 2 == 0;
+        let (one_ms, t) = if one_first {
+            let one_ms = one_buffer(&mut timer).ms;
+            (one_ms, split(&mut timer))
+        } else {
+            let t = split(&mut timer);
+            (one_buffer(&mut timer).ms, t)
+        };
+        eprintln!(
+            "sample {sample} ({} first): one command buffer {one_ms:.3} ms, split over {} {:.3} ms",
+            if one_first { "one buffer" } else { "split" },
+            t.spans,
+            t.ms
+        );
+        assert_eq!(t.spans as usize, BATCHES, "every command buffer is one span");
+        one = one.min(one_ms);
+        sum = sum.min(t.ms);
+    }
+    eprintln!("{BATCHES} batches: one command buffer {one:.2} ms, split over {BATCHES} {sum:.2} ms");
+    assert!(one > 0.0, "the copies took measurable GPU time");
+    // Equal batches: losing one of four spans reports about 75% of the work even when the
+    // span count is still four. Keep the lower bound above that failure, with 15% room for
+    // clock variation between the interleaved single-buffer and split measurements.
+    assert!(sum >= one * 0.85, "split over {BATCHES} command buffers measured {sum:.2} ms against {one:.2} ms in one: not every span is summed");
+    assert!(sum <= one * 1.5, "split over {BATCHES} command buffers measured {sum:.2} ms against {one:.2} ms in one: spans counted twice?");
+}
+
 #[test]
 fn refreshes_faster_than_the_readback_stay_valid() {
     let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
diff --git a/crates/ui-egui/src/canvas/cpu_background_tests.rs b/crates/ui-egui/src/canvas/cpu_background_tests.rs
--- a/crates/ui-egui/src/canvas/cpu_background_tests.rs
+++ b/crates/ui-egui/src/canvas/cpu_background_tests.rs
@@ -67,6 +67,9 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
 /// The CPU-canvas fixture on the offscreen software adapter, or `None` when this machine has no
 /// graphics adapter. Only creating the renderer may be skipped: a failing fixture command or a
 /// panic in the first frames must fail the test, not pass it as "no adapter".
+///
+/// Callers hold [`photocraft_testkit::gpu_lock`] for the whole test, like every other GPU harness:
+/// building wgpu devices on parallel test threads segfaults on some drivers (testkit `gpu.rs`).
 fn harness(size: [u32; 2], background: &str) -> Option<Harness> {
     let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
     // app.gpu remains None: compose on the CPU, use the harness's software window adapter.
@@ -117,8 +120,12 @@ fn fixture_failures_are_not_skipped_as_a_missing_adapter() {
     let _ = harness([0, 0], "white");
 }
 
+/// Skipping the transparency checker behind an opaque CPU canvas renders the same pixels as
+/// drawing it, across rotation, horizontal flip and pattern preview. Set
+/// `PHOTOCRAFT_CPU_PIXEL_OUTPUT` to a directory to save both frames and a difference report.
 #[test]
 fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() {
+    let _gpu = photocraft_testkit::gpu_lock();
     let Some(mut h) = harness([320, 240], "white") else {
         eprintln!("skipping: no offscreen graphics adapter");
         return;
@@ -157,8 +164,12 @@ fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() {
     }
 }
 
+/// With pattern preview on, the opaque CPU canvas still submits the same checker meshes as the
+/// reference path for the document and each visible tiled copy, rotated or flipped; with it off,
+/// the hidden checker is omitted.
 #[test]
 fn pattern_preview_keeps_the_legacy_checker_for_every_copy() {
+    let _gpu = photocraft_testkit::gpu_lock();
     let Some(mut h) = harness([320, 240], "white") else {
         eprintln!("skipping: no offscreen graphics adapter");
         return;
@@ -195,10 +206,15 @@ fn pattern_preview_keeps_the_legacy_checker_for_every_copy() {
     assert!(checker_meshes(&h).is_empty(), "ordinary opaque CPU canvases still omit the hidden checker");
 }
 
+/// Frame time of a 24 MP CPU canvas with and without the occluded checker, for an opaque and a
+/// transparent document: 24 alternating-order samples per path, reported as p50/p95 JSON on
+/// stdout (and in `PHOTOCRAFT_CPU_BENCH_OUTPUT`, with before/after frames, when set). Run in
+/// release with `--ignored`; it also asserts both paths render the same pixels.
 #[test]
 #[ignore = "manual release benchmark: 24 MP CPU canvas on the offscreen software adapter"]
 fn cpu_canvas_background_benchmark() {
     use std::time::Instant;
+    let _gpu = photocraft_testkit::gpu_lock();
     let mut results = Vec::new();
     let output = std::env::var_os("PHOTOCRAFT_CPU_BENCH_OUTPUT").map(std::path::PathBuf::from);
     for background in ["white", "transparent"] {
diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs
--- a/crates/ui-egui/src/panels/brush_panel/tests.rs
+++ b/crates/ui-egui/src/panels/brush_panel/tests.rs
@@ -1338,7 +1338,16 @@ fn app_harness() -> Harness<'static, PhotocraftApp> {
     app_harness_with_language(crate::i18n::Lang::EN)
 }
 
+/// [`app_harness`] with its UI drawn in `lang`, under kittest's strict missing-glyph policy, so
+/// every label of the Brush tool's options bar, settings window and preset picker must shape in
+/// that language from the first frame.
 fn app_harness_with_language(lang: crate::i18n::Lang) -> Harness<'static, PhotocraftApp> {
+    // Kittest's strict missing-glyph policy panics in the first frame that draws a CJK label. The
+    // font provider serves it then, but on Linux it may wait for the background Fontconfig lookup
+    // when no known font path has the script, so let that finish first (as the other strict
+    // all-language tests do).
+    #[cfg(target_os = "linux")]
+    assert!(crate::cjk_fonts::wait_for_fontconfig_cjk_scan(), "Fontconfig CJK lookup did not finish within 10 seconds");
     let mut app = app();
     app.ui.tool = crate::state::Tool::Brush;
     let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(
diff --git a/crates/ui-egui/src/text/cjk_fonts.rs b/crates/ui-egui/src/text/cjk_fonts.rs
--- a/crates/ui-egui/src/text/cjk_fonts.rs
+++ b/crates/ui-egui/src/text/cjk_fonts.rs
@@ -30,12 +30,13 @@
 //! Builds made with the optional craft-fonts input (`CRAFT_FONTS_DIR`,
 //! [`photocraft_text::craft_fonts`]) carry Japanese, Chinese, Korean and other faces. A
 //! per-character [`FontProvider`] serves them, and the system font a locale defers to, in the
-//! first frame; the lazy loader remains the later fallback. The provider has no lifetime read
-//! cap. It retains at most [`MAX_SYSTEM_CACHE_BYTES`] of unique font-file blobs, charging a
-//! collection (TTC) once however many faces it selects; a face that cannot be retained is
-//! returned transiently for the current request, so a later Fontconfig candidate is never
-//! skipped merely because earlier characters read other fonts.
-//! Without craft-fonts (and on the web, which never embeds them) nothing changes.
+//! first frame; the lazy loader remains the later fallback. Without craft-fonts the same
+//! provider serves the system fonts alone, so the first frame needs no embedded face either.
+//! The provider has no lifetime read cap. It retains at most [`MAX_SYSTEM_CACHE_BYTES`] of
+//! unique font-file blobs, charging a collection (TTC) once however many faces it selects; a
+//! face that cannot be retained is returned transiently for the current request, so a later
+//! Fontconfig candidate is never skipped merely because earlier characters read other fonts.
+//! The web, which never embeds craft-fonts and has no system font files, installs no provider.
 
 use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
 use egui::{FallbackRequest, FontData, FontFamily, FontId, FontProvider, Shape};
@@ -81,10 +82,11 @@ pub struct Sources {
     /// provider and lazy loader both use this injection (tests can therefore pin craft-font
     /// selection without depending on the build input).
     pub embedded: fn(&'static str) -> Vec<&'static CraftFont>,
-    /// Install the per-character craft-font provider, so a headless first frame can shape CJK
-    /// and other scripts before the lazy loader gets a second pass (tests panic on a missing
-    /// glyph). It selects a face for the character's script instead of putting a broad face into
-    /// the family stack up front for every later character.
+    /// Install the per-character provider (craft faces when the build has them, then system
+    /// faces), so a headless first frame can shape CJK and other scripts before the lazy loader
+    /// gets a second pass (tests panic on a missing glyph). It selects a face for the
+    /// character's script instead of putting a broad face into the family stack up front for
+    /// every later character.
     pub eager_craft_fonts: bool,
 }
 
@@ -977,6 +979,10 @@ pub fn install(ctx: &egui::Context) {
     install_with(ctx, Sources::system());
 }
 
+/// [`install`] with injected [`Sources`]: (re)installs the lazy fallback loader plugin, resetting
+/// one already present, and, when `sources.eager_craft_fonts` is set, the per-character provider
+/// that serves craft faces (if the build has them) and system faces in the first frame. Tests use
+/// it to pin locale, files and coverage without depending on the machine's fonts.
 pub fn install_with(ctx: &egui::Context, sources: Sources) {
     // egui keeps the first plugin of a type. set_fonts removes loaded fallbacks, so reset the
     // existing loader too; otherwise its tried/loaded sets prevent fonts being added again.
@@ -991,9 +997,11 @@ pub fn install_with(ctx: &egui::Context, sources: Sources) {
     {
         ctx.add_plugin(CjkFontPlugin(fallback));
     }
-    // The provider resolves a craft face for each missing character in the first frame, before the
-    // lazy loader's next-pass system fallback. Without it a headless frame panics on CJK text.
-    if sources.eager_craft_fonts && !craft_fonts::CRAFT_FONTS.is_empty() {
+    // The provider resolves a face for each missing character in the first frame, before the lazy
+    // loader's next-pass system fallback. Without it a headless frame panics on CJK text. It is
+    // installed without craft-fonts too: it then serves the system faces alone, exactly the files
+    // the lazy loader would read one frame later, so a strict first frame needs no embedded fonts.
+    if sources.eager_craft_fonts {
         let provider = std::sync::Arc::new(CraftFontProvider::new(&sources, crate::theme::ui_font_scale(ctx)));
         let as_provider: std::sync::Arc<dyn FontProvider> = provider.clone();
         ctx.data_mut(|d| d.insert_temp(craft_provider_id(), provider));
diff --git a/crates/ui-egui/src/text/cjk_fonts/tests.rs b/crates/ui-egui/src/text/cjk_fonts/tests.rs
--- a/crates/ui-egui/src/text/cjk_fonts/tests.rs
+++ b/crates/ui-egui/src/text/cjk_fonts/tests.rs
@@ -1366,12 +1366,13 @@ fn pending_scan_recovers_through_real_layout_and_the_lazy_reload() {
     PENDING_READY.store(false, std::sync::atomic::Ordering::Relaxed);
 }
 
+/// Under the panicking missing-glyph policy, the first frame after a finished scan draws a
+/// Traditional Chinese label from the system face alone: the eager provider is installed and
+/// serves it even with no embedded craft fonts.
 #[test]
 fn strict_policy_first_frame_after_the_scan_is_accepted() {
-    if craft_fonts::CRAFT_FONTS.is_empty() {
-        eprintln!("skipping: the eager provider is only installed with craft-fonts");
-        return;
-    }
+    // No craft-font skip: the sources embed nothing, and the provider must serve the system face
+    // alone, with or without craft-fonts in the build.
     let ctx = egui::Context::default();
     ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);
     let sources = Sources {
