diff --git a/Docs/DesignLanguage.md b/Docs/DesignLanguage.md
index f6a699687..b9c38c7d6 100644
--- a/Docs/DesignLanguage.md
+++ b/Docs/DesignLanguage.md
@@ -300,6 +300,27 @@ presence. Rings compete with the artwork and are hard to read at small sizes.
Where an icon appears without a row, such as in a planet folder, a small dot at
the top right marks unread activity.
+## Phone and wide layouts
+
+Valour has two layouts. The phone layout shows one window at a time, with the
+sidebar sliding in from the left. The wide layout keeps the sidebar open and
+lets windows sit side by side in tabs and splits. Components read
+`DeviceInfo.IsMobile` to choose between them.
+
+Browsers, desktop apps, and the Android app choose once at startup from the
+device type. The iPhone and iPad app choose from the window size instead: the
+wide layout needs a window at least 680 points wide and 500 points tall, which
+roughly matches iOS regular width and height size classes. When the window
+crosses that line, such as when an iPhone Duo opens or closes or an iPad enters
+Split View, the page shell rebuilds itself in the new layout. The dock saves its
+windows first and restores them afterward; the phone layout keeps only the
+focused tab.
+
+On iOS, the app shell stays inside the left and right safe areas. On iPhone Duo
+the status bar becomes a rail along one edge, and on other iPhones in landscape
+the camera housing takes one side. The page background shows through those
+insets.
+
## Victor and scenes
Victor is drawn as himself, the dog from the logo, in a few poses: `astronaut`,
diff --git a/Valour/Client.Maui/wwwroot/app.css b/Valour/Client.Maui/wwwroot/app.css
index 6222b37f6..16f254eec 100644
--- a/Valour/Client.Maui/wwwroot/app.css
+++ b/Valour/Client.Maui/wwwroot/app.css
@@ -61,3 +61,14 @@ h1:focus {
background-color: var(--surface-canvas);
}
}
+
+/* Keeps the app clear of the side safe areas: the status bar rail that the
+ iPhone Duo draws along one edge, and the camera housing on iPhones in
+ landscape. The page background shows through the inset. */
+@supports (-webkit-touch-callout: none) {
+ .mobile-holder {
+ box-sizing: border-box;
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ }
+}
diff --git a/Valour/Client/Device/DeviceInfo.cs b/Valour/Client/Device/DeviceInfo.cs
index d0ce9c427..f38555ea7 100644
--- a/Valour/Client/Device/DeviceInfo.cs
+++ b/Valour/Client/Device/DeviceInfo.cs
@@ -2,7 +2,31 @@
public class DeviceInfo
{
+ ///
+ /// True when the app shows the phone layout: one window at a time and a
+ /// slide-out sidebar. The iPhone and iPad app can switch layouts while it
+ /// runs (see ); elsewhere it is set once.
+ ///
public static bool IsMobile { get; set; }
+
+ ///
+ /// Raised after changes while the app runs, with the
+ /// new value. The page shell rebuilds the layout when it fires.
+ ///
+ public static event Action LayoutModeChanged;
+
+ ///
+ /// Switches between the phone and wide layouts, such as when a foldable
+ /// iPhone opens or closes or an iPad enters Split View.
+ ///
+ public static void SetMobileLayout(bool mobile)
+ {
+ if (IsMobile == mobile)
+ return;
+
+ IsMobile = mobile;
+ LayoutModeChanged?.Invoke(mobile);
+ }
public static bool IsEmbedded { get; set; }
///
diff --git a/Valour/Client/Device/LayoutModeWatcher.cs b/Valour/Client/Device/LayoutModeWatcher.cs
new file mode 100644
index 000000000..8c5dd0ab9
--- /dev/null
+++ b/Valour/Client/Device/LayoutModeWatcher.cs
@@ -0,0 +1,33 @@
+using Microsoft.JSInterop;
+
+namespace Valour.Client.Device;
+
+///
+/// Chooses the phone or wide layout from the window size in the iPhone and iPad
+/// app, and switches it when the window changes size, such as when an iPhone Duo
+/// opens or closes. The size rule lives in valourLayout in main.js.
+///
+public sealed class LayoutModeWatcher
+{
+ private static DotNetObjectReference _reference;
+
+ ///
+ /// Returns whether the window currently fits the phone layout, and starts
+ /// reporting changes to .
+ ///
+ public static async Task StartAsync(IJSRuntime js)
+ {
+ var wide = await js.InvokeAsync("valourLayout.isWide");
+
+ if (_reference is null)
+ {
+ _reference = DotNetObjectReference.Create(new LayoutModeWatcher());
+ await js.InvokeVoidAsync("valourLayout.watch", _reference);
+ }
+
+ return !wide;
+ }
+
+ [JSInvokable]
+ public void OnWideLayoutChanged(bool wide) => DeviceInfo.SetMobileLayout(!wide);
+}
diff --git a/Valour/Client/Pages/Index.razor b/Valour/Client/Pages/Index.razor
index 6cb0c9fda..53841e1c8 100644
--- a/Valour/Client/Pages/Index.razor
+++ b/Valour/Client/Pages/Index.razor
@@ -6,6 +6,7 @@
@page "/planetwiki/{StartPlanetId:long}/{StartPageId:long}"
@page "/planetwiki/{StartPlanetId:long}"
@using Valour.Sdk.Services
+@implements IDisposable
@inject ValourClient Client
@inject AuthService AuthService
@@ -30,6 +31,9 @@
+@* Removed for one render while the layout switches; see OnLayoutModeChanged. *@
+@if (!_rebuildingShell)
+{
@@ -75,6 +79,7 @@
+}
@* Ctrl+/ or Cmd+/ swaps the app for the ValourOS terminal. *@
@@ -103,6 +108,8 @@
[Parameter]
public long? StartPageId { get; set; }
+ private bool _rebuildingShell;
+
private string MobileClass => DeviceInfo.IsMobile ? "mobile" : "desktop";
private string EmbeddedClass => DeviceInfo.IsEmbedded ? "embedded" : "";
@@ -115,6 +122,7 @@
WindowService.FocusedPlanetChanged += OnPlanetChange;
AuthService.LoggedIn += OnLoginAsync;
+ DeviceInfo.LayoutModeChanged += OnLayoutModeChanged;
}
private async Task OnLoginAsync(User user)
@@ -122,9 +130,40 @@
await InvokeAsync(StateHasChanged);
}
+ // Folding an iPhone Duo or resizing an iPad window can switch between the
+ // phone and wide layouts. Components read DeviceInfo.IsMobile when they are
+ // created, so the shell is rebuilt instead of updated in place. The dock
+ // saves its layout first and restores it on load, collapsing it to the
+ // focused tab for the phone layout. The shell is removed for one render
+ // before it returns, so the old dock is disposed before the new one
+ // registers as the main dock.
+ private void OnLayoutModeChanged(bool mobile) => _ = InvokeAsync(RebuildShellAsync);
+
+ private async Task RebuildShellAsync()
+ {
+ if (_rebuildingShell)
+ return;
+
+ var dock = WindowService.MainDock;
+ if (dock?.Layout is not null)
+ await dock.SaveLayout();
+
+ // The launch route was already opened; reopening it would undo where the user went since.
+ _startRoute = null;
+
+ _rebuildingShell = true;
+ StateHasChanged();
+ await Task.Yield();
+
+ _rebuildingShell = false;
+ StateHasChanged();
+ }
+
public void Dispose()
{
WindowService.FocusedPlanetChanged -= OnPlanetChange;
+ DeviceInfo.LayoutModeChanged -= OnLayoutModeChanged;
+ AuthService.LoggedIn -= OnLoginAsync;
}
private Task OnPlanetChange(Planet planet)
diff --git a/Valour/Client/ValourApp.razor b/Valour/Client/ValourApp.razor
index eadb9469f..ad8a13e60 100644
--- a/Valour/Client/ValourApp.razor
+++ b/Valour/Client/ValourApp.razor
@@ -1,4 +1,4 @@
-@using Microsoft.Extensions.DependencyInjection
+@using Microsoft.Extensions.DependencyInjection
@inject ValourClient Client
@inject LoggingService Logger
@inject IJSRuntime JsRuntime
@@ -217,7 +217,11 @@
{
if (firstRender)
{
- var isMobile = await JsRuntime.InvokeAsync("IsMobile");
+ // The iPhone and iPad app pick the layout from the window size and
+ // switch it live; other hosts decide once from the device type.
+ var isMobile = DeviceInfo.IsIosApp
+ ? await LayoutModeWatcher.StartAsync(JsRuntime)
+ : await JsRuntime.InvokeAsync("IsMobile");
var isEmbedded = await JsRuntime.InvokeAsync("IsEmbedded");
DeviceInfo.IsMobile = isMobile;
diff --git a/Valour/Client/wwwroot/js/main.js b/Valour/Client/wwwroot/js/main.js
index 5e2444cb9..2d4502159 100644
--- a/Valour/Client/wwwroot/js/main.js
+++ b/Valour/Client/wwwroot/js/main.js
@@ -72,6 +72,39 @@ function IsEmbedded() {
return embedded;
}
+// The iPhone and iPad app choose between the phone and wide layouts from the
+// window size instead of the device, so an unfolded iPhone Duo or a full-screen
+// iPad gets the wide layout, while a folded phone, a phone in landscape, or a
+// narrow Split View window gets the phone layout. The thresholds approximate
+// Apple's regular width and height size classes.
+window.valourLayout = (() => {
+ const wideQuery = '(min-width: 680px) and (min-height: 500px)';
+ // Folding animates the window through several sizes; report only where it settles.
+ const settleMs = 250;
+ let media = null;
+ let timer = null;
+
+ function isWide() {
+ return window.matchMedia(wideQuery).matches;
+ }
+
+ function watch(dotnetRef) {
+ if (media)
+ return;
+
+ media = window.matchMedia(wideQuery);
+ media.addEventListener('change', () => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ dotnetRef.invokeMethodAsync('OnWideLayoutChanged', media.matches)
+ .catch(() => { });
+ }, settleMs);
+ });
+ }
+
+ return { isWide, watch };
+})();
+
// A readable name for this device, shown in the account's device list when
// linking devices for end-to-end encryption.
function getValourDeviceName(isHybrid) {