diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1018,6 +1018,7 @@ dependencies = [
 name = "craft-ui"
 version = "0.1.0"
 dependencies = [
+ "craft-font-catalog",
  "craft-fonts-build",
  "craft-i18n",
  "eframe",
diff --git a/crates/font-catalog/README.md b/crates/font-catalog/README.md
--- a/crates/font-catalog/README.md
+++ b/crates/font-catalog/README.md
@@ -72,7 +72,7 @@ Each face (`FaceInfo`) has its family and style, legacy family and style, family
 
 ## One enumeration for the UI and documents
 
-With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback can query the same collection instead of building its own, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one.
+With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback (its `system_fonts` feature) queries the same collection, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one. It doesn't wait for a rescan in progress, so a UI thread can check it every frame. `system::on_change(callback)` runs a callback after every build, on the thread that built it.
 
 ## Try it
 
diff --git a/crates/font-catalog/src/system.rs b/crates/font-catalog/src/system.rs
--- a/crates/font-catalog/src/system.rs
+++ b/crates/font-catalog/src/system.rs
@@ -10,7 +10,8 @@
 #[cfg(all(feature = "system", not(target_arch = "wasm32")))]
 mod imp {
     use std::path::PathBuf;
-    use std::sync::{Mutex, PoisonError};
+    use std::sync::atomic::{AtomicU64, Ordering};
+    use std::sync::{Arc, Mutex, PoisonError};
 
     use fontique::{Collection, CollectionOptions, SourceKind};
 
@@ -22,6 +23,11 @@ mod imp {
     }
 
     static SHARED: Mutex<Option<Shared>> = Mutex::new(None);
+    /// `SHARED`'s generation, readable while a build holds the lock (a UI thread checks it every
+    /// frame). Stored before the lock is released, so a [`collection`] taken after reading it is
+    /// at least that generation.
+    static GENERATION: AtomicU64 = AtomicU64::new(0);
+    static ON_CHANGE: Mutex<Vec<Arc<dyn Fn() + Send + Sync>>> = Mutex::new(Vec::new());
 
     /// A new collection of the system's fonts and the font store's. `None` when fontique panics
     /// (its DirectWrite backend unwraps a failed factory).
@@ -39,14 +45,36 @@ mod imp {
     fn with<R>(refresh: bool, f: impl FnOnce(&mut Shared) -> R) -> R {
         let mut guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
         let generation = guard.as_ref().map_or(0, |s| s.generation);
+        let built = refresh || guard.is_none();
         let shared = match &mut *guard {
             Some(shared) if !refresh => shared,
             slot => {
                 let collection = build().unwrap_or_else(|| Collection::new(CollectionOptions { shared: false, system_fonts: false }));
+                GENERATION.store(generation + 1, Ordering::Release);
                 slot.insert(Shared { generation: generation + 1, collection })
             }
         };
-        f(shared)
+        let result = f(shared);
+        drop(guard);
+        if built {
+            // A clone of the list, so a callback may take the collection or register another.
+            let callbacks = ON_CHANGE.lock().unwrap_or_else(PoisonError::into_inner).clone();
+            for callback in callbacks {
+                callback();
+            }
+        }
+        result
+    }
+
+    pub fn on_change(callback: Box<dyn Fn() + Send + Sync>) {
+        ON_CHANGE.lock().unwrap_or_else(PoisonError::into_inner).push(Arc::from(callback));
+    }
+
+    /// Run `f` while holding the lock a build holds, as a rescan on another thread would.
+    #[cfg(test)]
+    pub(super) fn while_building<R>(f: impl FnOnce() -> R) -> R {
+        let _guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
+        f()
     }
 
     pub fn collection() -> Collection {
@@ -54,7 +82,7 @@ mod imp {
     }
 
     pub fn generation() -> u64 {
-        SHARED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map_or(0, |s| s.generation)
+        GENERATION.load(Ordering::Acquire)
     }
 
     pub fn refresh() {
@@ -105,11 +133,20 @@ pub fn collection() -> fontique::Collection {
 
 #[cfg(all(feature = "system", not(target_arch = "wasm32")))]
 /// Counts the builds of [`collection`]: 0 before the first, one more after each rescan that asked
-/// the font service again.
+/// the font service again. It doesn't wait for a build in progress, so a UI thread can check it
+/// every frame.
 pub fn generation() -> u64 {
     imp::generation()
 }
 
+#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
+/// Call `callback` after every build of [`collection`], once [`generation`] has moved on: the first
+/// build and every rescan. It runs on the thread that built, after the build's lock is released,
+/// so it may take the new [`collection`]. Callbacks stay registered for the life of the process.
+pub fn on_change(callback: Box<dyn Fn() + Send + Sync>) {
+    imp::on_change(callback);
+}
+
 #[cfg(all(feature = "system", not(target_arch = "wasm32")))]
 /// Enumerate the system's fonts again (a catalog's [`crate::Catalog::rescan`] does this).
 pub fn refresh() {
@@ -120,3 +157,20 @@ pub fn refresh() {
 pub(crate) fn font_files(refresh: bool) -> Vec<std::path::PathBuf> {
     imp::font_files(refresh)
 }
+
+#[cfg(all(test, feature = "system", not(target_arch = "wasm32")))]
+mod tests {
+    use std::time::Duration;
+
+    #[test]
+    fn generation_does_not_wait_for_a_rescan() {
+        let read = super::imp::while_building(|| {
+            let (sent, received) = std::sync::mpsc::channel();
+            std::thread::spawn(move || {
+                let _ = sent.send(super::generation());
+            });
+            received.recv_timeout(Duration::from_secs(10))
+        });
+        assert!(read.is_ok(), "generation() returns while a build holds the collection's lock");
+    }
+}
diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml
--- a/crates/ui/Cargo.toml
+++ b/crates/ui/Cargo.toml
@@ -23,6 +23,8 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 # Screenshot and input tests of an app's screens (the screen-testing feature).
 egui_kittest = { version = "0.37", features = ["snapshot", "wgpu"], optional = true }
+# The installed fonts the UI falls back to: the system's and the Craft font store's.
+craft-font-catalog = { workspace = true, features = ["system"], optional = true }
 # The versions egui_system_font_provider 0.37 uses.
 fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true }
 unicode-script = { version = "0.5.8", optional = true }
@@ -48,7 +50,7 @@ native-windows = []
 # Draw colour glyphs (COLR, sbix, CBDT), e.g. colour emoji.
 color_fonts = ["epaint/color_fonts"]
 # Find installed fonts for characters the app's faces lack (desktop only).
-system_fonts = ["dep:fontique", "dep:unicode-script"]
+system_fonts = ["dep:craft-font-catalog", "dep:fontique", "dep:unicode-script"]
 # `docking::testing`: a headless driver for application docking tests.
 testing = []
 # craft_ui::testing: per-language screenshot tests and IME smoke tests with egui_kittest
diff --git a/crates/ui/README.md b/crates/ui/README.md
--- a/crates/ui/README.md
+++ b/crates/ui/README.md
@@ -406,10 +406,14 @@ directory out of version control. A build filters faces for the web as for the d
 Two features, both off by default, change what every craft-ui user draws, so the app turns them on:
 
 - `system_fonts` (desktop): the stack asks the installed fonts, at regular weight, and `install`
-  replaces the context's font providers, including the one eframe adds. One index of the
-  installed fonts serves every UI language, so a language switch doesn't scan them again.
-  `install` starts building it on a background thread; until it is ready, text draws with the
-  stack's own faces, and the context repaints when it arrives. Without it,
+  replaces the context's font providers, including the one eframe adds. The installed fonts are
+  craft-font-catalog's (`system::collection()`): the system's fonts and those in the Craft font
+  store. One index of them serves every UI language, so a language switch doesn't scan them
+  again. `install` starts building it on a background thread; until it is ready, text draws with
+  the stack's own faces, and the context repaints when it arrives. When the process's catalog
+  rescans (`system::on_change`), e.g. after another app installed a font, the index is rebuilt in the background and every installed context repaints, idle or not, and reloads its
+  fonts, so a font installed meanwhile draws the characters that had none. craft-ui never
+  rescans on its own; the app decides when. Without it,
   `install` keeps the providers the context has (eframe's `system_fonts`, for one) and asks the
   stack's own faces after them. `UiFonts::system_provider` supplies a provider either way, and
   `SystemFonts::Off` drops system fonts.
diff --git a/crates/ui/src/fonts/mod.rs b/crates/ui/src/fonts/mod.rs
--- a/crates/ui/src/fonts/mod.rs
+++ b/crates/ui/src/fonts/mod.rs
@@ -987,9 +987,10 @@ impl Stack {
 }
 
 /// A system font provider that prefers `locale`'s fonts, shared by every stack and check that asks
-/// for the same locale. Every locale's provider asks one index of the installed fonts, built once
-/// on a background thread; a lookup before it is built waits for it. `None` without the
-/// `system_fonts` feature and on the web.
+/// for the same locale. Every locale's provider asks one index of the installed fonts (the system's
+/// and the Craft font store's, from craft-font-catalog), built on a background thread and rebuilt
+/// when the catalog rescans; a lookup before it is built, or while it is rebuilt, waits for it.
+/// `None` without the `system_fonts` feature and on the web.
 ///
 /// To ask whether installed fonts can draw a language, pass it to
 /// [`UiFonts::system_provider`] and check with [`assert_covers_with_system`] or
diff --git a/crates/ui/src/fonts/system.rs b/crates/ui/src/fonts/system.rs
--- a/crates/ui/src/fonts/system.rs
+++ b/crates/ui/src/fonts/system.rs
@@ -1,10 +1,21 @@
-//! A font index built once on a background thread and shared by every locale's provider.
+//! A font index built on a background thread, shared by every locale's provider, and rebuilt when
+//! the fonts it was built from change.
 //!
 //! Lookups that may not block (the UI thread's) find nothing until the index is built: egui
 //! draws with the faces it has, and every context that was turned away reloads its fonts when the
 //! index arrives, which forgets egui's cached misses and repaints. If the build panics, every
-//! lookup finds nothing from then on and egui keeps the faces it has.
+//! lookup finds nothing and egui keeps the faces it has, until the fonts change and the build is
+//! tried again.
+//!
+//! The index remembers the generation of the fonts it was built from. When the fonts' source
+//! reports a change, and on every lookup and every pass of a context that started the index, it is
+//! compared with the current generation, which is cheap. On a change the index is rebuilt in the
+//! background while lookups keep asking the old one. Once the new index is in, every context that
+//! started the index repaints, idle or not, and reloads its fonts at the start of that pass, so the
+//! characters it had no font for are asked again; the faces egui already has stay. A rebuild that
+//! panics keeps the old index until the fonts change again.
 
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
 
 use egui::{FallbackRequest, FontInsert, FontProvider};
@@ -14,62 +25,128 @@ pub(super) trait FontIndex: Send + 'static {
     /// The provider's preference between fonts for one script, e.g. a locale.
     type Locale: Send + Sync + 'static;
 
+    /// The generation of the fonts the index was built from, as [`Source::generation`] counts
+    /// them.
+    fn generation(&self) -> u64;
+
     fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Self::Locale) -> Option<FontInsert>;
 }
 
+/// Where an index's fonts come from: how to read their generation and hear of a change.
+pub(super) struct Source {
+    /// The fonts' current generation. It must not block: every UI pass reads it.
+    pub(super) generation: fn() -> u64,
+    /// Registers a callback for every change of the generation, e.g. a rescan on another thread.
+    pub(super) on_change: fn(Box<dyn Fn() + Send + Sync>),
+}
+
 enum State<T> {
     Idle,
-    /// `turned_away`: a lookup found nothing because the index wasn't built, so `contexts` reload
-    /// their fonts when it is.
+    /// `turned_away`: a lookup found nothing because the index wasn't built, so the contexts
+    /// reload their fonts when it is.
     Building {
         turned_away: bool,
-        contexts: Vec<egui::Context>,
     },
-    Ready(Arc<Mutex<T>>),
-    Failed,
+    Ready {
+        index: Arc<Mutex<T>>,
+        generation: u64,
+    },
+    /// The fonts changed since `index` was built. Lookups that don't wait ask it until the new
+    /// one is in.
+    Rebuilding {
+        index: Arc<Mutex<T>>,
+    },
+    /// The build at `generation` panicked; a later generation tries again.
+    Failed {
+        generation: u64,
+    },
 }
 
-/// An index built at most once per process, on first use.
+/// An index built on first use, and again whenever the fonts' generation changes.
 pub(super) struct Shared<T> {
     state: Mutex<State<T>>,
     built: Condvar,
     build: fn() -> T,
+    source: Source,
+    /// Counts the rebuilds that replaced the index. A context reloads its fonts when this moves
+    /// past the count it last saw.
+    replaced: AtomicU64,
+    /// The contexts that started the index, to repaint after a rebuild. egui has no weak handle to
+    /// a context, so each stays until its integration calls [`egui::Context::on_exit`].
+    contexts: Mutex<Vec<egui::Context>>,
 }
 
 impl<T: FontIndex> Shared<T> {
-    pub(super) const fn new(build: fn() -> T) -> Self {
-        Self { state: Mutex::new(State::Idle), built: Condvar::new(), build }
+    pub(super) const fn new(build: fn() -> T, source: Source) -> Self {
+        Self { state: Mutex::new(State::Idle), built: Condvar::new(), build, source, replaced: AtomicU64::new(0), contexts: Mutex::new(Vec::new()) }
     }
 
     /// Start building the index on a background thread unless it is built or building. `ctx`
-    /// reloads its fonts when the index arrives if a lookup was turned away meanwhile.
+    /// reloads its fonts when the index arrives if a lookup was turned away meanwhile, and follows
+    /// the fonts' generation from then on.
     pub(super) fn start(&'static self, ctx: Option<&egui::Context>) {
+        if let Some(ctx) = ctx {
+            let replaced = self.replaced.load(Ordering::Acquire);
+            ctx.data_mut(|d| d.insert_temp(self.seen_id(), replaced));
+            let mut contexts = self.contexts();
+            if !contexts.contains(ctx) {
+                contexts.push(ctx.clone());
+            }
+            drop(contexts);
+            ctx.add_plugin(Follow(self));
+        }
         let idle = {
             let mut state = self.lock();
             let idle = matches!(*state, State::Idle);
             if idle {
-                *state = State::Building { turned_away: false, contexts: Vec::new() };
-            }
-            if let (State::Building { contexts, .. }, Some(ctx)) = (&mut *state, ctx) {
-                contexts.push(ctx.clone());
+                *state = State::Building { turned_away: false };
             }
             idle
         };
-        if idle && std::thread::Builder::new().name("craft-ui system fonts".into()).spawn(|| self.finish()).is_err() {
-            self.finish();
+        if idle {
+            (self.source.on_change)(Box::new(|| self.follow()));
+            self.spawn_build();
+        }
+    }
+
+    /// Rebuild the index in the background if the fonts changed since it was built, or retry a
+    /// failed build at a new generation.
+    fn follow(&'static self) {
+        let current = (self.source.generation)();
+        let rebuild = {
+            let mut state = self.lock();
+            match &mut *state {
+                State::Ready { index, generation } if *generation != current => {
+                    let index = Arc::clone(index);
+                    *state = State::Rebuilding { index };
+                    true
+                }
+                State::Failed { generation } if *generation != current => {
+                    // Every lookup since the failure found nothing.
+                    *state = State::Building { turned_away: true };
+                    true
+                }
+                _ => false,
+            }
+        };
+        if rebuild {
+            self.spawn_build();
         }
     }
 
     /// The index, or `None` while it builds unless `wait`, which blocks until it is built, or
-    /// `None` if its build failed.
+    /// `None` if its build failed. While it is rebuilt, `wait` blocks for the new index and a
+    /// lookup without gets the old one.
     fn get(&'static self, wait: bool) -> Option<Arc<Mutex<T>>> {
         self.start(None);
+        self.follow();
         let mut state = self.lock();
         loop {
             match &mut *state {
-                State::Ready(index) => return Some(Arc::clone(index)),
-                State::Failed => return None,
-                State::Building { turned_away, .. } if !wait => {
+                State::Ready { index, .. } => return Some(Arc::clone(index)),
+                State::Rebuilding { index } if !wait => return Some(Arc::clone(index)),
+                State::Failed { .. } => return None,
+                State::Building { turned_away } if !wait => {
                     *turned_away = true;
                     return None;
                 }
@@ -78,29 +155,87 @@ impl<T: FontIndex> Shared<T> {
         }
     }
 
-    fn finish(&self) {
+    fn spawn_build(&'static self) {
+        if std::thread::Builder::new().name("craft-ui system fonts".into()).spawn(|| self.finish()).is_err() {
+            self.finish();
+        }
+    }
+
+    fn finish(&'static self) {
+        let before = (self.source.generation)();
         let built = std::panic::catch_unwind(self.build).map_err(|payload| {
             let message = payload
                 .downcast_ref::<String>()
                 .map(String::as_str)
                 .or_else(|| payload.downcast_ref::<&str>().copied())
                 .unwrap_or("non-string panic payload");
-            eprintln!("craft-ui: building the system font index failed, so it adds no fonts: {message}");
+            eprintln!("craft-ui: building the system font index failed: {message}");
         });
-        let next = built.map_or(State::Failed, |index| State::Ready(Arc::new(Mutex::new(index))));
-        let previous = std::mem::replace(&mut *self.lock(), next);
+        let ready = |index: T| State::Ready { generation: index.generation(), index: Arc::new(Mutex::new(index)) };
+        let mut state = self.lock();
+        let (next, reload, repaint) = match (built, std::mem::replace(&mut *state, State::Idle)) {
+            (Ok(index), State::Rebuilding { .. }) => (ready(index), false, true),
+            (Err(()), State::Rebuilding { index }) => (State::Ready { index, generation: before }, false, false),
+            (Ok(index), State::Building { turned_away }) => (ready(index), turned_away, false),
+            (Ok(index), _) => (ready(index), false, false),
+            (Err(()), _) => (State::Failed { generation: before }, false, false),
+        };
+        *state = next;
+        if repaint {
+            self.replaced.fetch_add(1, Ordering::Release);
+        }
+        drop(state);
         self.built.notify_all();
         // Outside the lock: the UI thread holds the context's fonts while it asks for the index.
-        if let State::Building { turned_away: true, contexts } = previous {
-            for ctx in contexts {
+        // A repainted context reloads its fonts in its next pass (see `Follow`).
+        let contexts = if reload || repaint { self.contexts().clone() } else { Vec::new() };
+        for ctx in contexts {
+            if reload {
                 super::providers::reload_font_providers(&ctx);
+            } else {
+                ctx.request_repaint();
             }
         }
+        // The fonts may have changed again while this build ran.
+        self.follow();
+    }
+
+    /// Where a context keeps the [`Self::replaced`] count its fonts were last loaded at.
+    fn seen_id(&self) -> egui::Id {
+        egui::Id::unique(("craft_ui::fonts::system::seen", std::ptr::from_ref(self) as usize))
     }
 
     fn lock(&self) -> MutexGuard<'_, State<T>> {
         self.state.lock().unwrap_or_else(PoisonError::into_inner)
     }
+
+    fn contexts(&self) -> MutexGuard<'_, Vec<egui::Context>> {
+        self.contexts.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+}
+
+/// Follows the fonts for a context that started a [`Shared`] index: at the start of each pass it
+/// has the index rebuilt if they changed, and reloads the context's fonts once a new index is in.
+struct Follow<T: 'static>(&'static Shared<T>);
+
+impl<T: FontIndex> egui::plugin::Plugin for Follow<T> {
+    fn debug_name(&self) -> &'static str {
+        "craft_ui::fonts::system::Follow"
+    }
+
+    fn on_begin_pass(&mut self, ui: &mut egui::Ui) {
+        let ctx = ui.ctx().clone();
+        self.0.follow();
+        let replaced = self.0.replaced.load(Ordering::Acquire);
+        let seen = ctx.data_mut(|d| std::mem::replace(d.get_temp_mut_or_insert_with(self.0.seen_id(), || replaced), replaced));
+        if seen != replaced {
+            super::providers::reload_font_providers(&ctx);
+        }
+    }
+
+    fn on_exit(&mut self, ctx: &egui::Context) {
+        self.0.contexts().retain(|c| c != ctx);
+    }
 }
 
 /// One locale's provider over a [`Shared`] index.
@@ -128,7 +263,7 @@ impl<T: FontIndex> FontProvider for Lookup<T> {
 #[cfg(test)]
 mod tests {
     use std::sync::Arc;
-    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+    use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
     use std::time::{Duration, Instant};
 
     use egui::{FontData, FontFamily, FontId, FontPriority, InsertFontFamily};
@@ -153,6 +288,10 @@ mod tests {
     impl FontIndex for Gated {
         type Locale = ();
 
+        fn generation(&self) -> u64 {
+            0
+        }
+
         fn font_for(&mut self, request: &FallbackRequest<'_>, _: &()) -> Option<FontInsert> {
             Some(FontInsert::new(
                 "index:Arabic",
@@ -162,7 +301,15 @@ mod tests {
         }
     }
 
-    static GATED: Shared<Gated> = Shared::new(build_when_open);
+    fn unchanging() -> u64 {
+        0
+    }
+
+    fn never_changes(_: Box<dyn Fn() + Send + Sync>) {}
+
+    const UNCHANGING: Source = Source { generation: unchanging, on_change: never_changes };
+
+    static GATED: Shared<Gated> = Shared::new(build_when_open, UNCHANGING);
 
     #[test]
     fn ui_lookups_skip_the_index_until_it_is_built_then_the_context_reloads() {
@@ -197,7 +344,7 @@ mod tests {
         panic!("stand-in index build failure");
     }
 
-    static PANICS: Shared<Gated> = Shared::new(build_panics);
+    static PANICS: Shared<Gated> = Shared::new(build_panics, UNCHANGING);
 
     #[test]
     fn a_panicking_build_leaves_lookups_finding_nothing() {
@@ -213,4 +360,146 @@ mod tests {
         assert!(Lookup::new(&PANICS, (), false).font_for(&request).is_none());
         assert!(Lookup::new(&PANICS, (), true).font_for(&request).is_none());
     }
+
+    /// An index of the fonts at a generation; Arabic is installed from generation 2.
+    struct Installed(u64);
+
+    static FONTS: AtomicU64 = AtomicU64::new(1);
+    static INSTALLED_BUILDS: AtomicUsize = AtomicUsize::new(0);
+
+    fn fonts_generation() -> u64 {
+        FONTS.load(Ordering::Acquire)
+    }
+
+    fn build_installed() -> Installed {
+        INSTALLED_BUILDS.fetch_add(1, Ordering::Relaxed);
+        Installed(fonts_generation())
+    }
+
+    impl FontIndex for Installed {
+        type Locale = ();
+
+        fn generation(&self) -> u64 {
+            self.0
+        }
+
+        fn font_for(&mut self, request: &FallbackRequest<'_>, _: &()) -> Option<FontInsert> {
+            if self.0 < 2 {
+                return None;
+            }
+            Gated.font_for(request, &())
+        }
+    }
+
+    static INSTALLED: Shared<Installed> = Shared::new(build_installed, Source { generation: fonts_generation, on_change: never_changes });
+
+    #[test]
+    fn a_change_in_the_fonts_rebuilds_the_index_and_the_context_reloads() {
+        let ctx = egui::Context::default();
+        let font_id = FontId::new(14.0, FontFamily::Proportional);
+        let ui: Arc<dyn FontProvider> = Arc::new(Lookup::new(&INSTALLED, (), false));
+        super::super::providers::set_font_providers(&ctx, vec![ui]);
+        INSTALLED.start(Some(&ctx));
+        let draws_arabic = || {
+            ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
+            ctx.fonts_mut(|f| f.has_glyph(&font_id, 'ع'))
+        };
+        let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };
+        assert!(Lookup::new(&INSTALLED, (), true).font_for(&request).is_none(), "no Arabic font at generation 1");
+        for _ in 0..3 {
+            assert!(!draws_arabic());
+        }
+        assert_eq!(INSTALLED_BUILDS.load(Ordering::Relaxed), 1, "an unchanged generation keeps the index");
+
+        FONTS.store(2, Ordering::Release);
+        let deadline = Instant::now() + Duration::from_secs(10);
+        while !draws_arabic() {
+            assert!(Instant::now() < deadline, "the context reloads its fonts once the index is rebuilt");
+            std::thread::sleep(Duration::from_millis(5));
+        }
+        assert_eq!(INSTALLED_BUILDS.load(Ordering::Relaxed), 2, "one rebuild per change");
+    }
+
+    static IDLE_FONTS: AtomicU64 = AtomicU64::new(1);
+    static IDLE_BUILDS: AtomicUsize = AtomicUsize::new(0);
+    static IDLE_CALLBACKS: Mutex<Vec<Box<dyn Fn() + Send + Sync>>> = Mutex::new(Vec::new());
+
+    fn idle_generation() -> u64 {
+        IDLE_FONTS.load(Ordering::Acquire)
+    }
+
+    fn build_idle() -> Installed {
+        IDLE_BUILDS.fetch_add(1, Ordering::Relaxed);
+        Installed(idle_generation())
+    }
+
+    fn on_idle_change(callback: Box<dyn Fn() + Send + Sync>) {
+        IDLE_CALLBACKS.lock().unwrap().push(callback);
+    }
+
+    static IDLE: Shared<Installed> = Shared::new(build_idle, Source { generation: idle_generation, on_change: on_idle_change });
+
+    #[test]
+    fn an_idle_context_repaints_when_the_fonts_change() {
+        let ctx = egui::Context::default();
+        let ui: Arc<dyn FontProvider> = Arc::new(Lookup::new(&IDLE, (), false));
+        super::super::providers::set_font_providers(&ctx, vec![ui]);
+        IDLE.start(Some(&ctx));
+        let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };
+        assert!(Lookup::new(&IDLE, (), true).font_for(&request).is_none());
+        let draws_arabic = || {
+            ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
+            ctx.fonts_mut(|f| f.has_glyph(&FontId::new(14.0, FontFamily::Proportional), 'ع'))
+        };
+        assert!(!draws_arabic());
+        // Idle: no repaint pending, as an app between inputs. egui calls the callback only for a
+        // repaint requested after that.
+        for _ in 0..10 {
+            if !ctx.has_requested_repaint() {
+                break;
+            }
+            assert!(!draws_arabic());
+        }
+        assert!(!ctx.has_requested_repaint(), "the context settles");
+        let woken = Arc::new(AtomicBool::new(false));
+        let wake = Arc::clone(&woken);
+        ctx.set_request_repaint_callback(move |_| wake.store(true, Ordering::Release));
+
+        // A rescan on another thread, with no pass running.
+        IDLE_FONTS.store(2, Ordering::Release);
+        for callback in IDLE_CALLBACKS.lock().unwrap().iter() {
+            callback();
+        }
+        let deadline = Instant::now() + Duration::from_secs(10);
+        while !woken.load(Ordering::Acquire) {
+            assert!(Instant::now() < deadline, "the context is asked to repaint once the index is rebuilt");
+            std::thread::sleep(Duration::from_millis(5));
+        }
+        assert_eq!(IDLE_BUILDS.load(Ordering::Relaxed), 2);
+        draws_arabic();
+        assert!(draws_arabic(), "the repainted context reloads its fonts");
+    }
+
+    fn build_unless_first() -> Installed {
+        let generation = FLAKY_FONTS.load(Ordering::Acquire);
+        assert!(generation > 1, "stand-in index build failure");
+        Installed(generation)
+    }
+
+    static FLAKY_FONTS: AtomicU64 = AtomicU64::new(1);
+
+    fn flaky_generation() -> u64 {
+        FLAKY_FONTS.load(Ordering::Acquire)
+    }
+
+    static FLAKY: Shared<Installed> = Shared::new(build_unless_first, Source { generation: flaky_generation, on_change: never_changes });
+
+    #[test]
+    fn a_failed_build_is_tried_again_when_the_fonts_change() {
+        let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };
+        assert!(Lookup::new(&FLAKY, (), true).font_for(&request).is_none());
+        assert!(Lookup::new(&FLAKY, (), true).font_for(&request).is_none(), "an unchanged generation doesn't retry");
+        FLAKY_FONTS.store(2, Ordering::Release);
+        assert!(Lookup::new(&FLAKY, (), true).font_for(&request).is_some(), "a new generation builds again");
+    }
 }
diff --git a/crates/ui/src/fonts/system_index.rs b/crates/ui/src/fonts/system_index.rs
--- a/crates/ui/src/fonts/system_index.rs
+++ b/crates/ui/src/fonts/system_index.rs
@@ -1,21 +1,23 @@
-//! The installed fonts, indexed once with fontique for every locale. The lookup is
-//! egui_system_font_provider 0.37's, whose provider builds its own index per locale.
+//! The installed fonts, indexed with fontique for every locale: craft-font-catalog's collection of
+//! the system's fonts and the Craft font store's, indexed again when the catalog rescans them. The
+//! lookup is egui_system_font_provider 0.37's, whose provider builds its own index per locale.
 
 use std::sync::{Arc, LazyLock};
 
+use craft_font_catalog::system;
 use egui::{FallbackRequest, FontData, FontFamily, FontInsert, FontPriority, FontProvider, InsertFontFamily};
-use fontique::{
-    Collection, CollectionOptions, FallbackKey, GenericFamily, Language, QueryFamily, QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions,
-};
+use fontique::{Collection, FallbackKey, GenericFamily, Language, QueryFamily, QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions};
 
-use super::system::{FontIndex, Lookup, Shared};
+use super::system::{FontIndex, Lookup, Shared, Source};
 
 pub(super) struct Index {
     collection: Collection,
     source_cache: SourceCache,
+    /// The catalog's [`system::generation`] of `collection`.
+    generation: u64,
 }
 
-static INDEX: Shared<Index> = Shared::new(build);
+static INDEX: Shared<Index> = Shared::new(build, Source { generation: system::generation, on_change: system::on_change });
 
 #[cfg(test)]
 pub(super) static BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
@@ -23,10 +25,12 @@ pub(super) static BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::At
 fn build() -> Index {
     #[cfg(test)]
     BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
-    Index {
-        collection: Collection::new(CollectionOptions { shared: false, system_fonts: true }),
-        source_cache: SourceCache::new(SourceCacheOptions::default()),
-    }
+    // The first call enumerates the fonts if no one has yet. The generation read after it is
+    // then no newer than the collection taken next: a rescan in between costs one more rebuild,
+    // not a missed one.
+    system::collection();
+    let generation = system::generation();
+    Index { collection: system::collection(), source_cache: SourceCache::new(SourceCacheOptions::default()), generation }
 }
 
 /// A provider over the shared index that prefers `locale`'s fonts. With `wait`, a lookup before the
@@ -46,7 +50,7 @@ pub(super) fn locale_language(locale: &str) -> Option<Language> {
 }
 
 /// Start building the index in the background. `ctx` reloads its fonts once it is built if a
-/// lookup found nothing meanwhile.
+/// lookup found nothing meanwhile, and again after each rebuild for a catalog rescan.
 pub(super) fn start(ctx: &egui::Context) {
     INDEX.start(Some(ctx));
 }
@@ -54,10 +58,14 @@ pub(super) fn start(ctx: &egui::Context) {
 impl FontIndex for Index {
     type Locale = Option<Language>;
 
+    fn generation(&self) -> u64 {
+        self.generation
+    }
+
     fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Option<Language>) -> Option<FontInsert> {
         let FallbackRequest { cluster, family } = request;
         let base_char = cluster.chars().next()?;
-        let Self { collection, source_cache } = self;
+        let Self { collection, source_cache, .. } = self;
         let generic = match family {
             FontFamily::Monospace => GenericFamily::Monospace,
             FontFamily::Proportional | FontFamily::Name(_) => GenericFamily::SansSerif,
