diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 5ad6df6..ceefb2f 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -376,12 +376,25 @@
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
edge. See UpdateBanner.axaml.
+ ◆ THE DATA CONTEXT IS SET HERE, and the banner did nothing at all until it was.
+
+ Unlike the titlebar and the status bar, which are typed to this window's own view model and inherit its
+ context, the banner is typed to UpdateViewModel — it is one screen's control and its layout suite hosts
+ it over that view model alone. Inheriting the shell's context instead left every compiled binding
+ inside it resolving against the wrong type and failing silently: no headline, and both Commands null,
+ so the strip appeared, hovered and pressed like a real banner and neither button did anything.
+
+ IsVisible is unqualified because the context is set on this same element, which resolves it against
+ UpdateViewModel too — the rule the page area's wrappers above are wrapped for. It needs no wrapper: the
+ flag it binds is the banner's own, unlike IsHostsScreen and its siblings, which belong to the shell.
+
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
announcing an update that does not exist.
-->
+ DataContext="{Binding Updates}"
+ IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs
index 8d5be07..c051f17 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs
@@ -1,7 +1,12 @@
using Avalonia.Controls;
+using Avalonia.LogicalTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
@@ -145,4 +150,93 @@ public sealed class UpdateBannerTests
},
Token);
}
+
+ ///
+ /// The banner in the window that shows it, rather than in a host window this suite built.
+ ///
+ ///
+ ///
+ /// The two tests above hand the control an themselves — which is precisely
+ /// what the window did not do. The banner is the one control here typed to a screen's view model instead
+ /// of to the shell's, and it was dropped into MainWindow with no data context at all, so it
+ /// inherited the shell's: every compiled binding inside it then resolved against the wrong type and
+ /// failed silently. The strip appeared, with no headline and both Commands null, and hovered and
+ /// depressed like a working banner while neither button did anything.
+ ///
+ ///
+ /// Nothing else could have caught it. A layout test supplies the context it is measuring, and the shell
+ /// suite has no visual tree — its own project file says as much: it does not cover whether the XAML binds
+ /// to the right names.
+ ///
+ ///
+ /// The window is constructed and never shown, for the reason
+ /// gives, and it does not need to be: a
+ /// data context propagates and a binding resolves when the context is set, not when the tree is measured.
+ /// So this asserts the wiring and leaves every question of size to the two tests above.
+ ///
+ ///
+ [Fact]
+ public async Task TheWindowHandsTheBannerTheUpdateViewModel()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), $"dodossh-banner-shell-{Guid.CreateVersion7():N}");
+
+ using var caches = ClientCacheFactory.ForMemory($"banner-{Guid.CreateVersion7():N}");
+
+ await using var workspace = new TerminalWorkspace(
+ new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
+ Substitute.For(),
+ TimeProvider.System);
+
+ // The real shell view model, because the thing under test is what the window hands the banner. No
+ // vault and no sign-in: the banner is drawn outside the unlocked half of the window on purpose.
+ await using var shell = new MainWindowViewModel(
+ new ClientPaths(directory),
+ caches,
+ workspace,
+ new VaultKnownHostStore(),
+ Substitute.For(),
+ (_, _) => throw new NotSupportedException("nothing here signs in"),
+ TimeProvider.System,
+ Substitute.For(),
+ updates: new ReadyChannel());
+
+ try
+ {
+ await shell.Updates.CheckOnceAsync(Token);
+
+ shell.Updates.IsBannerShowing.ShouldBeTrue();
+
+ await LayoutHarness.OnTheUiThreadAsync(() => AssertTheBannerIsWiredTo(shell), Token);
+ }
+ finally
+ {
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+ }
+
+ private static void AssertTheBannerIsWiredTo(MainWindowViewModel shell)
+ {
+ var window = new MainWindow { DataContext = shell };
+
+ try
+ {
+ var banner = window.GetLogicalDescendants().OfType().ShouldHaveSingleItem();
+
+ banner.DataContext.ShouldBeSameAs(shell.Updates);
+ banner.IsVisible.ShouldBeTrue();
+
+ // The defect itself: a null command is a button that answers a click by doing nothing, and it
+ // is indistinguishable from a working one until somebody presses it.
+ var restart = banner.FindControl