The globe opens full screen; a phone held sideways keeps its feed

On a phone the globe is a banner above the feed; a tap on it now opens it full screen over everything, with a close
button (Escape closes it too), the focus moving to that button and back, and the feed underneath kept as it was but out
of reach (inert). The camera refits the new shape. Held sideways, a phone (a window at most 30rem tall, the new short:
variant) has no room for the banner: the banner and its toggle give way to a button that opens the globe full screen.
isMobileMedia, which picks the first visit's default, agrees with Tailwind's md now (it was off by one) and counts a
phone held sideways. GlobePanel tells its state (data-globe-state). Phone flows: the globe opens full screen and
closes, and a sideways phone keeps a feed at least 180px tall with no sideways scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 23:23:14 +02:00
1 parent 8f15d0d5c1
commit 931b7fcff1
9 files changed
+139 -8

No files matched your search

+8 -1
View File
@@ -30,7 +30,12 @@
<NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End"> <NerdTip Heading="@Localizer["Feed"]" Rows="Stats" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i> <i class="ion-md-stats opacity-50" aria-label="@Localizer["Feed"]"></i>
</NerdTip> </NerdTip>
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]"> <DButton Size="DSize.Sm" class="lg:hidden max-lg:short:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
<i class="ion-md-planet text-base"></i>
</DButton>
@* a phone held sideways has no room for the banner: the globe opens full screen *@
<DButton Size="DSize.Sm" class="hidden max-lg:short:inline-flex" OnClick="() => OnGlobeFull.InvokeAsync()" title="@Localizer["Open the globe full screen"]"
data-globe-full>
<i class="ion-md-planet text-base"></i> <i class="ion-md-planet text-base"></i>
</DButton> </DButton>
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]"> <DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
@@ -107,6 +112,8 @@
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = []; [Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
[Parameter] public bool IsComposing { get; set; } [Parameter] public bool IsComposing { get; set; }
[Parameter] public EventCallback OnCompose { get; set; } [Parameter] public EventCallback OnCompose { get; set; }
/// <summary>Opens the globe full screen (a phone held sideways, which has no room for its banner).</summary>
[Parameter] public EventCallback OnGlobeFull { get; set; }
/// <summary>Raised after Filter changed (tab, group or globe).</summary> /// <summary>Raised after Filter changed (tab, group or globe).</summary>
[Parameter] public EventCallback<FeedFilter> OnChanged { get; set; } [Parameter] public EventCallback<FeedFilter> OnChanged { get; set; }
+53 -2
View File
@@ -6,8 +6,10 @@
was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when was written. The posts' places go to the module in one call whenever they change; the module calls SunChanged when
the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the the Sun's moment changes. The top corner shows that moment, the phase of the day and the season (its tooltip the
Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The Sun's numbers); the bottom corner the camera and the frame rate, its tooltip the rest of the globe's numbers. The
globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. *@ globe is isolated: its labels carry z-indexes by depth, which must not reach the corners' tooltips. Below lg the
<div class="relative w-full h-full"> globe is a banner the page shows above the feed: a tap opens it full screen (Expanded, which the page lays out), and
its close button or Escape brings the feed back. *@
<div class="relative w-full h-full" tabindex="-1" @onkeydown="OnKeyDown" data-globe-state="@State">
<div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div> <div @ref="element" class="absolute inset-0 isolate" aria-label="@Localizer["Globe of the posts in view"]" role="img"></div>
@if (stats?.Sun is { } sun) @if (stats?.Sun is { } sun)
@@ -33,6 +35,21 @@
</div> </div>
} }
@if (Expanded)
{
<button @ref="closeButton" type="button" class="btn btn-circle btn-sm absolute right-3 top-[max(0.75rem,env(safe-area-inset-top))] z-20"
aria-label="@Localizer["Close"]" title="@Localizer["Close"]" @onclick="Close" data-globe-close>
<i class="ion-md-close" aria-hidden="true"></i>
</button>
}
else
{
<button @ref="expandButton" type="button" class="absolute inset-0 z-20 lg:hidden cursor-zoom-in" aria-label="@Localizer["Open the globe full screen"]"
title="@Localizer["Open the globe full screen"]" @onclick="Open" data-globe-expand>
<i class="ion-md-expand absolute right-3 bottom-3 text-lg rounded-full bg-base-100/70 p-1" aria-hidden="true"></i>
</button>
}
<div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto"> <div class="absolute left-2 bottom-2 z-10 flex items-center gap-2 text-[10px] font-mono opacity-70 pointer-events-auto">
<NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start"> <NerdTip Heading="@Localizer["Globe"]" Rows="StatsRows()" Placement="DTooltipPlacement.Top" Align="DTooltipAlign.Start">
<span class="badge badge-ghost badge-xs gap-1"> <span class="badge badge-ghost badge-xs gap-1">
@@ -61,6 +78,30 @@
/// <summary>The id of the element that scrolls the feed's cards.</summary> /// <summary>The id of the element that scrolls the feed's cards.</summary>
[Parameter, EditorRequired] public string ScrollContainerId { get; set; } [Parameter, EditorRequired] public string ScrollContainerId { get; set; }
[Parameter] public List<FeedPost> Posts { get; set; } = []; [Parameter] public List<FeedPost> Posts { get; set; } = [];
/// <summary>Open full screen (a phone's banner tapped); the page lays the globe out over everything.</summary>
[Parameter] public bool Expanded { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
ElementReference closeButton;
ElementReference expandButton;
bool focusClose;
bool focusExpand;
string State => stats is null ? "loading" : stats.Error is { Length: > 0 } ? "error" : "live";
async Task Open()
{
focusClose = true;
await ExpandedChanged.InvokeAsync(true);
}
async Task Close()
{
focusExpand = true;
await ExpandedChanged.InvokeAsync(false);
}
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && Expanded ? Close() : Task.CompletedTask;
ElementReference element; ElementReference element;
IJSObjectReference module; IJSObjectReference module;
@@ -78,6 +119,16 @@
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (focusClose && Expanded)
{
focusClose = false;
await closeButton.FocusAsync();
}
if (focusExpand && !Expanded)
{
focusExpand = false;
await expandButton.FocusAsync(preventScroll: true);
}
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
try try
{ {
+15 -4
View File
@@ -13,11 +13,13 @@
the stream tells is applied at once. *@ the stream tells is applied at once. *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full"> <div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
<aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden"> @* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" /> <aside class="@GlobeClass" role="@(globeExpanded ? "dialog" : null)" aria-modal="@(globeExpanded ? "true" : null)"
aria-label="@(globeExpanded ? Localizer["Globe"] : null)">
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" @bind-Expanded="globeExpanded" />
</aside> </aside>
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full"> <section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full" inert="@globeExpanded">
@if (!isComposing) @if (!isComposing)
{ {
@* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@ @* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@
@@ -27,7 +29,7 @@
<i class="ion-md-create text-xl" aria-hidden="true"></i> <i class="ion-md-create text-xl" aria-hidden="true"></i>
</DButton> </DButton>
} }
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" <FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
OnChanged="ChangeFeed" /> OnChanged="ChangeFeed" />
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl"> <div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
@@ -141,6 +143,15 @@
string loadError; string loadError;
bool isLoadingMore; bool isLoadingMore;
bool isComposing; bool isComposing;
bool globeExpanded;
// a phone's banner (none when the phone is held sideways: the feed would have no room), the column from lg, or
// full screen
string GlobeClass => globeExpanded
? "fixed inset-0 z-[70] block h-dvh w-full overflow-hidden bg-base-100"
: Filter.ShowGlobe
? "block max-lg:short:hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden"
: "hidden lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden";
(double Latitude, double Longitude)? position; (double Latitude, double Longitude)? position;
string positionError; string positionError;
string loadedPersonaId; string loadedPersonaId;
+3
View File
@@ -1823,4 +1823,7 @@
<data name="New post" xml:space="preserve"> <data name="New post" xml:space="preserve">
<value>Nuovo post</value> <value>Nuovo post</value>
</data> </data>
<data name="Open the globe full screen" xml:space="preserve">
<value>Apri il globo a schermo intero</value>
</data>
</root> </root>
+3
View File
@@ -1823,4 +1823,7 @@
<data name="New post" xml:space="preserve"> <data name="New post" xml:space="preserve">
<value>New post</value> <value>New post</value>
</data> </data>
<data name="Open the globe full screen" xml:space="preserve">
<value>Open the globe full screen</value>
</data>
</root> </root>
+3
View File
@@ -28,6 +28,9 @@
} }
} }
/* A short window: a phone held sideways. The globe's banner would leave the feed no room there. */
@custom-variant short (@media (max-height: 30rem));
@layer utilities { @layer utilities {
/* At every width: a checkbox's, radio's or toggle's label wraps (daisyUI's .label never does, and a long one ran past /* At every width: a checkbox's, radio's or toggle's label wraps (daisyUI's .label never does, and a long one ran past
a phone's edge), and the control keeps its size beside it. */ a phone's edge), and the control keeps its size beside it. */
+43
View File
@@ -182,6 +182,49 @@ namespace decePubClient.E2E.Mobile
}); });
} }
[Fact]
public async Task The_globe_opens_full_screen_from_its_banner_and_closes()
{
await using var session = await SignIn.As(browser, Persona(), Phone);
await Flow(session, "globe", async page =>
{
await page.Locator("button[title='Globe']").TapAsync();
var expand = page.Locator("[data-globe-expand]");
await Assertions.Expect(expand).ToBeVisibleAsync();
await expand.TapAsync();
var full = page.Locator("aside[role=dialog]");
await Assertions.Expect(full).ToBeVisibleAsync();
var box = (await full.BoundingBoxAsync())!;
var size = page.ViewportSize!;
Assert.True(Math.Abs(box.Width - size.Width) < 2 && Math.Abs(box.Height - size.Height) < 2,
$"the globe is {box.Width}x{box.Height}, not the screen's {size.Width}x{size.Height}");
await AssertWhole(page, "aside[role=dialog]");
await page.Locator("[data-globe-close]").TapAsync();
await Assertions.Expect(full).ToHaveCountAsync(0);
await Assertions.Expect(expand).ToBeVisibleAsync();
});
}
// a phone held sideways keeps its feed: no banner squeezing it, the globe full screen from the tabs instead
[Fact]
public async Task A_phone_held_sideways_keeps_its_feed()
{
await using var session = await SignIn.As(browser, Persona(), Phone.Pixel7Landscape);
var page = session.Page;
await Results.Cell("client.mobile.flow.landscape", "privapub", Phone.Pixel7Landscape.Key, async () =>
{
Assert.False(await page.Locator("[data-globe-expand]").IsVisibleAsync(), "the globe's banner squeezes the feed");
var feedHeight = await page.EvaluateAsync<double>("() => document.getElementById('feed-scroll').clientHeight");
Assert.True(feedHeight >= 180, $"the feed is {feedHeight}px tall");
Assert.True(await page.EvaluateAsync<bool>("() => document.scrollingElement.scrollWidth <= innerWidth + 1"), "the page scrolls sideways");
await page.Locator("[data-globe-full]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToBeVisibleAsync();
await page.Locator("[data-globe-close]").TapAsync();
await Assertions.Expect(page.Locator("aside[role=dialog]")).ToHaveCountAsync(0);
await session.AssertGuards();
}, page);
}
static async Task<JsonNode> Latest(Pasture.Persona persona) => static async Task<JsonNode> Latest(Pasture.Persona persona) =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
} }
+9
View File
@@ -198,6 +198,15 @@ function resize() {
state.globe.width(width).height(height) state.globe.width(width).height(height)
pause(document.visibilityState !== "visible") pause(document.visibilityState !== "visible")
wake() wake()
// a new shape (the phone's banner opened full screen, or closed again) wants the camera at another height
const shape = narrowness()
if (Math.abs(shape - (state.shape ?? shape)) > 0.05) {
if (state.view)
scheduleView()
else
state.globe.pointOfView({ ...state.globe.pointOfView(), altitude: 2.6 * shape }, reducedMotion.matches ? 0 : 600)
}
state.shape = shape
} }
async function build(width, height) { async function build(width, height) {
+2 -1
View File
@@ -123,9 +123,10 @@ window.clearAppBadge = () => {
window.logFromJs(e.message, "clearAppBadge") window.logFromJs(e.message, "clearAppBadge")
} }
} }
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
window.isMobileMedia = () => { window.isMobileMedia = () => {
try { try {
return ((window.innerWidth > 0) ? window.innerWidth : screen.width) <= 768 return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
} catch (e) { } catch (e) {
console.error(e) console.error(e)
window.logFromJs(e.message, "isMobileMedia") window.logFromJs(e.message, "isMobileMedia")