Context
The left navigation sidebar (AUFBAU, ETB, AUFGABEN, FUNKTIONEN, KRÄFTE, ATEMSCHUTZ, CO-MESSUNG, DATEIEN, LINKS, ABBAU) is a native Avalonia TabControl with TabStripPlacement="Left" (src/LageBuch.App.Shared/Theme/Styles.axaml:283-286). When the window is too short to fit all 11 tab items in one column (confirmed on a phone-height viewport), it currently splits into two side-by-side columns ("AUFBAU | LINKS", "ETB | ABBAU") instead of scrolling — confusing, and on a narrow window the second column can run off the right edge entirely.
Root cause, confirmed against the real upstream template: TabStripPlacement="Left" makes Avalonia's Fluent theme give the tab strip a vertical WrapPanel (its own built-in default ItemsPanel, plus a ControlTheme-level rule that flips the WrapPanel to vertical for Left/Right placement). A WrapPanel starts a new column whenever it runs out of space in the space it's given — and today that ItemsPresenter sits directly in a DockPanel with no scrollable region around it, so it's bounded by whatever height is left in the window and wraps instead of overflowing/scrolling. This app doesn't declare that WrapPanel itself (nothing to edit in IncidentWorkspaceView.axaml — TabControl/TabItem are plain native controls, confirmed via IncidentWorkspaceView.axaml.cs having no selection/panel code at all) — it comes from Avalonia's own default TabControl control template, so the fix has to override that template.
TabControl is used nowhere else in this codebase (grep -rl "<TabControl" src/LageBuch.App.Shared/Views/*.axaml → only IncidentWorkspaceView.axaml), so touching the type-wide Style Selector="TabControl" in Styles.axaml is safe — there's only one consumer to affect.
Fix
Add a Setter Property="Template" to the existing <Style Selector="TabControl"> block in src/LageBuch.App.Shared/Theme/Styles.axaml (currently lines 283-286). The replacement ControlTemplate is the real upstream Fluent ControlTheme template for TabControl (fetched from AvaloniaUI/Avalonia at tag 12.1.1, matching this repo's pinned Avalonia version — see Directory.Packages.props), with exactly one change: PART_ItemsPresenter is wrapped in a ScrollViewer that takes over the DockPanel.Dock="{TemplateBinding TabStripPlacement}" role:
<Setter Property="Template">
<ControlTemplate>
<Border BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Background="{TemplateBinding Background}"
HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
VerticalAlignment="{TemplateBinding VerticalAlignment}">
<DockPanel>
<ScrollViewer DockPanel.Dock="{TemplateBinding TabStripPlacement}"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
<Panel ClipToBounds="True">
<ContentPresenter Name="PART_SelectedContentHost2"
Margin="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
IsVisible="False" />
<ContentPresenter Name="PART_SelectedContentHost"
Margin="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Panel>
</DockPanel>
</Border>
</ControlTemplate>
</Setter>
Why this is sufficient by itself (no need to also swap the WrapPanel for a StackPanel, and no need to touch TabControl.ItemsPanel): the part name (PART_ItemsPresenter) and its generated child (the default WrapPanel, still produced by TabControl's own ItemsPanel default — this template only binds to it via {TemplateBinding ItemsPanel}, it doesn't change it) are unchanged, so the theme's own nested rule (Style Selector="^[TabStripPlacement=Left] /template/ ItemsPresenter#PART_ItemsPresenter > WrapPanel" → Orientation="Vertical") still finds and applies to it exactly as before. What changes is that the WrapPanel is now measured inside a ScrollViewer, which offers it effectively unbounded height in its wrap direction — so it never runs out of vertical space to wrap from, and instead the ScrollViewer grows a scrollbar once content exceeds the viewport. The existing app rule Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter" → Margin="0" (Styles.axaml:287-289) is untouched and keeps applying to the same named part.
Everything else in the template (Border, DockPanel, the two ContentPresenters including the pre-caching PART_SelectedContentHost2) is copied verbatim from the real upstream template, so tab-content switching, transitions and existing TabItem/hover/selected styles (Styles.axaml:291-336) are unaffected — none of them target the DockPanel/Border structure being replaced.
Files to change
src/LageBuch.App.Shared/Theme/Styles.axaml — the Template setter above, added to the existing <Style Selector="TabControl"> block (~line 283).
No changes needed to IncidentWorkspaceView.axaml, IncidentWorkspaceView.axaml.cs, or any ViewModel — this is purely a control-template fix.
Verification
dotnet build src/LageBuch.App.Shared first, to catch any XAML/template syntax error immediately (a malformed ControlTemplate fails at compile time, not silently at runtime).
- Add an acceptance test (e.g.
tests/LageBuch.Acceptance.Tests/ModuleTabsScrollingTests.cs, following the AvaloniaFact + GetVisualDescendants() conventions already used in LayoutAlignmentTests.cs/ScbaControlBarReachabilityTests.cs) that renders IncidentWorkspaceView in a deliberately short window (e.g. Height=400, short enough to force wrap regardless of which header banners happen to be visible) and asserts:
- All 11
TabItems are still present under ModuleTabs (nothing lost).
- Every
TabItem's horizontal position (Bounds.X, or TranslatePoint relative to ModuleTabs) is identical — proving a single column, not two.
- A
ScrollViewer is present in the tab-strip's ancestry with Extent.Height > Viewport.Height in this short-window state, proving the content actually overflows into a scrollable region rather than being silently clipped.
- Re-run the full existing acceptance suite (
dotnet test tests/LageBuch.Acceptance.Tests) to confirm nothing else in IncidentWorkspaceView (banners, tab content, the two checklist-dot TabItem headers) regresses.
- Capture a before/after screenshot pair at a short window height via the existing headless render harness (
Window.CaptureRenderedFrame(), RENDER_OUT convention already used by ScbaTabRenderTests/ScbaControlBarReachabilityTests) for the PR body, per this repo's UI-PR screenshot convention.
Context
The left navigation sidebar (AUFBAU, ETB, AUFGABEN, FUNKTIONEN, KRÄFTE, ATEMSCHUTZ, CO-MESSUNG, DATEIEN, LINKS, ABBAU) is a native Avalonia
TabControlwithTabStripPlacement="Left"(src/LageBuch.App.Shared/Theme/Styles.axaml:283-286). When the window is too short to fit all 11 tab items in one column (confirmed on a phone-height viewport), it currently splits into two side-by-side columns ("AUFBAU | LINKS", "ETB | ABBAU") instead of scrolling — confusing, and on a narrow window the second column can run off the right edge entirely.Root cause, confirmed against the real upstream template:
TabStripPlacement="Left"makes Avalonia's Fluent theme give the tab strip a verticalWrapPanel(its own built-in defaultItemsPanel, plus aControlTheme-level rule that flips the WrapPanel to vertical for Left/Right placement). AWrapPanelstarts a new column whenever it runs out of space in the space it's given — and today thatItemsPresentersits directly in aDockPanelwith no scrollable region around it, so it's bounded by whatever height is left in the window and wraps instead of overflowing/scrolling. This app doesn't declare thatWrapPanelitself (nothing to edit inIncidentWorkspaceView.axaml—TabControl/TabItemare plain native controls, confirmed viaIncidentWorkspaceView.axaml.cshaving no selection/panel code at all) — it comes from Avalonia's own defaultTabControlcontrol template, so the fix has to override that template.TabControlis used nowhere else in this codebase (grep -rl "<TabControl" src/LageBuch.App.Shared/Views/*.axaml→ onlyIncidentWorkspaceView.axaml), so touching the type-wideStyle Selector="TabControl"inStyles.axamlis safe — there's only one consumer to affect.Fix
Add a
Setter Property="Template"to the existing<Style Selector="TabControl">block insrc/LageBuch.App.Shared/Theme/Styles.axaml(currently lines 283-286). The replacementControlTemplateis the real upstream FluentControlThemetemplate forTabControl(fetched fromAvaloniaUI/Avaloniaat tag12.1.1, matching this repo's pinned Avalonia version — seeDirectory.Packages.props), with exactly one change:PART_ItemsPresenteris wrapped in aScrollViewerthat takes over theDockPanel.Dock="{TemplateBinding TabStripPlacement}"role:Why this is sufficient by itself (no need to also swap the
WrapPanelfor aStackPanel, and no need to touchTabControl.ItemsPanel): the part name (PART_ItemsPresenter) and its generated child (the defaultWrapPanel, still produced byTabControl's ownItemsPaneldefault — this template only binds to it via{TemplateBinding ItemsPanel}, it doesn't change it) are unchanged, so the theme's own nested rule (Style Selector="^[TabStripPlacement=Left] /template/ ItemsPresenter#PART_ItemsPresenter > WrapPanel"→Orientation="Vertical") still finds and applies to it exactly as before. What changes is that theWrapPanelis now measured inside aScrollViewer, which offers it effectively unbounded height in its wrap direction — so it never runs out of vertical space to wrap from, and instead theScrollViewergrows a scrollbar once content exceeds the viewport. The existing app ruleStyle Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter"→Margin="0"(Styles.axaml:287-289) is untouched and keeps applying to the same named part.Everything else in the template (
Border,DockPanel, the twoContentPresenters including the pre-cachingPART_SelectedContentHost2) is copied verbatim from the real upstream template, so tab-content switching, transitions and existingTabItem/hover/selected styles (Styles.axaml:291-336) are unaffected — none of them target theDockPanel/Borderstructure being replaced.Files to change
src/LageBuch.App.Shared/Theme/Styles.axaml— theTemplatesetter above, added to the existing<Style Selector="TabControl">block (~line 283).No changes needed to
IncidentWorkspaceView.axaml,IncidentWorkspaceView.axaml.cs, or any ViewModel — this is purely a control-template fix.Verification
dotnet build src/LageBuch.App.Sharedfirst, to catch any XAML/template syntax error immediately (a malformedControlTemplatefails at compile time, not silently at runtime).tests/LageBuch.Acceptance.Tests/ModuleTabsScrollingTests.cs, following theAvaloniaFact+GetVisualDescendants()conventions already used inLayoutAlignmentTests.cs/ScbaControlBarReachabilityTests.cs) that rendersIncidentWorkspaceViewin a deliberately short window (e.g.Height=400, short enough to force wrap regardless of which header banners happen to be visible) and asserts:TabItems are still present underModuleTabs(nothing lost).TabItem's horizontal position (Bounds.X, orTranslatePointrelative toModuleTabs) is identical — proving a single column, not two.ScrollVieweris present in the tab-strip's ancestry withExtent.Height > Viewport.Heightin this short-window state, proving the content actually overflows into a scrollable region rather than being silently clipped.dotnet test tests/LageBuch.Acceptance.Tests) to confirm nothing else inIncidentWorkspaceView(banners, tab content, the two checklist-dotTabItemheaders) regresses.Window.CaptureRenderedFrame(),RENDER_OUTconvention already used byScbaTabRenderTests/ScbaControlBarReachabilityTests) for the PR body, per this repo's UI-PR screenshot convention.