diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index a891e02..e39db2a 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -39,9 +39,17 @@ Avalonia-drawn content, which is the axis on which it does not behave like an or
next entry.
**A native child window cannot be covered by Avalonia content, on any platform that hosts it windowed.**
-`NativeWebView` attaches a real Win32 child HWND through `NativeControlHost`, and a child window paints
-above everything its parent draws, whatever the visual tree's z-order says. Layering a screen over the
-terminal therefore does nothing: the WebView's rectangle stays on top. In this shell that sliced the setup
+`NativeWebView` attaches a real Win32 child HWND through `NativeControlHost` — on Windows the backend
+creates a `WS_CHILD` holder window and `SetParent`s WebView2's HWND into it — and a child window paints
+above everything its parent draws, whatever the visual tree's z-order says. This is by design and
+acknowledged upstream: *"NativeControlHost places native controls over Avalonia content just like WPF one
+does. So it suffers from the same airspace problem"* (Avalonia's maintainer,
+[#6605](https://github.com/AvaloniaUI/Avalonia/issues/6605), still open). Reproduced in a 60-line standalone
+app with no DodoSSH code: a `340,*` grid, a `NativeWebView` in column 1 and an opaque `Border` as a later
+`Panel` sibling renders the overlay sliced dead on x=340.
+
+Layering a screen over the terminal therefore does nothing: the WebView's rectangle stays on top. In this
+shell that sliced the setup
and unlock cards at the terminal column's left edge, put every one of their buttons inside the WebView's
rectangle at the window's default width — so the flow could only be completed by keyboard — and handed
Win32 focus to WebView2 on any click in that region, which makes a text box stop accepting keystrokes with
@@ -57,8 +65,21 @@ for a while:
- `NativeWebView` stashes a `Source` assigned before its adapter exists and replays it once created, so
navigation is never lost to ordering. The shell already depends on that replay.
- So a collapsed WebView still starts WebView2, still loads the page and still lets the renderer attach its
- socket. Confirmed on Windows: 35 `msedgewebview2` processes with the control collapsed behind the setup
- screen.
+ socket. Measured on Windows in a harness mirroring the data plane's handshake, with `IsVisible=false` set
+ before the window was ever shown: adapter created, `GET /`, then **the WebSocket 101 sent** — the moment
+ `RendererAttached` fires — followed by frames arriving over the socket, all while hidden. A cold WebView2
+ profile behaves the same. Revealing it recomputes bounds within about 7 ms, on one `ResizeObserver`
+ callback, over the same socket.
+
+ It must be `IsVisible`, not removal from the tree. Detaching runs `DestroyNativeControl` and takes the
+ whole WebView2 process tree with it, so conditional content or a template swap would pay a cold start on
+ every unlock. Hiding merely does
+ `SetWindowPos(holder, …, SWP_HIDEWINDOW)`. Negative `Margin` also works as a runtime toggle;
+ `RenderTransform` does **not**, because `NativeControlHost` never watches it.
+
+ Note the earlier version of this bullet cited "35 `msedgewebview2` processes" as the confirmation. A
+ process count cannot show that a socket was accepted — it is the same shape of mistake as the one
+ described below, one level down.
The previous version of this entry claimed the reverse — that hiding it would mean never realising it — and
cited the `msedgewebview2` connection as verification. That observation was made while the overlay was
@@ -73,6 +94,26 @@ drops frames when no renderer is attached rather than queueing them. That await
control's visibility is not. It currently has no timeout, so a WebView2 that fails to initialise hangs
Connect with the busy flag stuck — worth fixing on its own merits.
+**Hiding the WebView does not pause it.** With the holder window hidden, the page keeps
+`visibilityState: "visible"` and `requestAnimationFrame` keeps firing at roughly 115/s — Chromium does not
+treat a hidden child HWND as a hidden page. That is *why* the handshake completes while collapsed, so it is
+load-bearing rather than merely wasteful, but it means a locked DodoSSH is still animating a full-size
+off-screen page. Worth revisiting if idle power ever matters.
+
+**A degenerate pane size reaches the remote pty.** The fit addon floors its proposal at 2 columns by 1 row
+rather than refusing, so any path that fits a terminal with almost no viewport sends `window-change` for a
+2x1 window and permanently mangles the wrapped scrollback. Reachable today by minimising, and — once splits
+land — by dragging a splitter to the edge. `terminal.js` now skips the fit below 40 px in either axis.
+Related and not yet addressed: the conflict log above the terminal is an `ItemsControl` with no
+`ScrollViewer` and no `MaxHeight` on an `Auto` row, so enough conflicts squeeze the terminal row toward
+nothing.
+
+**Nothing hands the terminal keyboard focus after connecting.** The page calls `term.focus()`, which focuses
+the textarea inside the document, but Avalonia's focus is still on the Connect button — so the first
+keystrokes after a successful connect go to the shell's UI, not to the remote shell. Click inside the
+terminal first. This is a focus-plumbing gap between Avalonia and the native child window, not a terminal
+bug.
+
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index ebb6ec0..2b67440 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -220,8 +220,20 @@
IsVisible is load-bearing rather than cosmetic — see the note on the root Panel. Without it the
native child window paints over the setup and unlock screens and swallows their input.
+
+ It must stay IsVisible on this control specifically, and two nearby alternatives are wrong.
+ Removing the control from the tree instead — conditional content, a template swap — detaches it,
+ and detaching destroys the native control and the whole WebView2 process tree, so every unlock
+ would pay a cold start. Hoisting the binding to an ancestor looks tidier and is unverified:
+ NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push fires
+ only for its own IsVisible.
+
+ FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible then
+ falls back to its default of true, and the occlusion comes back silently. Not reachable at
+ runtime — the DataContext is set before the window is shown — but it is what the previewer does.
-->
-
+
diff --git a/src/DodoSSH.Client.App/WebAssets/terminal.js b/src/DodoSSH.Client.App/WebAssets/terminal.js
index 6081058..7eed365 100644
--- a/src/DodoSSH.Client.App/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.App/WebAssets/terminal.js
@@ -135,7 +135,21 @@ function activate(sessionId) {
}
}
+// Below this, a pane is not being looked at — it is minimised, dragged to nothing, or the host has
+// hidden its window. Fitting anyway would be actively harmful rather than merely useless: the fit addon
+// floors its proposal at 2 columns by 1 row, so a degenerate viewport reflows the *remote* pty to 2x1
+// through window-change, and the wrapped scrollback that produces cannot be recovered when the pane comes
+// back. A guard rather than a fix for one caller, because several paths reach here — a minimised window, a
+// splitter dragged to the edge, and a host that hides the WebView while the vault is locked.
+const MINIMUM_FITTABLE_PIXELS = 40;
+
function resize(session, sessionId) {
+ const pane = session.pane;
+
+ if (pane.clientWidth < MINIMUM_FITTABLE_PIXELS || pane.clientHeight < MINIMUM_FITTABLE_PIXELS) {
+ return;
+ }
+
// fit() throws if the pane has no layout yet, which happens on the very first frame.
try {
session.fit.fit();