Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
74dbfcc
Add Lexical Edit Avalonia migration plan and coverage
johnml1135 Jun 11, 2026
5eb7c51
Updates
johnml1135 Jun 11, 2026
4330a6e
IMplementation 1.0
johnml1135 Jun 11, 2026
d88d079
Next round of xml retirement
johnml1135 Jun 11, 2026
bd2867c
Round 2 of alignment and no WinForms in Lexical Edit
johnml1135 Jun 11, 2026
1d525a2
Jump to lists, gear icons
johnml1135 Jun 12, 2026
391cb6b
Updates skills, tests and lexical edit view
johnml1135 Jun 12, 2026
5b5f282
Bug fixes and cleanup
johnml1135 Jun 12, 2026
414fb89
Fix up multiselect from list
johnml1135 Jun 12, 2026
872b54b
Add remaining custom fields
johnml1135 Jun 13, 2026
406a4b0
ghost lexical reference fields
johnml1135 Jun 13, 2026
6595dbd
Openspec for multi-writing-system
johnml1135 Jun 13, 2026
100fff8
Retire POC
johnml1135 Jun 13, 2026
f97c3a3
LexReferenceMultiSlice
johnml1135 Jun 13, 2026
639b4d6
Poc cleanup and initial 6.13 work
johnml1135 Jun 13, 2026
5ea761e
multi-writing-system - next chunk
johnml1135 Jun 15, 2026
aa90590
Almost done with mult-writing-systems
johnml1135 Jun 15, 2026
aacaa1b
Further cleanup
johnml1135 Jun 15, 2026
67a6f39
Almost ready for PR
johnml1135 Jun 15, 2026
15dd2fb
Planning all Avalonia phases
johnml1135 Jun 15, 2026
0541230
Getting towards phases 1-4 done
johnml1135 Jun 15, 2026
1a4478c
add openspec change for editable table
johnml1135 Jun 15, 2026
5fa3bee
Intermediate - almost done
johnml1135 Jun 16, 2026
94d96ce
Virtualization table
johnml1135 Jun 16, 2026
fa42660
Take work from editable-table branch
johnml1135 Jun 16, 2026
d7095b6
Updates from review
johnml1135 Jun 16, 2026
3c22db7
Updates from test coverage review
johnml1135 Jun 16, 2026
c27cd67
More updates from review
johnml1135 Jun 16, 2026
348263e
fix bugs
johnml1135 Jun 16, 2026
c6201d0
Headless integration tests
johnml1135 Jun 16, 2026
3481a74
Update skills
johnml1135 Jun 16, 2026
d20135b
Table render updates
johnml1135 Jun 16, 2026
57acf71
fix more bugs
johnml1135 Jun 17, 2026
7a2eaf4
Further refinement
johnml1135 Jun 17, 2026
8a15dd2
Updates from reviews
johnml1135 Jun 17, 2026
661c299
Fixes
johnml1135 Jun 17, 2026
21ce676
More dialogs, better feature parity
johnml1135 Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
143 changes: 143 additions & 0 deletions .claude/skills/fieldworks-avalonia-ui/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
name: fieldworks-avalonia-ui
description: "Build, review, or fix Avalonia UI code in FieldWorks: XAML, MVVM, view models, owned controls, headless tests, preview host, accessibility identity, and product-vs-preview wiring. Use for any change under Src/Common/FwAvalonia/, Src/Common/FwAvaloniaPreviewHost/, or Src/**/*.Avalonia/, and for net48/net8 Avalonia test changes — even if the request only mentions a control, a binding, a style, or a flaky UI test. For whole-surface migration planning use fieldworks-winforms-to-avalonia-migration first."
---

# FieldWorks Avalonia UI

## Use This For

- Avalonia XAML, view models, commands, lifetimes, dispatching, and
resource/style changes.
- New or changed projects under `Src/**/**/*.Avalonia/`,
`Src/Common/FwAvalonia/`, and `Src/Common/FwAvaloniaPreviewHost/`.
- Preview Host module registration, sample data providers, and UI
diagnostics (see `.github/instructions/avalonia.instructions.md` for
build/preview commands and project layout rules).
- UI host wiring that selects between Avalonia and legacy UI — apply
`fieldworks-ui-wiring-review` alongside this skill.

## Start From the Established Patterns

Do not design controls or seams from scratch. The migration hub skill
(`fieldworks-winforms-to-avalonia-migration`) documents the decided
architecture; its `references/architecture-patterns.md` covers owned
controls, writing-system text fields, dialogs/flyouts, validation, and
lifetime. Canonical code to imitate:

- Owned field controls: `Src/Common/FwAvalonia/Region/FwFieldControls.cs`,
`FwOptionPicker.cs`, `RegionMenuFlyout.cs`, `HoverReveal.cs`
- Region view + focus memory: `LexicalEditRegionView.cs`,
`RegionFocusMemory.cs`
- Seams (scheduler, lifetime, clipboard, edit sessions):
`Src/Common/FwAvalonia/Seams/ISeams.cs`
- Headless test setup: `Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs`;
examples in `RegionEditingTests.cs`, `VisualParityAndDensityTests.cs`
- Density constants: `Src/Common/FwAvalonia/Poc/PocDensity.cs`
- **Dialog kit (XAML + CommunityToolkit.Mvvm + compiled bindings):**
`Src/Common/FwAvaloniaDialogs/` — `OptionsDialogView.axaml`/`.axaml.cs` +
`OptionsDialogViewModel.cs`; headless tests in `FwAvaloniaDialogsTests/`.
This is the verified template for hand-authored dialogs — see
"Converting a WinForms dialog (MVVM kit)" below.

## Converting a WinForms dialog (MVVM kit)

Hand-authored dialogs/wizards use **XAML + CommunityToolkit.Mvvm + compiled
bindings** — NOT the region/IR pattern (that is only for XML-view-definition
surfaces). Decided 2026-06-15; rationale in
`avalonia-migration-roadmap/complete-migration-program.md` §11.3. Full
step-by-step + the working template:
`references/dialog-conversion.md`. The shape, per dialog:

1. **View** `XyzDialogView.axaml` (+ `.axaml.cs`): a `UserControl` (not a
`Window` — see modality below), `x:DataType` set to the view-model,
compiled `{Binding}`s, and a stable `AutomationProperties.AutomationId`
on every interactive control. Reuse owned controls (`FwMultiWsTextField`,
`FwOptionPicker`) for writing-system fields and list pickers.
2. **View-model** `XyzDialogViewModel.cs`: `ObservableObject` with
`[ObservableProperty]` state and `[RelayCommand]` actions; expose the
result (e.g. `Accepted`). Keep it LCModel-free for the view; bind real
settings/domain through the app-settings/edit-session seams.
3. **Tests** `XyzDialogTests.cs` (headless `[AvaloniaTest]`): assert the
compiled bindings propagate both directions and the commands fire — this
is the per-dialog definition of done.

Rules specific to dialogs:

- **It lives in `Src/Common/FwAvaloniaDialogs/`** (the dedicated XAML project),
never in the pure-C# `FwAvalonia` foundation. Add new dialog projects to
`FieldWorks.sln` (restore + VS) but build them in `build.ps1`'s Avalonia
loop; keep the XAML compile off the main `FieldWorks.proj` traversal (the
exclude pattern there). Exclude any nested test folder from the library's
compile glob (`<Compile Remove="XxxTests/**/*.cs"/>`).
- **Modality during coexistence:** no Avalonia `Window.ShowDialog`. Show the
dialog `UserControl` via **`AvaloniaDialogHost.ShowModal(owner, view, vm, title)`**
(in `Src/Common/FwAvalonia/`), which hosts it in a WinForms-owned modal `Form`
(per the hub's `dialog-ownership.md`). The view-model implements
**`IDialogViewModel`** and raises `CloseRequested(bool)` from OK/Cancel — no
windowing in the VM. A dialog is "view + VM + `ShowModal`."
- **Scope:** simple/confirmation/settings dialogs are good junior+AI work;
Views-engine-coupled dialogs (Find/Replace, Styles host `IVwRootSite`)
belong with the document engine (Stage 9), NOT this kit.

## Required Checks

- Use current Avalonia docs for uncertain APIs; do not guess dispatcher,
headless, automation, or binding behavior.
- Keep product UI strings localizable (`FwAvaloniaStrings.resx` or the
StringTable lane); prototype hardcoded strings must be called out as gaps.
- Stamp stable, nonlocalized `AutomationProperties.AutomationId` (derived
from IR `StableId` where applicable) and localized
`AutomationProperties.Name` on user-facing controls.
- UI logic stays in bindings/view models where practical; avoid
logic-heavy code-behind.
- For any Avalonia "select from a list" surface, prefer the shared
`FwOptionPicker` pattern in `Src/Common/FwAvalonia/Region/FwOptionPicker.cs`
(AutoCompleteBox-based, keyboard-safe, search-capable, compact density)
over ad hoc `ListBox` popups or one-off editable selectors. Reach for a raw
`ComboBox` only when the UX explicitly needs an always-visible inline combo
rather than the shared flyout selector.
- Do not fix Avalonia keyboard, focus, filtering, selection, popup, or
rendering bugs by patching `System.Windows.Forms` hosts, WinForms
interop message handling, or other legacy host-only routes unless the
task explicitly targets interop behavior. Default to fixing the issue
inside the Avalonia control tree or Avalonia-owned seams.
- Marshal to the UI thread through `IUiScheduler` (or Avalonia dispatcher
in non-region code); no hidden `Task.Run`, no sync-over-async.
- Keep preview data lightweight unless the change explicitly opts into
LCModel/project data; product-facing paths use real edit-session/domain
contracts — detached DTO-only models remain preview-only.
- Headless tests: simulate input on `Window`, flush with
`Dispatcher.UIThread.RunJobs()`, and capture visual regression frames
with Skia (`UseHeadlessDrawing=false` + `CaptureRenderedFrame()`).
- Evidence runs through `./build.ps1` and `./test.ps1` via the normal repo
graph, not branch-only lanes.

## Review Red Flags

- A Common project directly references a feature module without an
explicit architecture decision.
- Preview-only code launched from product UI without a feature gate.
- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar
instead of proving the real broadcast/wiring path.
- The active Avalonia path drives hidden legacy rendering/menu
infrastructure (see the hub skill's hard rules).
- Sleep-based or timing-sensitive UI tests.
- Claims of accessibility, localization, IME, or keyboard parity without
executable evidence (see the hub skill's
`references/parity-evidence.md` §"Evidence language").

## Handoff

Report Avalonia docs consulted, tests run, remaining prototype gaps,
whether the change is product-facing or preview-only, and how the live
wiring path was validated for each affected host. For parity work, say
whether visual evidence is control-level headless capture or live desktop
capture, and which automation identities were assigned.

## Keep This Skill Current

When a control pattern, headless-test technique, or Avalonia API gotcha
proves out (or a pointer above goes stale), update this skill in the same
PR — and route durable architecture lessons through the protocol in
`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`.
206 changes: 206 additions & 0 deletions .claude/skills/fieldworks-avalonia-ui/references/dialog-conversion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Converting a WinForms dialog to Avalonia (MVVM kit)

The verified playbook for migrating a hand-authored WinForms dialog/wizard to Avalonia using the
**XAML + CommunityToolkit.Mvvm + compiled bindings** kit. Proven by the **real** Tools→Options
migration (`Src/Common/FwAvaloniaDialogs/`): four tabs wired to the live settings bus via an
`OptionsState` DTO seam, launched New-mode-gated from `LexTextApp`/`WelcomeToFieldWorksDlg`
(`Src/LexText/LexTextControls/AvaloniaOptionsDialogLauncher.cs`), headless tests green on net48
through `build.ps1`. Decision + rationale: `avalonia-migration-roadmap/complete-migration-program.md` §11.3.

> Use this for **hand-authored** dialogs/wizards. Do NOT use the region/IR/composer pattern — that is
> only for surfaces driven by FieldWorks XML view-definitions (entry/detail/browse). Dialogs have no
> XML layout to compile.
>
> Do NOT convert Views-engine-coupled dialogs here (e.g. `FwFindReplaceDlg`, `FwStylesDlg` host
> `IVwRootSite`/`SimpleRootSite`) — those go with the document engine (Stage 9), not this kit.

## 0. Where dialogs live

All MVVM dialogs go in **`Src/Common/FwAvaloniaDialogs/`** — the dedicated XAML-enabled project. Never
add XAML to the pure-C# `FwAvalonia` foundation. The project enables `EnableDefaultAvaloniaItems` +
`AvaloniaUseCompiledBindingsByDefault` and references `CommunityToolkit.Mvvm` + the foundation.

## 1. The three artifacts (copy this shape)

### View-model — `XyzDialogViewModel.cs`
```csharp
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace FwAvaloniaDialogs
{
public partial class XyzDialogViewModel : ObservableObject
{
[ObservableProperty] private bool _someFlag;
[ObservableProperty] private string _someChoice = "A";
public IReadOnlyList<string> Choices { get; } = new[] { "A", "B" };

public bool? Accepted { get; private set; } // null until closed; true=OK, false=Cancel

[RelayCommand] private void Ok() => Accepted = true;
[RelayCommand] private void Cancel() => Accepted = false;
}
}
```
`[ObservableProperty]` generates the public property + change notification; `[RelayCommand]` generates
`OkCommand`/`CancelCommand`. The class MUST be `partial`. Keep it LCModel-free for the view; bind real
settings/domain through the app-settings/edit-session seams (not directly).

### View — `XyzDialogView.axaml` (+ `.axaml.cs`)
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:FwAvaloniaDialogs"
xmlns:res="clr-namespace:FwAvaloniaDialogs"
x:Class="FwAvaloniaDialogs.XyzDialogView"
x:DataType="vm:XyzDialogViewModel">
<DockPanel Margin="8">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="{x:Static res:FwAvaloniaDialogsStrings.Ok}" IsDefault="True" Command="{Binding OkCommand}"
AutomationProperties.AutomationId="Xyz.Ok"/>
<Button Content="{x:Static res:FwAvaloniaDialogsStrings.Cancel}" IsCancel="True" Command="{Binding CancelCommand}"
AutomationProperties.AutomationId="Xyz.Cancel"/>
</StackPanel>
<CheckBox Content="{x:Static res:FwAvaloniaDialogsStrings.SomeFlag}" IsChecked="{Binding SomeFlag}"
AutomationProperties.AutomationId="Xyz.SomeFlag"/>
</DockPanel>
</UserControl>
```
Visible text comes from the resource accessor (`{x:Static …}`), not literals — see §5. `AutomationId`
stays a nonlocalized literal.
```csharp
using Avalonia.Controls;
namespace FwAvaloniaDialogs
{
public partial class XyzDialogView : UserControl
{
public XyzDialogView() => InitializeComponent(); // InitializeComponent is generated by the XAML compiler
}
}
```
- **`x:DataType` is mandatory** for compiled bindings — it's how `{Binding}` is statically checked at
build time (the whole point: a wrong property name fails the *build*).
- **Use a `UserControl`, not a `Window`** — see modality below.
- Stamp a stable, nonlocalized `AutomationProperties.AutomationId` on every interactive control.
- Reuse owned controls: `FwMultiWsTextField` for writing-system text, `FwOptionPicker` for "select from
a list". Use a raw `ComboBox` only for an always-visible inline settings combo.

### Tests — `FwAvaloniaDialogsTests/XyzDialogTests.cs`
Headless `[AvaloniaTest]`: create the view with a VM as `DataContext`, `window.Show()`,
`Dispatcher.UIThread.RunJobs()`, then assert (a) compiled binding propagates **both** directions and
(b) the generated commands fire. This is the per-dialog definition of done. See
`FwAvaloniaDialogsTests/OptionsDialogTests.cs` for the exact pattern (find controls by AutomationId via
`GetVisualDescendants`).

## 2. Modality during coexistence — use `AvaloniaDialogHost` (built)

There is **no Avalonia `Window.ShowDialog`** while WinForms owns the app. Use the reusable wrapper:

```csharp
// view-model implements IDialogViewModel; OK/Cancel raise CloseRequested(bool).
var vm = new XyzDialogViewModel();
var view = new XyzDialogView { DataContext = vm };
bool? accepted = AvaloniaDialogHost.ShowModal(ownerForm, view, vm, "Xyz"); // owner = the WinForms host form
if (accepted == true) { /* apply vm state */ }
```

`AvaloniaDialogHost.ShowModal` (in `Src/Common/FwAvalonia/AvaloniaDialogHost.cs`) shows the dialog
`UserControl` inside a **WinForms-owned modal `Form`** (per the hub's `dialog-ownership.md`: owner-relative,
restores focus on close), and the view-model closes it by raising `CloseRequested` — **no windowing code in
the view-model**. The view-model implements **`IDialogViewModel`** (`Src/Common/FwAvalonia/IDialogViewModel.cs`):
```csharp
public partial class XyzDialogViewModel : ObservableObject, IDialogViewModel
{
public event EventHandler<bool> CloseRequested;
[RelayCommand] private void Ok() { /* set result */ CloseRequested?.Invoke(this, true); }
[RelayCommand] private void Cancel() { CloseRequested?.Invoke(this, false); }
}
```
So a new dialog is genuinely **"view + VM + `ShowModal`."** (Avalonia init funnels through the single
`FwAvaloniaRuntime.EnsureInitialized()` — don't add another init guard.)

## 2a. Density — compact, matching the legacy WinForms dialogs

Dialogs must match legacy WinForms density, **not** the roomy Fluent defaults. The baseline is
`Src/Common/FwAvalonia/CompactDialogStyles.cs` — `AvaloniaDialogHost.ShowModal` applies it to **every**
hosted dialog body automatically (font 12, line-control min-height ~23, tight padding, no Fluent
min-height floors), so new dialogs inherit compact density with **zero per-dialog work**; don't
re-define density per dialog and don't reach for `FwAvaloniaDensity` (that owns the region/table, a
different surface). In the view: use tight `Margin="8"` / `Spacing="4"–"6"` (see `OptionsDialogView.axaml`)
and let `ShowModal`'s compact size defaults (420×320) fit the content — don't pass large explicit sizes.
If a control type still looks roomy, add its compact setter to `CompactDialogStyles` (one place, all dialogs).

## 2b. Real settings via a DTO seam; live-apply over restart

The VM stays LCModel/PropertyTable-free. Carry real settings across the boundary with a **plain DTO**
(`OptionsState`): the product edge populates it from the live bus, the VM edits it, and a product-side
launcher applies it on OK — replicating the legacy dialog's exact apply order. Canonical:
`AvaloniaOptionsDialogLauncher` (builds `OptionsState`, calls `ShowModal`, applies Reporting/Update/
UI-mode/UI-language/plugins/Save). **Prefer live apply to "restart required":** for a setting that can
take effect immediately, put an `Action` callback on the DTO and an `Apply` command on the VM (e.g. the
Lexical Edit UI Mode — broadcasting the `PropertyTable` "UIMode" property re-resolves the open surfaces
live, so the button says **"Apply"**, not "Restart to apply"). Only keep a restart prompt for settings
that genuinely cannot apply mid-session (e.g. UI-language).

## 3. Build wiring for a NEW dialog *project* (only when adding one; usually you just add files)

`FwAvaloniaDialogs` already exists. If you ever add another XAML dialog project, replicate:
- Add it (and its `*Tests`) to **`FieldWorks.sln`** (`dotnet sln FieldWorks.sln add <csproj>`) — for
restore + Visual Studio.
- Add `Add-ProjectIfPresent` lines in **`build.ps1`**'s net48 Avalonia loop (main project + test under
`if ($IncludeTests)`).
- **Exclude** it (and its test) from the **`FieldWorks.proj`** glob/`*Tests` includes — the XAML compile
stays on build.ps1's isolated Avalonia-loop path, off the main solution traversal.
- In the library `.csproj`, exclude the nested test folder from the compile glob:
`<Compile Remove="XxxTests/**/*.cs"/>` and `<AvaloniaXaml Remove="XxxTests/**/*.axaml"/>`.

## 4. Build & verify

```powershell
./test.ps1 -SkipNative -TestProject FwAvaloniaDialogsTests
```
Evidence runs through the normal repo scripts. Compiled-binding errors surface at build time; runtime
binding/command behavior is proven by the headless tests.

## 5. Localization (don't ship hardcoded strings)

Dialog text is **product messages → `.resx`** (apply `fieldworks-localization-review`). Each Avalonia UI
project owns its own resources: the foundation uses `FwAvaloniaStrings`; the dialog kit uses
**`FwAvaloniaDialogsStrings`** (`Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx` + the
hand-written accessor `FwAvaloniaDialogsStrings.cs`). To add a dialog's strings:

1. Add `<data name="ksXxx"><value>…</value><comment>translator note</comment></data>` entries to
`FwAvaloniaDialogsStrings.resx` (the `.resx` is auto-embedded; the project's `<RootNamespace>` is what
makes the Crowdin satellite build work — never drop it).
2. Add a `public static string Xxx => Resources.GetString("ksXxx");` property to the accessor (mirror
`FwAvaloniaStrings.cs`; `ResourceManager("FwAvaloniaDialogs.FwAvaloniaDialogsStrings", …)`).
3. Bind from XAML with **`{x:Static res:FwAvaloniaDialogsStrings.Xxx}`** (declare
`xmlns:res="clr-namespace:FwAvaloniaDialogs"`). Use it for `Content`, `Header`, `Text`, and the
`ShowModal` title.

Rules: **`AutomationId` stays a nonlocalized literal**; visible text (which is also the accessible name on
a `ContentControl`) is localized. Combo/list *values* that are codes or data (e.g. `"en"`, channel names)
are not UI strings — leave them. Test it: assert the accessor resolves (`…GeneralTab == "General"`) and
that a control's `Content` equals the accessor value (proves the `x:Static` binding), per
`OptionsDialogTests`.

## 6. Gotchas learned in the spike

- **Nested test files leak into the library** unless excluded — the SDK glob compiles
`FwAvaloniaDialogsTests/**/*.cs` into `FwAvaloniaDialogs` (which lacks NUnit/Headless refs) → CS0246.
Fix: the `<Compile Remove="…Tests/**/*.cs"/>` in §3 (mirrors `FwAvalonia.csproj`).
- **Restore is solution-scoped** (`dotnet restore FieldWorks.sln`) but the managed build globs
`Src\**`. A project not in the `.sln` builds in the traversal without `project.assets.json`
(`NETSDK1004`). Be in the sln OR excluded from the traversal (this kit does both: in sln, built in the
Avalonia loop).
- **Compiled bindings need `x:DataType`** on the view root, or `{Binding}` falls back / fails to compile.
- The Avalonia XAML compiler composes fine with the customized net48 build **because** the Avalonia loop
runs each project through plain `MSBuild /t:Restore;Build`, away from the main traversal's ILRepack.

## 7. Author tiers / scope

- **Junior + AI:** small/confirmation/settings dialogs (mechanical; compiled bindings + headless VM tests
are the safety net).
- **Mid:** wizards, writing-system setup, project properties.
- **Not here (→ Stage 9):** Views-engine-coupled dialogs (Find/Replace, Styles).
Loading
Loading