diff --git a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md
index 00ae845bf9..adfbe877bf 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,12 @@ sit inside a `Border.fwFieldHost` that supplies the box.
directly (not three independently-maintained copies) -- see
`Src/Common/FwAvalonia/FwThemeResources.cs`.
+**Font family:** the bundled Andika 7.000, token `FwUiFontFamily` (`fonts:FieldWorks#Andika`) in the same
+file. `FwUiFont.ApplyTo` (called from both `Application.Initialize` overrides) registers the collection
+and points Semi's `DefaultFontFamily`, `ContentControlThemeFontFamily` and `SemiFontFamilyRegular` at it, so
+chrome inherits it. The stack continues with fallback fonts for UI languages whose script Andika lacks (`FwUiFont.FallbackFamiliesFor`). Data text keeps its writing-system font. Measure UI text with `FwUiFont.UiFontFamily`,
+never `FontFamily.Default`. 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..5d792bad24
--- /dev/null
+++ b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md
@@ -0,0 +1,85 @@
+# The Avalonia interface font is controlled by the theme; data text keeps its writing-system font
+
+LT-22854. FieldWorks controls its interface font instead of leaving it to the platform
+default. One theme token, `FwUiFontFamily` in `FwColorTokens.axaml`, names the font that every
+Avalonia surface (dialogs, detail view chrome, menus, flyouts) draws its interface text in. The
+font is bundled in `FwAvaloniaTheme.dll` and registered at startup as the private font
+collection `fonts:FieldWorks`, with per-language fallbacks, so every machine and every test run
+looks the same, and changing the font later is a one-token change plus the font files. The
+proposed default is Andika 7.000 (`fonts:FieldWorks#Andika`; see "Alternatives for the
+default"). `FwUiFont.ApplyTo`, called from both
+`Application.Initialize` overrides, registers the collection and points Semi's
+`DefaultFontFamily`, `ContentControlThemeFontFamily` and `SemiFontFamilyRegular` resources at
+the token, so text inherits it from the root. Data text keeps the font of its writing system.
+The family is a stack: the default font, then the fallback fonts of the UI language the application
+started in (see "Fallback fonts" below).
+
+**Why a bundled collection rather than an installed font**: the installer ships Andika 6.101
+as a system font and a developer machine may carry any build or none. A bundled 7.000 gives
+the same glyphs and kerning in the installed product, the Preview Host and headless tests,
+which would otherwise vary by machine. A writing system that names "Andika" still resolves to
+the system copy through the plain family name, because a private collection key never captures
+it, so data rendering is unchanged.
+
+**Why Semi's resource keys**: Semi sets the family on its own root and popup themes from those
+three resources, and the WinForms-hosted root uses the same theme. Setting only
+`FontManagerOptions.DefaultFamilyName` would leave Semi's stack (landing on Segoe UI) in
+charge, and the headless test builders bypass that option anyway.
+
+**Five faces of the default**: Regular, Italic, Bold, BoldItalic and SemiBold, the weights and styles the
+interface uses, about 4.1 MB. Avalonia matches the nearest weight for anything else.
+
+**Sizes stay at 11 px for surfaces and 13 px for labels.** Andika's x-height (0.508 em) matches
+Segoe UI's (0.500 em), so legibility holds. Its line height is 1.61 em against 1.33 em, so
+a line grows about 3 px at 11 px (14.6 to 17.7) and rows and dialogs get taller. This is
+visible in the refreshed baseline screenshots. Line-height tokens to recover WinForms row
+height are a separate decision.
+
+**Fallback fonts**: Andika lacks the scripts of five of the sixteen UI languages shipped in
+`Localizations/l10ns` (az es fa fr hi hu id ko ms pt ru rw te tr vi zh-CN). Persian (fa), Hindi
+(hi), Korean (ko), Telugu (te) and Simplified Chinese (zh-CN) therefore get a named fallback
+after Andika in the family stack, chosen from the UI culture by `FwUiFont.FallbackFamiliesFor`.
+Each list names a Windows system font first (Segoe UI, Nirmala UI, Malgun Gothic, Microsoft
+YaHei UI), so the fallback is present on every Windows machine, and then the SIL or Noto family
+that an SIL per-language font table names (Scheherazade New, Annapurna SIL, Noto Sans CJK KR, Noto
+Sans Telugu, Noto Sans CJK SC), which is used where installed and skipped otherwise. Avalonia
+tries the families in the stack per character, so Latin text stays in Andika. The table holds
+only these five languages and each has a test; a language added to Crowdin later needs a new
+entry and test. No fallback fonts are bundled. The other eleven languages are drawn in Andika
+alone, which a test confirms with a sample string each.
+
+**What this deliberately does not do**:
+
+- It does not set `FontManagerOptions.DefaultFamilyName`, which would change the fallback for
+ data text whose writing-system font is missing.
+- It does not bump the installer's Andika 6.101 to 7.000 (tracked under LT-19288).
+- It does not bundle any fallback font; the fallbacks are Windows system fonts and installed
+ SIL or Noto families.
+- It does not follow a UI language change after start-up: the stack is chosen once, as the
+ UI language itself is only read at start-up.
+- It does not add FontFamily setters to dialog or surface styles; inheritance carries the font.
+
+**Revisit when**: the installer ships Andika 7.000 as a system font (the bundled copy could
+then be dropped), line-height tokens are introduced, or testers report unreadable text on a
+machine that has none of the named fallback families.
+
+**Alternatives for the default**: Andika is the proposed default, not a requirement of this
+decision. Swapping it means replacing the font files in `Src/Common/FwAvaloniaTheme/Assets/Fonts`
+(with their licence and notice) and changing the value of `FwUiFontFamily`; no code changes. Its
+line height (1.61 em) is taller than Segoe UI's (1.33 em), so a font with tighter vertical
+metrics would keep rows closer to the WinForms height.
+
+- The platform UI font (Segoe UI on Windows) is the closest to today's look (x-height 0.500,
+ line height 1.33). Its licence does not allow bundling, so other platforms and test machines
+ would differ. The token can still name it if the team prefers today's look.
+- Sophia Nubian 1.0 (SIL, OFL, 2009; https://software.sil.org/sophianubian/) is a Univers-like
+ sans whose vertical metrics are closest to Segoe UI (x-height 0.500, line height 1.385). It has
+ no Cyrillic, so it cannot draw the Russian UI. It also has no SemiBold and little Greek and
+ IPA, and its last release was in 2009.
+- Padauk 6.000 (SIL, OFL, 2025; https://software.sil.org/padauk/) is a Myanmar family with a
+ Latin sans. It has a SemiBold weight. Its x-height is 0.435, so it would need a size about 15%
+ larger, and it has no Cyrillic, IPA or Italic.
+
+Andika 7.000 is the only SIL Latin sans with full Latin, Cyrillic, Greek and IPA and all the
+weights FieldWorks uses. Its cost is the taller line (1.61 em against 1.33 em). Metrics are per
+em.
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..6c83f51912 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 (the bundled Andika, `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..476a32584e 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..b09a2b6964 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..17d159b191 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..c8b3349e46 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..bfcca8ac27 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..54b2eaa4ca 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..60c2786122 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..f9e7c42b30 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..77fd185ec4 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..acb189be31
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs
@@ -0,0 +1,259 @@
+// 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
+{
+ ///
+ /// The Avalonia interface font is the one the FwUiFontFamily token names (today the Andika
+ /// bundled in FwAvaloniaTheme), drawn from its own font collection rather than from whatever
+ /// is installed on the machine, and it
+ /// reaches text in every kind of root the hosts create. Runs under the same headless app
+ /// (TestAppBuilder -> FwAvaloniaApp) as the rest of the suite.
+ ///
+ [TestFixture]
+ public class FwUiFontTests
+ {
+ // The family name the token declares, so a change of default font needs no test edit.
+ private static string DefaultName => FwUiFont.UiFontFamily.FamilyNames.First();
+
+ private static IGlyphTypeface Resolve(FontWeight weight, FontStyle style = FontStyle.Normal)
+ {
+ var bundled = new FontFamily(FwUiFont.CollectionKey + "#" + DefaultName);
+ Assert.That(FontManager.Current.TryGetGlyphTypeface(new Typeface(bundled, style, weight), out var face),
+ Is.True, DefaultName + " " + 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. The scripts the default font lacks are the first five.
+ 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