diff --git a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md
index 00ae845bf9..4a276c5f11 100644
--- a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md
+++ b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md
@@ -8,7 +8,7 @@ toward the Fluent theme per the migration hub's `architecture-patterns.md` §12.
## Why a global token system (and why it renders headlessly)
-`FwAvaloniaApp` adds `new FluentTheme()`, which ships ~14px fonts and ~32px control floors app-wide.
+`FwAvaloniaApp` adds Semi core plus Ursa Semi (no Fluent), which ships ~14px fonts and ~32px control floors app-wide.
Density/borders must be layered ON TOP of Fluent. The reliable mechanism — proven by a prior agent —
is **per-control-tree `.Styles`**, added to each view's own `Styles` collection by its view ctor.
App-level `Application.Styles` did **not** apply in the headless test app, so that path is rejected.
@@ -42,6 +42,11 @@ sit inside a `Border.fwFieldHost` that supplies the box.
directly (not three independently-maintained copies) -- see
`Src/Common/FwAvalonia/FwThemeResources.cs`.
+**Font family:** Segoe UI, named by `FwUiFontFamily`. `FwUiFont.ApplyTo` registers
+bundled Andika and points Semi's font resources at the theme family stack. Named
+UI-language fallbacks and the platform default follow it. Data text keeps its
+writing-system font. Measure UI text with `FwUiFont.UiFontFamily`. See ADR 0004.
+
**Control height:** `TextBox`/`ComboBox`/`Button` `MinHeight = 24` (WinForms runs ~21-23px; 24 is the
pointer-accessibility floor — see the "Why `DialogMinControlHeight` is 24, not 22" note below — still far
from Fluent's ~32px).
diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
index 543d05c5a6..4cb2d7dcc1 100644
--- a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
+++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
@@ -38,9 +38,10 @@ public class GenerateTokenKeys : Task
{
private static readonly XNamespace XamlNs = "http://schemas.microsoft.com/winfx/2006/xaml";
- /// Every top-level ThemeDictionary entry becomes a key constant.
+ /// Every top-level ThemeDictionary entry becomes a key constant. FontFamily
+ /// entries are included, so the interface font family has a constant too.
private static readonly HashSet TokenElementNames = new HashSet(
- new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "StaticResource" });
+ new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "FontFamily", "StaticResource" });
/// .axaml files whose EVERY x:Key becomes a generated constant.
[Required]
diff --git a/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md
new file mode 100644
index 0000000000..3b9e85ba33
--- /dev/null
+++ b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md
@@ -0,0 +1,22 @@
+# The Avalonia interface font is controlled by the theme
+
+LT-22854. The `FwUiFontFamily` token names Segoe UI as the default interface
+font on Windows. Segoe UI preserves the density of the current interface; the
+proposed Andika default has taller line metrics. Line-height tokens remain a
+separate decision.
+
+`FwUiFont.ApplyTo` registers the bundled Andika collection and assigns the
+interface font stack to Semi's `DefaultFontFamily`,
+`ContentControlThemeFontFamily`, and `SemiFontFamilyRegular` resources. The
+stack combines the theme default, fallbacks for the UI language, and the
+platform default. It is chosen at startup and applies across the application:
+menus and other interface chrome should not change when the project changes.
+
+The language project's `UiElement` style continues to control UI-writing-system
+text drawn in Views; Avalonia interface chrome does not read that style. Data
+text continues to use its writing-system font. Andika 7.000 remains bundled
+in a private collection; a plain `Andika` family still names the system font
+for writing-system data.
+
+Windows validation must measure density, clipping and script glyph fallback.
+Screenshot baselines still depict Andika and must be refreshed on Windows.
diff --git a/Docs/architecture/adr/README.md b/Docs/architecture/adr/README.md
index 9fd119285e..34c6291971 100644
--- a/Docs/architecture/adr/README.md
+++ b/Docs/architecture/adr/README.md
@@ -28,3 +28,4 @@ editing the next time the code changes, it belongs in one of the other three.
| [0001](0001-alias-semi-semantic-tokens.md) | Alias Semi's semantic tokens instead of inventing a FieldWorks palette |
| [0002](0002-whole-tree-token-hygiene-check.md) | token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering |
| [0003](0003-geometric-assertions-not-pixel-diff.md) | Assert geometry, not pixel diffs, for layout parity |
+| [0004](0004-interface-font-controlled-by-the-theme.md) | Control the Avalonia interface font through a theme token; data text keeps its writing-system font |
diff --git a/Docs/migration/adjust-the-layout.md b/Docs/migration/adjust-the-layout.md
index 0be482beed..83292538be 100644
--- a/Docs/migration/adjust-the-layout.md
+++ b/Docs/migration/adjust-the-layout.md
@@ -173,8 +173,9 @@ host. Treat difficulty here as a design signal, not a tooling problem.
- **The host window is WinForms, not Avalonia.** Title bar, icon, and close
button belong to the modal host form. You cannot style them from the
`.axaml`, and neither loop above shows them.
-- **Converted dialogs render on the Fluent background, not WinForms grey**, and
- use the Fluent font family. That is stack-wide, not something your dialog did.
+- **Converted dialogs render on the Semi theme background, not WinForms grey**,
+ and use the FieldWorks interface font (`FwUiFontFamily`,
+ ADR 0004). That is stack-wide, not something your dialog did.
Changing it means editing `DialogTheme.axaml`'s `fwDialogRoot` style, which
restyles **every** converted dialog -- treat it as its own change, with dark
mode considered.
diff --git a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png
index 1229559c99..c1c1dba0c4 100644
Binary files a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png and b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png differ
diff --git a/Docs/migration/baseline-screenshots/Chooser-01-initial.png b/Docs/migration/baseline-screenshots/Chooser-01-initial.png
index d8c5ce62c1..f9bd92c857 100644
Binary files a/Docs/migration/baseline-screenshots/Chooser-01-initial.png and b/Docs/migration/baseline-screenshots/Chooser-01-initial.png differ
diff --git a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png
index 71f331638a..b42036c24e 100644
Binary files a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png and b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png differ
diff --git a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png
index b81a600544..77d0522f90 100644
Binary files a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png and b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png differ
diff --git a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png
index bdf5fc3879..f538d21cac 100644
Binary files a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png and b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png differ
diff --git a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png
index 391f156b37..b761047ea7 100644
Binary files a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png and b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png differ
diff --git a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png
index 828d554fcc..f2a3e9f444 100644
Binary files a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png and b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png differ
diff --git a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png
index 9f8b770baf..85ec64c9e5 100644
Binary files a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png and b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png differ
diff --git a/Docs/migration/baseline-screenshots/Options-01-initial.png b/Docs/migration/baseline-screenshots/Options-01-initial.png
index b1ced1321f..3c9f94b850 100644
Binary files a/Docs/migration/baseline-screenshots/Options-01-initial.png and b/Docs/migration/baseline-screenshots/Options-01-initial.png differ
diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs
index 91dcc132f4..ef4f50f6c0 100644
--- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs
+++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs
@@ -94,7 +94,7 @@ public static double ComputeWsAbbrevColumnWidth(DetailModel model)
if (model == null)
return FwAvaloniaDensity.WsAbbrevWidth;
- var typeface = new Typeface(FontFamily.Default);
+ var typeface = new Typeface(FwUiFont.UiFontFamily);
double widest = 0;
foreach (var abbrev in model.Fields.Where(f => f.IsMultiStringRow)
.SelectMany(f => f.Values).Select(v => v.WsAbbrev)
diff --git a/Src/Common/FwAvalonia/FwAvaloniaApp.cs b/Src/Common/FwAvalonia/FwAvaloniaApp.cs
index 71fd4059ef..214fce4c54 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaApp.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaApp.cs
@@ -36,6 +36,8 @@ public override void Initialize()
// Shared dialog spacing/border tokens, so the Dialog* keys this assembly
// publishes as generated constants are actually registered here too.
MergeTokens("avares://FwAvaloniaTheme/Tokens/DialogTokens.axaml");
+ // After the token merge: the interface font family is itself a token.
+ FwUiFont.ApplyTo(this);
}
private void MergeTokens(string avaresUri)
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs
index 5fc21d3f0c..1e480edb73 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs
@@ -46,7 +46,7 @@ private sealed class DisposableControl : Control, IDisposable
public void Dispose() => DisposeCount++;
}
- [Test]
+ [AvaloniaTest]
public void DisposeDialogResources_DisposesDisposableBodyAndViewModel()
{
var body = new DisposableControl();
@@ -58,7 +58,7 @@ public void DisposeDialogResources_DisposesDisposableBodyAndViewModel()
Assert.That(vm.DisposeCount, Is.EqualTo(1), "the VM is disposed if IDisposable (it owns its resources)");
}
- [Test]
+ [AvaloniaTest]
public void DisposeDialogResources_NonDisposableViewModel_DoesNotThrow()
{
Assert.DoesNotThrow(() => AvaloniaDialogHost.DisposeDialogResources(new Control(), new PlainVm()));
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs
index 9dc37d8dc1..b0e45a80f0 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs
@@ -58,6 +58,18 @@ public void Preview_WsText_UsesConfiguredFonts()
Assert.That(Find(window, "LexemeFormEditor.en").FontFamily.Name, Is.EqualTo("Times New Roman"));
}
+ [AvaloniaTest]
+ public void Preview_WsText_KeepsWsFonts_WhileLabelsUseTheInterfaceFont()
+ {
+ var window = ShowPreview();
+ Assert.That(Find(window, "LexemeFormEditor.seh").FontFamily.Name, Is.EqualTo("Charis SIL"));
+ Assert.That(Find(window, "LexemeFormEditor.en").FontFamily.Name, Is.EqualTo("Times New Roman"));
+
+ var label = window.GetVisualDescendants().OfType()
+ .First(t => !string.IsNullOrEmpty(t.Text) && t.FindAncestorOfType() == null);
+ Assert.That(label.FontFamily, Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwUiFont.UiFontFamily));
+ }
+
[AvaloniaTest]
public void Preview_MorphTypeChooser_UsesSharedOptionPicker_AndReturnsFocus()
{
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs
new file mode 100644
index 0000000000..bb4811d525
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs
@@ -0,0 +1,282 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Headless.NUnit;
+using Avalonia.Media;
+using Avalonia.Media.TextFormatting;
+using Avalonia.Threading;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
+
+namespace FwAvaloniaTests
+{
+ /// Checks interface font selection, bundled faces and glyph rendering.
+ [TestFixture]
+ public class FwUiFontTests
+ {
+ private static IGlyphTypeface Resolve(FontWeight weight, FontStyle style = FontStyle.Normal)
+ {
+ var bundled = new FontFamily(FwUiFont.CollectionKey + "#Andika");
+ Assert.That(FontManager.Current.TryGetGlyphTypeface(new Typeface(bundled, style, weight), out var face),
+ Is.True, "Andika " + weight + " " + style + " must resolve from the bundled collection");
+ return face;
+ }
+
+ // Shapes the text in the interface font and returns the typeface and glyph id of each
+ // character, so a missing glyph (id 0) or a wrong face fails with the language named.
+ private static List<(IGlyphTypeface Face, ushort Glyph)> Shape(FontFamily family, string text)
+ {
+ var layout = new TextLayout(text, new Typeface(family), 13, Brushes.Black);
+ var result = new List<(IGlyphTypeface, ushort)>();
+ foreach (var run in layout.TextLines.SelectMany(l => l.TextRuns).OfType())
+ foreach (var info in run.GlyphRun.GlyphInfos)
+ result.Add((run.GlyphRun.GlyphTypeface, info.GlyphIndex));
+ return result;
+ }
+
+ /// UI languages of the Localizations/l10ns satellites, with a sample string in
+ /// each one's script. These samples exercise the existing named fallbacks.
+ private static readonly object[] FallbackLanguages =
+ {
+ new object[] { "fa", "\u0641\u0627\u06CC\u0644" },
+ new object[] { "hi", "\u092B\u093C\u093E\u0907\u0932" },
+ new object[] { "ko", "\uD30C\uC77C" },
+ new object[] { "te", "\u0C2B\u0C48\u0C32\u0C4D" },
+ new object[] { "zh-CN", "\u6587\u4EF6" },
+ };
+
+ private static string Sample(string tag)
+ => (string)FallbackLanguages.Cast