Summary
The top "view strip" (the horizontal ListBox at the top of MainWindow listing Inbox, Workstreams, Pull Requests, Sessions, Workspaces, Git Workspaces, Tools, Entity Browser) renders each item's small box indicator as a solid / filled square at initial paint. The first time the user hovers the pointer over an item, that item's box permanently changes to the intended outline square and stays outlined thereafter — moving the pointer away does not revert it. Every item independently transitions from "solid" to "outline" on its first pointerover, one hover at a time. The outline square is the correct desired appearance; the bug is that every item's box starts filled and only self-corrects after being hovered once.
Correction: an earlier diagnosis described this as a transient :pointerover state that swapped the glyph while the pointer was over the item and reverted when it left. That framing is wrong. The change is permanent per item after the first hover, which rules out a :pointerover visual-state selector and points at lazy text-shaping / measure invalidation, not a style swap.
Root Cause
The item template renders the box indicator as a bare Unicode character (◻, U+25A1 WHITE SQUARE) inside a plain TextBlock, with no explicit FontFamily, so it inherits the window-wide Theme.FontFamily = Inter. Inter is a Latin-focused sans-serif that does not contain U+25A1 (Geometric Shapes block), so Avalonia's text engine must fall back to another typeface for that codepoint.
The observed "solid at start, permanently outlined after one hover" behavior is characteristic of lazy / cached glyph-run shaping combined with a hover-triggered re-measure, not of a :pointerover style setter:
- Initial layout — When each
ListBoxItem's content is first shaped, U+25A1 falls back and resolves to a glyph that draws as a filled block (either the .notdef "tofu" rectangle or a filled U+25A1 from the first fallback typeface picked). The shaped glyph run is cached on the text layout.
- First pointerover on that item — The Fluent default
ListBoxItem template applies :pointerover setters (background/foreground brush changes on the item's ContentPresenter), which invalidate the item's visual and force a re-measure/re-render of the TextBlock inside the DataTemplate. On this second shaping pass, the fallback resolution yields the correctly-drawn outline U+25A1 glyph. The re-shaped run replaces the cached one.
- Pointer leaves — The
:pointerover setters revert, but the TextBlock's cached glyph run is not re-shaped again (nothing invalidates its text layout). The correct outline glyph therefore persists for the lifetime of that item. This is why the fix is "sticky" per item rather than transient.
This also explains why Tools, which is the initially selected item in the reported screenshot, may already show the outline square before any hover: the Fluent :selected state applies its own template/brush changes at load, forcing the same re-measure as :pointerover and producing the outline glyph on first paint.
Key locations:
Phantom.Workspaces/MainWindow.axaml lines 43–62 — the ListBox with Classes="top-view-list" and its DataTemplate:
<ListBox Grid.Column="1"
Classes="top-view-list"
ItemsSource="{Binding TopLevelViews}"
SelectedItem="{Binding SelectedTopLevelView}">
...
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ViewDefinitionViewModel">
<Border Classes="top-view-item">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding IconGlyph}" /> <!-- line 56: the offending glyph -->
<TextBlock Text="{Binding Title}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs line 43 and line 1224 — IconGlyph hard-coded to the Unicode character:
IconGlyph = "◻", // U+25A1 WHITE SQUARE
Phantom.Workspaces/MainWindow.axaml line 18 — the window inherits the theme font:
FontFamily="{DynamicResource Theme.FontFamily}"
Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml line 14 — Theme.FontFamily = Inter (which lacks U+25A1):
<FontFamily x:Key="Theme.FontFamily">Inter</FontFamily>
Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml lines 358–367 — the only styles targeting the top view strip; neither the ListBox.top-view-list style nor the Border.top-view-item style pins a font or forces a stable measure for the glyph:
<Style Selector="ListBox.top-view-list">
<Setter Property="Margin" Value="18,0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="Border.top-view-item">
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="10,6" />
</Style>
Root cause, in one sentence: the top view-strip glyph is a codepoint (◻ U+25A1) that the ambient Inter font does not contain, so Avalonia's per-run font fallback picks a filled glyph on first shaping, and the first pointerover invalidates that item's text layout and re-shapes it to the correct outline glyph — which then persists because no subsequent event re-invalidates the layout.
Affected Files
| File |
Role |
Phantom.Workspaces/MainWindow.axaml |
Declares the ListBox.top-view-list and its item template (the icon TextBlock at line 56). Where the fix is applied. |
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs |
Sets IconGlyph = "◻" at lines 43 and 1224; obsolete for the top strip once the fix uses a vector shape. |
Phantom.Workspaces/ViewModels/ViewDefinitionViewModel.cs |
Declares the IconGlyph property consumed by the template. Retained for other views (e.g. ⌕ for Entity Browser at MainWindowViewModel.cs:1186). |
Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml |
Contains the ListBox.top-view-list / Border.top-view-item styles (lines 358–367) and defines Theme.FontFamily (line 14). Home for the new indicator style. |
Phantom.Workspaces.Tests/MainWindowViewModelTests.cs |
Existing test suite following the MainWindow_<Scenario>_<ExpectedOutcome> pattern; where regression tests live. |
Design / Fix
The bug's root cause is depending on a Unicode codepoint that the ambient font does not contain, so first-paint shaping resolves the wrong fallback glyph and only a later invalidation corrects it. Any fix that keeps the glyph as text in a font that lacks U+25A1 will be fragile. The chosen fix eliminates the font dependency entirely.
Chosen fix — Replace the character glyph with a deterministic vector outline square
Replace the icon TextBlock in the top-view-strip item template with a fixed-size Border that draws an outline square directly. Vector geometry is not subject to font selection, fallback, or text-shaping caches, so the resting-state appearance is identical to the post-hover appearance and identical for every item on first paint.
Sketch — MainWindow.axaml (item template around lines 54–59):
<Border Classes="top-view-item">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Border Classes="top-view-item-glyph"
Width="12" Height="12"
BorderThickness="1"
Background="Transparent"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" />
</StackPanel>
</Border>
Companion style — SharedStyles.axaml (near lines 358–367):
<Style Selector="Border.top-view-item-glyph">
<Setter Property="BorderBrush" Value="{DynamicResource Theme.Class.normal.Foreground}" />
<Setter Property="CornerRadius" Value="1" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
The IconGlyph property on ViewDefinitionViewModel is retained because other views (e.g. Entity Browser uses ⌕ at MainWindowViewModel.cs:1186) still consume it, but the top view strip stops binding to it and uses the fixed outline Border instead. If a distinct Entity-Browser indicator is required in the strip, use a small Path/StreamGeometry instead of a character — same rationale.
Considered / Background
- Prior (incorrect) diagnosis — transient
:pointerover glyph swap. Originally we described this as a :pointerover visual-state style that swapped the glyph while the pointer was over the item and reverted on pointer-leave. That is inconsistent with the actual observation, in which the correction is permanent after the first hover on each item and does not revert. Superseded — the mechanism is lazy text-shaping / measure invalidation, not a style-driven state swap.
- Option A — Pin an explicit
FontFamily on the icon TextBlock. Add FontFamily="Segoe UI Symbol,Segoe UI,Arial Unicode MS" (or a shared theme resource) so U+25A1 resolves through a font that actually contains it. Minimal diff, but still depends on the host OS shipping those fonts and does not fully insulate against future theme/font changes.
- Option B — Force item re-measure on load / add a resting-state style that mirrors
:pointerover. Attach a loaded handler (or a container style) that invalidates each item's text layout after first render, so the "correct" fallback shaping happens on paint 1 instead of on first hover. This masks the symptom rather than removing the font dependency, and is brittle across Avalonia versions.
- Option C — Use a
Path with a StreamGeometry rectangle stroked at 1px. Equivalent in effect to the Border sketch above, but heavier syntactically. Preferred only if a non-rectangular indicator is later needed.
The chosen Border-based fix is preferred because it removes the root cause (font-fallback dependence for the indicator) rather than merely masking it, so the resting-state and post-hover appearances are guaranteed to match.
Expected Tests
Existing tests for this ViewModel live in Phantom.Workspaces.Tests/MainWindowViewModelTests.cs and follow the pattern MainWindow_<Scenario>_<ExpectedOutcome> (e.g. MainWindow_LeftPaneCollapser_TogglesLeftColumnToZero, MainWindow_Navigate_AutoExpandsCollapsedLeftPane). New tests should be added there and should exercise a headless MainWindow render of the top view strip.
| Test Name |
Class |
What It Verifies |
MainWindow_TopViewList_BoxIndicatorRendersOutlineAtInitialRender |
MainWindowViewModelTests |
On first render of the top view strip, without any pointer interaction, every item's box indicator is present as an outline element (BorderThickness >= 1, transparent/unset Background) — i.e. the correct outline appearance is visible before any hover. |
MainWindow_TopViewList_BoxIndicatorAppearanceUnchangedByFirstPointerOver |
MainWindowViewModelTests |
Simulating a pointerover (and pointer-leave) on a top-view-strip item does not change the visual identity of that item's box indicator — the same outline element is present before, during, and after the hover. This regresses the "solid → outline on first hover" behavior. |
MainWindow_TopViewList_BoxIndicatorIsNotATextGlyph |
MainWindowViewModelTests |
The box indicator is produced by a vector element (Border or Path) inside the item template rather than a TextBlock bound to IconGlyph, so its appearance is independent of Theme.FontFamily and of the text engine's fallback/shaping cache. |
Summary
The top "view strip" (the horizontal
ListBoxat the top ofMainWindowlisting Inbox, Workstreams, Pull Requests, Sessions, Workspaces, Git Workspaces, Tools, Entity Browser) renders each item's small box indicator as a solid / filled square at initial paint. The first time the user hovers the pointer over an item, that item's box permanently changes to the intended outline square and stays outlined thereafter — moving the pointer away does not revert it. Every item independently transitions from "solid" to "outline" on its first pointerover, one hover at a time. The outline square is the correct desired appearance; the bug is that every item's box starts filled and only self-corrects after being hovered once.Root Cause
The item template renders the box indicator as a bare Unicode character (
◻, U+25A1 WHITE SQUARE) inside a plainTextBlock, with no explicitFontFamily, so it inherits the window-wideTheme.FontFamily=Inter.Interis a Latin-focused sans-serif that does not contain U+25A1 (Geometric Shapes block), so Avalonia's text engine must fall back to another typeface for that codepoint.The observed "solid at start, permanently outlined after one hover" behavior is characteristic of lazy / cached glyph-run shaping combined with a hover-triggered re-measure, not of a
:pointeroverstyle setter:ListBoxItem's content is first shaped, U+25A1 falls back and resolves to a glyph that draws as a filled block (either the.notdef"tofu" rectangle or a filled U+25A1 from the first fallback typeface picked). The shaped glyph run is cached on the text layout.ListBoxItemtemplate applies:pointeroversetters (background/foreground brush changes on the item'sContentPresenter), which invalidate the item's visual and force a re-measure/re-render of theTextBlockinside theDataTemplate. On this second shaping pass, the fallback resolution yields the correctly-drawn outline U+25A1 glyph. The re-shaped run replaces the cached one.:pointeroversetters revert, but theTextBlock's cached glyph run is not re-shaped again (nothing invalidates its text layout). The correct outline glyph therefore persists for the lifetime of that item. This is why the fix is "sticky" per item rather than transient.This also explains why
Tools, which is the initially selected item in the reported screenshot, may already show the outline square before any hover: the Fluent:selectedstate applies its own template/brush changes at load, forcing the same re-measure as:pointeroverand producing the outline glyph on first paint.Key locations:
Phantom.Workspaces/MainWindow.axamllines 43–62 — theListBoxwithClasses="top-view-list"and itsDataTemplate:Phantom.Workspaces/ViewModels/MainWindowViewModel.csline 43 and line 1224 —IconGlyphhard-coded to the Unicode character:Phantom.Workspaces/MainWindow.axamlline 18 — the window inherits the theme font:FontFamily="{DynamicResource Theme.FontFamily}"Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axamlline 14 —Theme.FontFamily=Inter(which lacks U+25A1):Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axamllines 358–367 — the only styles targeting the top view strip; neither theListBox.top-view-liststyle nor theBorder.top-view-itemstyle pins a font or forces a stable measure for the glyph:Root cause, in one sentence: the top view-strip glyph is a codepoint (
◻U+25A1) that the ambientInterfont does not contain, so Avalonia's per-run font fallback picks a filled glyph on first shaping, and the first pointerover invalidates that item's text layout and re-shapes it to the correct outline glyph — which then persists because no subsequent event re-invalidates the layout.Affected Files
Phantom.Workspaces/MainWindow.axamlListBox.top-view-listand its item template (the iconTextBlockat line 56). Where the fix is applied.Phantom.Workspaces/ViewModels/MainWindowViewModel.csIconGlyph = "◻"at lines 43 and 1224; obsolete for the top strip once the fix uses a vector shape.Phantom.Workspaces/ViewModels/ViewDefinitionViewModel.csIconGlyphproperty consumed by the template. Retained for other views (e.g.⌕for Entity Browser atMainWindowViewModel.cs:1186).Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axamlListBox.top-view-list/Border.top-view-itemstyles (lines 358–367) and definesTheme.FontFamily(line 14). Home for the new indicator style.Phantom.Workspaces.Tests/MainWindowViewModelTests.csMainWindow_<Scenario>_<ExpectedOutcome>pattern; where regression tests live.Design / Fix
The bug's root cause is depending on a Unicode codepoint that the ambient font does not contain, so first-paint shaping resolves the wrong fallback glyph and only a later invalidation corrects it. Any fix that keeps the glyph as text in a font that lacks U+25A1 will be fragile. The chosen fix eliminates the font dependency entirely.
Chosen fix — Replace the character glyph with a deterministic vector outline square
Replace the icon
TextBlockin the top-view-strip item template with a fixed-sizeBorderthat draws an outline square directly. Vector geometry is not subject to font selection, fallback, or text-shaping caches, so the resting-state appearance is identical to the post-hover appearance and identical for every item on first paint.Sketch —
MainWindow.axaml(item template around lines 54–59):Companion style —
SharedStyles.axaml(near lines 358–367):The
IconGlyphproperty onViewDefinitionViewModelis retained because other views (e.g. Entity Browser uses⌕atMainWindowViewModel.cs:1186) still consume it, but the top view strip stops binding to it and uses the fixed outlineBorderinstead. If a distinct Entity-Browser indicator is required in the strip, use a smallPath/StreamGeometryinstead of a character — same rationale.Considered / Background
:pointeroverglyph swap. Originally we described this as a:pointerovervisual-state style that swapped the glyph while the pointer was over the item and reverted on pointer-leave. That is inconsistent with the actual observation, in which the correction is permanent after the first hover on each item and does not revert. Superseded — the mechanism is lazy text-shaping / measure invalidation, not a style-driven state swap.FontFamilyon the iconTextBlock. AddFontFamily="Segoe UI Symbol,Segoe UI,Arial Unicode MS"(or a shared theme resource) so U+25A1 resolves through a font that actually contains it. Minimal diff, but still depends on the host OS shipping those fonts and does not fully insulate against future theme/font changes.:pointerover. Attach a loaded handler (or a container style) that invalidates each item's text layout after first render, so the "correct" fallback shaping happens on paint 1 instead of on first hover. This masks the symptom rather than removing the font dependency, and is brittle across Avalonia versions.Pathwith aStreamGeometryrectangle stroked at 1px. Equivalent in effect to theBordersketch above, but heavier syntactically. Preferred only if a non-rectangular indicator is later needed.The chosen
Border-based fix is preferred because it removes the root cause (font-fallback dependence for the indicator) rather than merely masking it, so the resting-state and post-hover appearances are guaranteed to match.Expected Tests
Existing tests for this ViewModel live in
Phantom.Workspaces.Tests/MainWindowViewModelTests.csand follow the patternMainWindow_<Scenario>_<ExpectedOutcome>(e.g.MainWindow_LeftPaneCollapser_TogglesLeftColumnToZero,MainWindow_Navigate_AutoExpandsCollapsedLeftPane). New tests should be added there and should exercise a headlessMainWindowrender of the top view strip.MainWindow_TopViewList_BoxIndicatorRendersOutlineAtInitialRenderMainWindowViewModelTestsBorderThickness >= 1, transparent/unsetBackground) — i.e. the correct outline appearance is visible before any hover.MainWindow_TopViewList_BoxIndicatorAppearanceUnchangedByFirstPointerOverMainWindowViewModelTestsMainWindow_TopViewList_BoxIndicatorIsNotATextGlyphMainWindowViewModelTestsBorderorPath) inside the item template rather than aTextBlockbound toIconGlyph, so its appearance is independent ofTheme.FontFamilyand of the text engine's fallback/shaping cache.