From 7f8ad26a4ca1a5a9920f6672a33d17e9557302e8 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 4 Nov 2026 21:02:21 +0000 Subject: [PATCH 01/42] Centre a readable column and pair onboarding on expanded windows On a window of at least 840pt (an iPhone Duo's inner display held sideways) the log, notifications, remotes, profile, search and commit pages draw their lists in a centred 672pt column instead of running across the whole display, and onboarding puts the lockup and the sign-in actions side by side. Narrower windows, including the closed display, keep the full-width layouts. The column takes a fixed expanded width rather than max-w: EDGE stretches a w-full child across its parent regardless of items-center, and its scroll-view lays children out leading, so the commit page wraps its column in a centring one. Co-authored-by: Connor McManus Amp-Thread-ID: https://ampcode.com/threads/T-01a1a7bf-8563-75c2-9973-a03b3476f392 --- app/Devnull/Display.php | 9 + .../views/native/commit-detail.blade.php | 190 +++++++++--------- resources/views/native/onboarding.blade.php | 11 +- .../views/native/partials/log-page.blade.php | 4 +- .../partials/notifications-page.blade.php | 4 +- .../native/partials/remotes-page.blade.php | 4 +- resources/views/native/profile.blade.php | 6 +- resources/views/native/search.blade.php | 6 +- tests/Feature/Screens/CommitDetailTest.php | 5 +- 9 files changed, 127 insertions(+), 112 deletions(-) diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 43fbd5f4..3f321043 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -41,6 +41,15 @@ final class Display */ public const int ElbowLevel = 22; + /** + * Classes for a screen's scrolling column: as wide as the screen on a phone, but a + * comfortable 672pt line once the window reaches the expanded width (840pt, the inner + * display of a foldable held sideways), centred by its parent (give the parent items-center). + * A fixed width rather than max-w: EDGE stretches a w-full child across its parent and + * ignores items-center for it, and has no mx-auto. + */ + public const string ReadableColumn = 'expanded:w-[672px]'; + public static function relativeTime(?CarbonInterface $date, ?CarbonInterface $now = null): string { if ($date === null) { diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index c633a3d0..b4263b13 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -6,110 +6,112 @@ @include('native.partials.stargazers-sheet') - - @if (! $commit && $error === null) - - - - @endif - - {{-- - The commit is the main thing, near the top: larger, in full ink, with its git date and its replies behind - a toggle in its action row. The commit it replies to shows above it, and what that replies to waits - behind "↑ N earlier commits", since EDGE can't open the page scrolled down to the commit. Opened, the - ancestors, the commit and the rest of its series share one rail, so someone replying to themselves shows - their avatar once. The replies hang under it on the thread's dashed lines, with the rail ending in a dot - above them, five at a time with "Show more" under the last. A part of a series with more parts after it - has its replies right under it, beside the rail running on, and the later parts follow. Everything - around the commit is muted, and still opens its own page. A deleted reply's own replies open in place, - one level in, five at a time too, since a deleted commit has no page. - --}} - @if ($earlierCount > 0) - @include('native.partials.earlier-commits', ['count' => $earlierCount, 'lane' => $earlierLane]) - @endif + + + @if (! $commit && $error === null) + + + + @endif - @foreach ($rows as $row) - @php($isFocused = $row->commit->id === $commit?->id) - @php($hasThread = $isFocused && $repliesShown && $replies !== []) - @include('native.partials.commit-row', [ - 'commit' => $row->commit, - 'previousLane' => $row->previousLane, - 'continuesRun' => $row->continuesRun, - 'continuesNext' => $isFocused ? ! $loop->last : null, - 'threadBelow' => $hasThread, - 'focused' => $isFocused, - 'muted' => ! $isFocused, - 'seriesExpanded' => true, - 'withTail' => ! $loop->last, - ]) - @if ($isFocused && $repliesShown && $replies === [] && ! $repliesLoaded && $error === null) - - - + {{-- + The commit is the main thing, near the top: larger, in full ink, with its git date and its replies behind + a toggle in its action row. The commit it replies to shows above it, and what that replies to waits + behind "↑ N earlier commits", since EDGE can't open the page scrolled down to the commit. Opened, the + ancestors, the commit and the rest of its series share one rail, so someone replying to themselves shows + their avatar once. The replies hang under it on the thread's dashed lines, with the rail ending in a dot + above them, five at a time with "Show more" under the last. A part of a series with more parts after it + has its replies right under it, beside the rail running on, and the later parts follow. Everything + around the commit is muted, and still opens its own page. A deleted reply's own replies open in place, + one level in, five at a time too, since a deleted commit has no page. + --}} + @if ($earlierCount > 0) + @include('native.partials.earlier-commits', ['count' => $earlierCount, 'lane' => $earlierLane]) @endif - @if ($hasThread) - @php($railLane = $loop->last ? null : $commit->lane()) - @foreach ($replies as $index => $threadReply) - @php($nested = $tombstoneReplies[$threadReply->id] ?? []) - @php($nestedMore = ($tombstoneCursors[$threadReply->id] ?? null) !== null) - @php($hasNext = ! $loop->last || $repliesCursor !== null) - @php($replyRow = [ - 'reply' => $threadReply, - 'lane' => $commit->lane(), - 'previousLane' => $index === 0 ? null : $replies[$index - 1]->lane(), - 'hasNext' => $hasNext, - 'railLane' => $railLane, - 'tombstones' => true, - 'muted' => true, - 'thread' => $commit, - 'more' => $loop->last && $nested === [] && $repliesCursor !== null ? 'loadMoreReplies' : null, - 'last' => $loop->last && $nested === [], - ]) - @php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])) - @php($replyRows->include('reply-'.$threadReply->id, \App\Devnull\RowInputs::for($threadReply, $rowScope), 'native.partials.thread-reply', $replyRow, $rowScope)) - @foreach ($nested as $nestedIndex => $nestedReply) - @php($nestedRow = [ - 'reply' => $nestedReply, - 'lane' => \App\Devnull\Lane::Rail, - 'previousLane' => $nestedIndex === 0 ? null : $nested[$nestedIndex - 1]->lane(), - 'hasNext' => ! $loop->last || $nestedMore, + + @foreach ($rows as $row) + @php($isFocused = $row->commit->id === $commit?->id) + @php($hasThread = $isFocused && $repliesShown && $replies !== []) + @include('native.partials.commit-row', [ + 'commit' => $row->commit, + 'previousLane' => $row->previousLane, + 'continuesRun' => $row->continuesRun, + 'continuesNext' => $isFocused ? ! $loop->last : null, + 'threadBelow' => $hasThread, + 'focused' => $isFocused, + 'muted' => ! $isFocused, + 'seriesExpanded' => true, + 'withTail' => ! $loop->last, + ]) + @if ($isFocused && $repliesShown && $replies === [] && ! $repliesLoaded && $error === null) + + + + @endif + @if ($hasThread) + @php($railLane = $loop->last ? null : $commit->lane()) + @foreach ($replies as $index => $threadReply) + @php($nested = $tombstoneReplies[$threadReply->id] ?? []) + @php($nestedMore = ($tombstoneCursors[$threadReply->id] ?? null) !== null) + @php($hasNext = ! $loop->last || $repliesCursor !== null) + @php($replyRow = [ + 'reply' => $threadReply, + 'lane' => $commit->lane(), + 'previousLane' => $index === 0 ? null : $replies[$index - 1]->lane(), + 'hasNext' => $hasNext, 'railLane' => $railLane, - 'indent' => 32, - 'spine' => $hasNext ? $threadReply->lane() : null, + 'tombstones' => true, 'muted' => true, - 'thread' => $nestedMore ? $threadReply : $commit, - 'more' => match (true) { - ! $loop->last => null, - $nestedMore => "loadMoreTombstoneReplies('{$threadReply->id}')", - $loop->parent->last && $repliesCursor !== null => 'loadMoreReplies', - default => null, - }, - 'last' => $loop->last && $loop->parent->last, + 'thread' => $commit, + 'more' => $loop->last && $nested === [] && $repliesCursor !== null ? 'loadMoreReplies' : null, + 'last' => $loop->last && $nested === [], ]) @php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])) - @php($replyRows->include('reply-'.$nestedReply->id, \App\Devnull\RowInputs::for($nestedReply, $rowScope), 'native.partials.thread-reply', $nestedRow, $rowScope)) + @php($replyRows->include('reply-'.$threadReply->id, \App\Devnull\RowInputs::for($threadReply, $rowScope), 'native.partials.thread-reply', $replyRow, $rowScope)) + @foreach ($nested as $nestedIndex => $nestedReply) + @php($nestedRow = [ + 'reply' => $nestedReply, + 'lane' => \App\Devnull\Lane::Rail, + 'previousLane' => $nestedIndex === 0 ? null : $nested[$nestedIndex - 1]->lane(), + 'hasNext' => ! $loop->last || $nestedMore, + 'railLane' => $railLane, + 'indent' => 32, + 'spine' => $hasNext ? $threadReply->lane() : null, + 'muted' => true, + 'thread' => $nestedMore ? $threadReply : $commit, + 'more' => match (true) { + ! $loop->last => null, + $nestedMore => "loadMoreTombstoneReplies('{$threadReply->id}')", + $loop->parent->last && $repliesCursor !== null => 'loadMoreReplies', + default => null, + }, + 'last' => $loop->last && $loop->parent->last, + ]) + @php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])) + @php($replyRows->include('reply-'.$nestedReply->id, \App\Devnull\RowInputs::for($nestedReply, $rowScope), 'native.partials.thread-reply', $nestedRow, $rowScope)) + @endforeach @endforeach - @endforeach - @endif - @endforeach + @endif + @endforeach - @if ($commit && $replyCount === 0 && $repliesLoaded && $series === []) - - No replies yet. - - @endif + @if ($commit && $replyCount === 0 && $repliesLoaded && $series === []) + + No replies yet. + + @endif - @if ($commit && $related !== []) - @include('native.partials.related-commits') - @endif + @if ($commit && $related !== []) + @include('native.partials.related-commits') + @endif - {{-- - A pushed screen runs under its bottom bar, so the page clears the reply bar itself (see - Display::replyBarClearance): the last reply and its actions end above the bar, keyboard up or down. - --}} - @if ($commit) - - @endif + {{-- + A pushed screen runs under its bottom bar, so the page clears the reply bar itself (see + Display::replyBarClearance): the last reply and its actions end above the bar, keyboard up or down. + --}} + @if ($commit) + + @endif + diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index 563a8b8f..a40cbceb 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -1,13 +1,18 @@ @use('App\Icons\Ios') @use('App\Icons\Android') - - {{-- The lockup, two thirds of the screen wide at its own 15:4, centred in the space above the buttons. --}} +{{-- + On a phone, the lockup over the buttons. Once the window is expanded (a foldable's inner display held sideways) the + lockup and the buttons sit side by side as two equal halves, with the gap between them in the middle of the + window. +--}} + + {{-- The lockup, two thirds of the screen wide at its own 15:4 (as wide as its half, once side by side), centred in its space. --}} - + @if ($error) {{ $error }} @endif diff --git a/resources/views/native/partials/log-page.blade.php b/resources/views/native/partials/log-page.blade.php index ebee83bc..e50a9218 100644 --- a/resources/views/native/partials/log-page.blade.php +++ b/resources/views/native/partials/log-page.blade.php @@ -12,11 +12,11 @@ Every page keeps pull to refresh, which can only be pulled on the log you're on anyway: iOS builds a new list when a list gains or loses it, and the new one starts at the top. --}} - + + + + @if ($profile && ! $unavailable) @php($user = $profile->user) {{-- @@ -27,7 +27,7 @@ ref="profile-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface" + class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }}" :count="count($pages)" :page="$pageIndex" on-page-change="showPage" @@ -53,7 +53,7 @@ class="w-full flex-1 bg-theme-surface" @else - + @if ($unavailable) @include('native.partials.profile-unavailable') @elseif ($error === null) diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php index 1172e448..fe094cc8 100644 --- a/resources/views/native/search.blade.php +++ b/resources/views/native/search.blade.php @@ -3,18 +3,18 @@ developers whose name or handle matches, then the commits nearest in meaning, on a rail of their own as the web's results page draws them. A term with nothing near it answers in git's voice. --}} - + @include('native.partials.error') @include('native.partials.refresh-failed') @include('native.partials.commit-sheet') @include('native.partials.live-stream-sheet') - + @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => true, 'a11yLabel' => 'Search developers or commits']) - + @if ($term === '') diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index f9ce6fc6..28fe01fe 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -768,11 +768,10 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): fakeThread(replies: [replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'The last reply'])]); $screen = Native::visit('/log/commits/c2', platform: 'ios'); - $scroller = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'scroll_view'); - $page = $scroller['children'][0]['children']; + $page = childRefs($screen, 'thread-page'); // The clearance is the last thing on the page, under the last reply: the bar's line and the home indicator. - expect(end($page)['ref'] ?? null)->toBe('reply-bar-clearance') + expect(end($page))->toBe('reply-bar-clearance') ->and(nodeByRef($screen, 'reply-bar-clearance')['layout']['height'])->toEqual(24 + 53 + 34); $screen->input('reply-input', "Three lines\nof a\ndraft"); From f3f27f24bf65455c44721e7fc431b43a272d2409 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 5 Nov 2026 01:34:11 +0000 Subject: [PATCH 02/42] Patch the mobile-ui pager to page by its own width beside a side bar On an iPhone Duo the system's rail and status column give a horizontal pager a trailing safe-area inset. NativeUIPagerRenderer sized pages by the ScrollView's width but measured progress against visibleRect, which takes in the inset, so the tab underline lagged further behind each later tab, landscape Notifications could select the wrong filter, and the next page drew under the rail. Apply a patch to nativephp/mobile-ui 0.9.0 through cweagans/composer-patches: divide by the ScrollView's bounds width and clip the pager's sides only, leaving the bottom extension under a tab bar and vertical pagers as they were. Co-authored-by: Connor McManus Amp-Thread-ID: https://ampcode.com/threads/T-01a1a7bf-8563-75c2-9973-a03b3476f392 --- composer.json | 8 +++ composer.lock | 51 +++++++++++++++- ...ativephp-mobile-ui-pager-side-insets.patch | 60 +++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 patches/nativephp-mobile-ui-pager-side-insets.patch diff --git a/composer.json b/composer.json index 3c5c0cb7..583b35b8 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "require": { "php": "^8.3", "codingwithrk/nativephp-social-auth": "^1.0", + "cweagans/composer-patches": "^1.7", "devnull/nativephp-calls": "^1.0", "laravel/boost": "^2.0", "laravel/framework": "^13.0", @@ -111,6 +112,12 @@ "extra": { "laravel": { "dont-discover": [] + }, + "composer-exit-on-patch-failure": true, + "patches": { + "nativephp/mobile-ui": { + "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch" + } } }, "config": { @@ -118,6 +125,7 @@ "preferred-install": "dist", "sort-packages": true, "allow-plugins": { + "cweagans/composer-patches": true, "pestphp/pest-plugin": true, "php-http/discovery": true } diff --git a/composer.lock b/composer.lock index e82156e6..358b1cbc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "847b76889db0a6fb94632b18587ff996", + "content-hash": "c03857c5fc1f57902e875041719b3ec3", "packages": [ { "name": "bacon/bacon-qr-code", @@ -341,6 +341,55 @@ ], "time": "2026-10-29T09:13:34+00:00" }, + { + "name": "cweagans/composer-patches", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/cweagans/composer-patches.git", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3.0" + }, + "require-dev": { + "composer/composer": "~1.0 || ~2.0", + "phpunit/phpunit": "~4.6" + }, + "type": "composer-plugin", + "extra": { + "class": "cweagans\\Composer\\Patches" + }, + "autoload": { + "psr-4": { + "cweagans\\Composer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Cameron Eagans", + "email": "me@cweagans.net" + } + ], + "description": "Provides a way to patch Composer packages.", + "support": { + "issues": "https://github.com/cweagans/composer-patches/issues", + "source": "https://github.com/cweagans/composer-patches/tree/1.7.3" + }, + "time": "2022-12-20T22:53:13+00:00", + "published-time": "2022-12-21T19:08:26+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", diff --git a/patches/nativephp-mobile-ui-pager-side-insets.patch b/patches/nativephp-mobile-ui-pager-side-insets.patch new file mode 100644 index 00000000..fb7720fe --- /dev/null +++ b/patches/nativephp-mobile-ui-pager-side-insets.patch @@ -0,0 +1,60 @@ +--- a/resources/ios/NativeUIPagerRenderer.swift ++++ b/resources/ios/NativeUIPagerRenderer.swift +@@ -197,6 +197,11 @@ + .scrollTargetLayout() + } + .ignoresSafeArea(.container, edges: horizontal ? .bottom : []) ++ // Beside a sideways bar (an iPhone Duo's rail) the ScrollView ++ // also draws into the bar's inset, so the next page showed ++ // under it. Clip the sides only: the pages still run under a ++ // bar at the bottom. ++ .clipShape(PagerSideClip(horizontal: horizontal)) + .scrollTargetBehavior(.paging) + .scrollPosition(id: $position) + // On a non-lazy stack SwiftUI ignores the initial value of +@@ -207,14 +212,16 @@ + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + .contentMargins(0) +- // `visibleRect` is inset-adjusted: its origin is 0 on page 0 +- // and its length is the viewport, whatever the bar insets are. ++ // `visibleRect` is inset-adjusted: its origin is 0 on page 0. ++ // Its width is not a page, though: beside a sideways bar it ++ // takes in the bar's inset too, so horizontally a page is the ++ // ScrollView's own width, which `.paging` steps by. + // Before the first layout the pager has no size. That is no + // page yet, not page 0: reported as page 0, PHP would think the + // user had left the start page. + .onScrollGeometryChange(for: Int?.self) { geometry in + let visible = geometry.visibleRect +- let length = horizontal ? visible.width : visible.height ++ let length = horizontal ? geometry.bounds.width : visible.height + guard length > 0, count > 0 else { return nil } + let offset = horizontal ? visible.minX : visible.minY + return min(count - 1, max(0, Int((offset / length).rounded()))) +@@ -236,7 +243,7 @@ + // pager has a size, so the row doesn't flash the first tab. + .onScrollGeometryChange(for: CGFloat?.self) { geometry in + let visible = geometry.visibleRect +- let length = horizontal ? visible.width : visible.height ++ let length = horizontal ? geometry.bounds.width : visible.height + guard length > 0 else { return nil } + return (horizontal ? visible.minX : visible.minY) / length + } action: { _, value in +@@ -941,3 +948,16 @@ + } + } + } ++ ++/// Clips a horizontal pager at its sides only, leaving a page's height of ++/// room above and below, so its pages can still draw under a bar at the ++/// bottom. A vertical pager gets that much room on every side as well. ++private struct PagerSideClip: Shape { ++ let horizontal: Bool ++ ++ func path(in rect: CGRect) -> Path { ++ Path(horizontal ++ ? rect.insetBy(dx: 0, dy: -rect.height) ++ : rect.insetBy(dx: -rect.width, dy: -rect.height)) ++ } ++} From 73248941c267e472b129695f95eaffb0c41326c6 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 5 Nov 2026 17:38:00 +0000 Subject: [PATCH 03/42] Restore horizontal pager selection after warm resume Preserve the side-inset width and clipping fixes while reconciling horizontal pager geometry after background resizing. Retain recovery through tracking, accept genuine inactive swipe completion, and reconcile late native acknowledgements without overriding newer selection intent. Validated exact cand12 renderer SHA1: 5659b5b4395cdb01f710483cf2f7a2b79683d654. Independent Astra source and native-evidence review found no additional concrete blocker. Controlled iOS 27.2 lifecycle races, clean Log/Profile warm resume, 30 extracted-handler checks, and the PHP suite were verified. Physical iOS 27.3 and active fold/rotation retention remain unverified. Amp-Thread-ID: https://ampcode.com/threads/T-01a1ac67-a129-7066-90e3-bf362ac34436 Co-authored-by: Connor McManus --- ...ativephp-mobile-ui-pager-side-insets.patch | 403 +++++++++++++++++- 1 file changed, 397 insertions(+), 6 deletions(-) diff --git a/patches/nativephp-mobile-ui-pager-side-insets.patch b/patches/nativephp-mobile-ui-pager-side-insets.patch index fb7720fe..2533dfeb 100644 --- a/patches/nativephp-mobile-ui-pager-side-insets.patch +++ b/patches/nativephp-mobile-ui-pager-side-insets.patch @@ -1,6 +1,42 @@ --- a/resources/ios/NativeUIPagerRenderer.swift +++ b/resources/ios/NativeUIPagerRenderer.swift -@@ -197,6 +197,11 @@ +@@ -98,6 +98,8 @@ + /// Programmatic-jump binding only. Reading it back is useless here: + /// under `.paging` it goes nil the moment the pager moves. + @State private var position: Int? ++ /// A same-ID tap still needs an explicit scroll when geometry is off-grid. ++ @State private var tabRequest = 0 + /// Page nearest the leading edge, derived from the scroll geometry. + @State private var leading: Int + /// Pages reported to PHP that it has not echoed back yet, oldest first. +@@ -114,6 +116,10 @@ + @State private var progress: PagerProgress + /// Where the header is, and each page's list, when there's a header. + @State private var headerScroll = PagerHeaderScroll() ++ /// How a horizontal pager gets back on its page after iOS resizes it ++ /// while the app is in the background (see PagerResume). ++ @State private var resume: PagerResume ++ @Environment(\.scenePhase) private var scenePhase + + /// Loaded-but-unshipped pages this far from the current one get their + /// placeholder image; the rest stay empty. Wide enough that a fast +@@ -141,6 +147,7 @@ + _reported = State(initialValue: [requestedPage]) + _lastReported = State(initialValue: requestedPage) + _progress = State(initialValue: PagerProgress(CGFloat(requestedPage))) ++ _resume = State(initialValue: PagerResume(page: requestedPage, count: count)) + } + + var body: some View { +@@ -185,6 +192,7 @@ + // both size pages against the extended container and drift by + // the inset on every swipe.) + GeometryReader { geo in ++ ScrollViewReader { proxy in + // A horizontal pager runs under a bar at the bottom of the + // screen (a tab bar), as a screen's own list does. Its pages + // take the full height and hand the bar's inset back to their +@@ -197,6 +205,11 @@ .scrollTargetLayout() } .ignoresSafeArea(.container, edges: horizontal ? .bottom : []) @@ -12,7 +48,7 @@ .scrollTargetBehavior(.paging) .scrollPosition(id: $position) // On a non-lazy stack SwiftUI ignores the initial value of -@@ -207,14 +212,16 @@ +@@ -207,26 +220,28 @@ .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize) .contentMargins(0) @@ -32,16 +68,293 @@ guard length > 0, count > 0 else { return nil } let offset = horizontal ? visible.minX : visible.minY return min(count - 1, max(0, Int((offset / length).rounded()))) -@@ -236,7 +243,7 @@ + } action: { _, page in +- guard let page else { return } ++ guard let page, !resume.holds(horizontal: horizontal) else { return } + leading = page + // Report the moment a page becomes the nearest one, mid-swipe, + // so PHP's round-trip overlaps the animation instead of + // starting after it. Idle re-reports the same page (no-op). + // With `report_on_settle` only idle reports, so the frame + // the report causes never lands during the animation. +- if !reportOnSettle { ++ if !reportOnSettle, !(horizontal && resume.target != nil) { + report(page) + } + // A header follows the page nearest the leading edge. +@@ -234,19 +249,74 @@ + } + // The same position, unrounded, for the tab row. Nil until the // pager has a size, so the row doesn't flash the first tab. - .onScrollGeometryChange(for: CGFloat?.self) { geometry in +- .onScrollGeometryChange(for: CGFloat?.self) { geometry in ++ .onScrollGeometryChange(for: PagerPosition?.self) { geometry in let visible = geometry.visibleRect - let length = horizontal ? visible.width : visible.height + let length = horizontal ? geometry.bounds.width : visible.height guard length > 0 else { return nil } - return (horizontal ? visible.minX : visible.minY) / length +- return (horizontal ? visible.minX : visible.minY) / length ++ return PagerPosition(pages: (horizontal ? visible.minX : visible.minY) / length, length: length) } action: { _, value in -@@ -941,3 +948,16 @@ +- if tabs != nil, let value { +- progress.value = value ++ resume.position = value ++ resume.geometryVersion += 1 ++ guard let value else { return } ++ if resume.phase == .idle, resume.userScrolling { ++ // Idle had no size; this is its first measurable finish. ++ resume.settled() ++ reconcile(value) ++ resume.armed = horizontal && !resume.active ++ return ++ } ++ if horizontal, resume.active, !resume.armed, let target = resume.target { ++ // A timeout cannot cancel a scroll already sent to UIKit. ++ // Keep following its fresh geometry, including a late ack. ++ if value.isOn(target) { ++ resume.settled() ++ } ++ reconcile(value) ++ return ++ } ++ if resume.holds(horizontal: horizontal) { ++ // Not the user's doing: iOS sizing the app while it's in ++ // the background, or the pager being put back. Nothing ++ // follows it until the pager is back on its page. ++ resume.armed = resume.armed || !resume.active ++ if resume.active, resume.armed { ++ restore(proxy) ++ } ++ return ++ } ++ if tabs != nil { ++ progress.value = value.pages ++ } ++ } ++ .onScrollPhaseChange { _, phase, context in ++ scrollPhaseChanged(phase, geometry: context.geometry, proxy: proxy) ++ } ++ .onChange(of: scenePhase, initial: true) { _, phase in ++ resume.active = phase == .active ++ // Work queued before the app left the foreground is stale. ++ resume.suspend() ++ if resume.active, resume.armed, horizontal { ++ // Hold the pager until it's checked: the geometry it ++ // came back with is not a page the user chose. ++ resume.restoring = true ++ restore(proxy) + } + } +- .onScrollPhaseChange { _, phase in +- guard phase == .idle else { return } +- report(leading) ++ .onChange(of: count) { _, count in ++ resume.count = count ++ resume.accepted = min(resume.accepted, max(count - 1, 0)) ++ if count == 0 { ++ resume.settled() ++ resume.phase = .idle ++ return ++ } ++ if let target = resume.target { ++ resume.target = min(target, count - 1) ++ } ++ resume.suspend() ++ if resume.active, resume.armed, horizontal { ++ restore(proxy) ++ } + } + .onChange(of: requestedPage) { _, page in + // An echo of something we reported: drop it and everything +@@ -261,8 +331,163 @@ + } + reported = [page] + lastReported = page ++ resume.chosen(page) + position = page ++ if horizontal { ++ verifySelection(proxy) ++ } + } ++ .onChange(of: tabRequest) { _, _ in ++ verifySelection(proxy) ++ } ++ } ++ } ++ } ++ ++ /// Tracking is only a finger down. Keep the recovery until that finger ++ /// actually moves, or check it again when the touch ends without a drag. ++ private func scrollPhaseChanged(_ phase: ScrollPhase, geometry: ScrollGeometry, proxy: ScrollViewProxy) { ++ resume.phase = phase ++ if phase == .tracking { ++ resume.suspend() ++ return ++ } ++ if phase == .interacting { ++ resume.settled() ++ resume.userScrolling = true ++ return ++ } ++ guard phase == .idle else { return } ++ let finishedSwipe = resume.userScrolling ++ let visible = geometry.visibleRect ++ let length = horizontal ? geometry.bounds.width : visible.height ++ guard length > 0, resume.count > 0 else { ++ if horizontal, resume.armed, resume.target != nil { ++ resume.restoring = true ++ } ++ return ++ } ++ resume.userScrolling = false ++ let final = PagerPosition(pages: (horizontal ? visible.minX : visible.minY) / length, length: length) ++ resume.position = final ++ resume.geometryVersion += 1 ++ if finishedSwipe { ++ resume.settled() ++ reconcile(final) ++ // A real swipe can finish after the scene became inactive. ++ // Accept it now; subsequent system resizes must not replace it. ++ resume.armed = horizontal && !resume.active ++ } else if horizontal, resume.armed, let target = resume.target { ++ if resume.active, final.isOn(target) { ++ resume.settled() ++ reconcile(final) ++ } else if resume.active { ++ restore(proxy) ++ } ++ } else if resume.holds(horizontal: horizontal) { ++ if resume.active, resume.armed { ++ restore(proxy) ++ } ++ } else { ++ if horizontal, let target = resume.target, final.isOn(target) { ++ resume.settled() ++ } ++ reconcile(final) ++ } ++ } ++ ++ /// Reconcile from one geometry sample, not separately delivered rounded ++ /// and fractional callbacks. `report` de-duplicates a page PHP knows. ++ private func reconcile(_ position: PagerPosition) { ++ guard resume.count > 0 else { return } ++ let page = min(max(Int(position.pages.rounded()), 0), resume.count - 1) ++ resume.accepted = page ++ leading = page ++ headerScroll.lead(page) ++ if tabs != nil { ++ progress.value = position.pages ++ } ++ report(page) ++ } ++ ++ /// Every explicit horizontal selection gets a check, including a binding ++ /// assignment that does not move anything. Give an ordinary animation time ++ /// to start; its idle callback owns completion if it is still running. ++ private func verifySelection(_ proxy: ScrollViewProxy) { ++ guard resume.active, resume.armed else { return } ++ if resume.phase == .idle, let position = resume.position, position.isOn(resume.accepted) { ++ resume.settled() ++ reconcile(position) ++ return ++ } ++ let generation = resume.generation ++ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { ++ guard generation == resume.generation, resume.active, resume.armed, resume.phase == .idle else { return } ++ restore(proxy) ++ } ++ } ++ ++ /// Puts the pager back on the page it was meant to be on, once the app ++ /// is in front again: one check at a time, on the next turn of the run ++ /// loop, dropped if anything newer happened first. Each of two attempts ++ /// waits for new geometry, with a bounded timeout if layout never replies. ++ private func restore(_ proxy: ScrollViewProxy) { ++ let generation = resume.generation ++ guard resume.pending != generation else { return } ++ resume.pending = generation ++ DispatchQueue.main.async { ++ if resume.pending == generation { ++ resume.pending = nil ++ } ++ guard generation == resume.generation, resume.active, resume.armed, resume.phase == .idle else { return } ++ guard resume.count > 0 else { ++ resume.settled() ++ return ++ } ++ guard let position = resume.position else { ++ // The next valid geometry sample continues this idle check. ++ resume.restoring = true ++ return ++ } ++ let page = min(max(resume.accepted, 0), resume.count - 1) ++ if position.isOn(page) { ++ resume.settled() ++ reconcile(position) ++ return ++ } ++ if let waiting = resume.waitingForGeometry, resume.geometryVersion <= waiting { ++ return ++ } ++ if resume.attempts >= 2 { ++ failResume() ++ return ++ } ++ resume.restoring = true ++ resume.target = page ++ resume.attempts += 1 ++ resume.waitingForGeometry = resume.geometryVersion ++ let attempt = resume.attempts ++ var transaction = Transaction() ++ transaction.disablesAnimations = true ++ withTransaction(transaction) { proxy.scrollTo(page, anchor: .leading) } ++ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { ++ guard generation == resume.generation, resume.armed, resume.attempts == attempt else { return } ++ failResume() ++ } ++ } ++ } ++ ++ /// A failed correction is not a successful restore. Release the hold, ++ /// reconcile PHP and native selection with the actual nearest page. Keep ++ /// ownership of the issued scroll until it acknowledges or newer intent ++ /// supersedes it: timing out cannot cancel a native scroll already issued. ++ private func failResume() { ++ NSLog("NativeUI pager resume correction failed after \(resume.attempts) attempt(s)") ++ let target = resume.target ++ resume.settled() ++ resume.target = target ++ if let position = resume.position { ++ reconcile(position) + } + } + +@@ -330,13 +555,23 @@ + private func selectTab(_ index: Int) { + guard index >= 0, index < count else { return } + +- if index == leading, abs(progress.value - CGFloat(index)) < 0.01 { ++ resume.chosen(index) ++ if index == leading, abs(progress.value - CGFloat(index)) < 0.01, resume.position?.isOn(index) == true { ++ resume.settled() + if let cb = tabs?.reselectCbId, cb != 0 { + NativeElementBridge.sendTabChangeEvent(cb, nodeId: nodeId, index: index) + } + return + } + ++ if horizontal, resume.active, resume.phase == .idle, let actual = resume.position, actual.isOn(index) { ++ resume.settled() ++ reconcile(actual) ++ return ++ } ++ if horizontal { ++ tabRequest += 1 ++ } + withAnimation { position = index } + } + +@@ -941,3 +1176,94 @@ } } } @@ -58,3 +371,81 @@ + : rect.insetBy(dx: -rect.width, dy: -rect.height)) + } +} ++ ++/// Where the pager is, in pages, and how long a page is. ++private struct PagerPosition: Equatable { ++ let pages: CGFloat ++ let length: CGFloat ++ ++ /// Within half a point of `page`. ++ func isOn(_ page: Int) -> Bool { ++ abs(pages - CGFloat(page)) * length <= 0.5 ++ } ++} ++ ++/// A horizontal pager's way back to its page after iOS resizes it in the ++/// background (on an iPhone Duo beside its side rail, the pager came back ++/// between two pages). A class, so none of this redraws the pages. ++private final class PagerResume { ++ /// The page the pager is meant to be on: where it last came to rest on ++ /// a page, a tapped tab, or a page PHP jumped to. ++ var accepted: Int ++ var count: Int ++ /// The latest scroll geometry; nil while the pager has no size. ++ var position: PagerPosition? ++ var active = true ++ var phase: ScrollPhase = .idle ++ var userScrolling = false ++ var geometryVersion = 0 ++ var waitingForGeometry: Int? ++ /// An explicit selection or issued correction still owns its completion, ++ /// even after its retry timeout. New user/PHP intent replaces this target. ++ var target: Int? ++ /// The pager moved while the app wasn't in front, so it may be off its page. ++ var armed = false ++ /// The pager is being put back; its moves are not the user's. ++ var restoring = false ++ var attempts = 0 ++ /// Bumped by anything that makes queued work stale. ++ var generation = 0 ++ /// The generation a check is queued for. ++ var pending: Int? ++ ++ init(page: Int, count: Int) { ++ accepted = page ++ self.count = count ++ } ++ ++ /// Geometry changes aren't the user's: the app isn't in front, or the ++ /// pager is being put back, and nobody is scrolling. ++ func holds(horizontal: Bool) -> Bool { ++ horizontal && (!active || restoring) && !userScrolling ++ } ++ ++ /// A tab tap or a PHP jump: the pager goes there instead. In the ++ /// background its move is only seen once the app is in front again. ++ func chosen(_ page: Int) { ++ let holding = restoring || !active ++ settled() ++ accepted = page ++ target = page ++ armed = true ++ restoring = holding ++ } ++ ++ /// Cancel queued checks and timeouts without losing the recovery intent. ++ func suspend() { ++ generation += 1 ++ waitingForGeometry = nil ++ attempts = 0 ++ } ++ ++ /// The user scrolled: whatever was pending is theirs to decide. ++ func settled() { ++ suspend() ++ restoring = false ++ armed = false ++ userScrolling = false ++ target = nil ++ } ++} From c2163374c2bb777a1db07fb30ed4ef699d36f2e7 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 5 Nov 2026 19:41:36 +0000 Subject: [PATCH 04/42] Retain horizontal pager selection across active fold resizing Classify geometry before selection reporting and retain resize intent across late clamps with bounded, cancellable corrections. Preserve warm-resume recovery and side-inset paging fixes. Amp-Thread-ID: https://ampcode.com/threads/T-01a1ac67-a129-7066-90e3-bf362ac34436 Co-authored-by: Connor McManus --- ...ativephp-mobile-ui-pager-side-insets.patch | 202 +++++++++++++----- 1 file changed, 154 insertions(+), 48 deletions(-) diff --git a/patches/nativephp-mobile-ui-pager-side-insets.patch b/patches/nativephp-mobile-ui-pager-side-insets.patch index 2533dfeb..78334f17 100644 --- a/patches/nativephp-mobile-ui-pager-side-insets.patch +++ b/patches/nativephp-mobile-ui-pager-side-insets.patch @@ -48,7 +48,7 @@ .scrollTargetBehavior(.paging) .scrollPosition(id: $position) // On a non-lazy stack SwiftUI ignores the initial value of -@@ -207,26 +220,28 @@ +@@ -207,46 +220,98 @@ .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize) .contentMargins(0) @@ -61,44 +61,22 @@ // Before the first layout the pager has no size. That is no // page yet, not page 0: reported as page 0, PHP would think the // user had left the start page. - .onScrollGeometryChange(for: Int?.self) { geometry in - let visible = geometry.visibleRect -- let length = horizontal ? visible.width : visible.height -+ let length = horizontal ? geometry.bounds.width : visible.height - guard length > 0, count > 0 else { return nil } - let offset = horizontal ? visible.minX : visible.minY - return min(count - 1, max(0, Int((offset / length).rounded()))) - } action: { _, page in -- guard let page else { return } -+ guard let page, !resume.holds(horizontal: horizontal) else { return } - leading = page - // Report the moment a page becomes the nearest one, mid-swipe, - // so PHP's round-trip overlaps the animation instead of - // starting after it. Idle re-reports the same page (no-op). - // With `report_on_settle` only idle reports, so the frame - // the report causes never lands during the animation. -- if !reportOnSettle { -+ if !reportOnSettle, !(horizontal && resume.target != nil) { - report(page) - } - // A header follows the page nearest the leading edge. -@@ -234,19 +249,74 @@ - } - // The same position, unrounded, for the tab row. Nil until the - // pager has a size, so the row doesn't flash the first tab. -- .onScrollGeometryChange(for: CGFloat?.self) { geometry in +- .onScrollGeometryChange(for: Int?.self) { geometry in ++ // Rounded selection and fractional progress share one sample, ++ // so recovery ownership is checked before either can escape. + .onScrollGeometryChange(for: PagerPosition?.self) { geometry in let visible = geometry.visibleRect - let length = horizontal ? visible.width : visible.height +- guard length > 0, count > 0 else { return nil } +- let offset = horizontal ? visible.minX : visible.minY +- return min(count - 1, max(0, Int((offset / length).rounded()))) +- } action: { _, page in +- guard let page else { return } + let length = horizontal ? geometry.bounds.width : visible.height - guard length > 0 else { return nil } -- return (horizontal ? visible.minX : visible.minY) / length ++ guard length > 0 else { return nil } + return PagerPosition(pages: (horizontal ? visible.minX : visible.minY) / length, length: length) - } action: { _, value in -- if tabs != nil, let value { -- progress.value = value -+ resume.position = value -+ resume.geometryVersion += 1 ++ } action: { _, value in ++ if observe(value, proxy: proxy) { return } + guard let value else { return } + if resume.phase == .idle, resume.userScrolling { + // Idle had no size; this is its first measurable finish. @@ -126,10 +104,35 @@ + } + return + } ++ guard resume.count > 0 else { return } ++ let page = min(resume.count - 1, max(0, Int(value.pages.rounded()))) + leading = page +- // Report the moment a page becomes the nearest one, mid-swipe, +- // so PHP's round-trip overlaps the animation instead of +- // starting after it. Idle re-reports the same page (no-op). +- // With `report_on_settle` only idle reports, so the frame +- // the report causes never lands during the animation. +- if !reportOnSettle { ++ // Report mid-swipe unless PHP requested settle-only reports. ++ if !reportOnSettle, !(horizontal && resume.target != nil) { + report(page) + } +- // A header follows the page nearest the leading edge. + headerScroll.lead(page) + if tabs != nil { + progress.value = value.pages + } -+ } + } +- // The same position, unrounded, for the tab row. Nil until the +- // pager has a size, so the row doesn't flash the first tab. +- .onScrollGeometryChange(for: CGFloat?.self) { geometry in +- let visible = geometry.visibleRect +- let length = horizontal ? visible.width : visible.height +- guard length > 0 else { return nil } +- return (horizontal ? visible.minX : visible.minY) / length +- } action: { _, value in +- if tabs != nil, let value { +- progress.value = value + .onScrollPhaseChange { _, phase, context in + scrollPhaseChanged(phase, geometry: context.geometry, proxy: proxy) + } @@ -137,6 +140,11 @@ + resume.active = phase == .active + // Work queued before the app left the foreground is stale. + resume.suspend() ++ if !resume.active { ++ // Background sizing belongs to warm-resume, not an active ++ // resize's deadline. Keep its accepted selection and hold. ++ resume.resize = nil ++ } + if resume.active, resume.armed, horizontal { + // Hold the pager until it's checked: the geometry it + // came back with is not a page the user chose. @@ -158,6 +166,9 @@ + if let target = resume.target { + resume.target = min(target, count - 1) + } ++ if let resize = resume.resize { ++ resize.page = min(resize.page, count - 1) ++ } + resume.suspend() + if resume.active, resume.armed, horizontal { + restore(proxy) @@ -165,7 +176,7 @@ } .onChange(of: requestedPage) { _, page in // An echo of something we reported: drop it and everything -@@ -261,8 +331,163 @@ +@@ -261,9 +326,230 @@ } reported = [page] lastReported = page @@ -174,7 +185,7 @@ + if horizontal { + verifySelection(proxy) + } - } ++ } + .onChange(of: tabRequest) { _, _ in + verifySelection(proxy) + } @@ -205,10 +216,9 @@ + } + return + } -+ resume.userScrolling = false + let final = PagerPosition(pages: (horizontal ? visible.minX : visible.minY) / length, length: length) -+ resume.position = final -+ resume.geometryVersion += 1 ++ if observe(final, proxy: proxy) { return } ++ resume.userScrolling = false + if finishedSwipe { + resume.settled() + reconcile(final) @@ -234,6 +244,62 @@ + } + } + ++ /// Classify a sample before it can change selection, header or PHP. Width ++ /// changes belong to layout, unless a genuine swipe already owns the move. ++ /// A quiet width groups the burst; it does not forget its selected page. ++ private func observe(_ value: PagerPosition?, proxy: ScrollViewProxy) -> Bool { ++ resume.position = value ++ resume.geometryVersion += 1 ++ guard horizontal, let value else { return false } ++ let changed = resume.width.map { abs($0 - value.length) > 0.5 } ?? false ++ resume.width = value.length ++ guard resume.active, !resume.userScrolling, resume.count > 0 else { return false } ++ if changed { ++ let page = resume.resize?.page ?? (resume.armed ? resume.target : nil) ?? resume.accepted ++ // Quiet is not an acknowledgement. Churn cannot refill a live ++ // or exhausted repair; only a completed selection can start anew. ++ if resume.resize == nil || (resume.resize!.stable && !resume.resize!.checking && !resume.resize!.exhausted) { ++ let resize = PagerResize(page: page) ++ resume.resize = resize ++ } ++ let resize = resume.resize! ++ resize.version += 1 ++ resize.stable = false ++ let version = resize.version ++ resume.suspend() ++ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { ++ guard resume.resize === resize, resize.version == version else { return } ++ resize.stable = true ++ if resume.active, resume.armed, !resize.exhausted { ++ restore(proxy) ++ } ++ } ++ } ++ guard let resize = resume.resize, !resize.exhausted else { return false } ++ if !resize.checking { ++ resize.checking = true ++ resize.timerVersion += 1 ++ let timer = resize.timerVersion ++ // Bound each outstanding repair burst, not passive selection ++ // ownership. Acknowledgement cancels this timer, not its budget. ++ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { ++ guard resume.resize === resize, resize.timerVersion == timer, !resize.exhausted else { return } ++ resize.overdue = true ++ if resume.active, resume.phase == .idle, resume.armed { ++ failResume() ++ } + } + } ++ resume.accepted = min(resize.page, resume.count - 1) ++ resume.target = resume.accepted ++ resume.armed = true ++ resume.restoring = true ++ if resize.stable || resize.overdue, resume.phase == .idle { ++ restore(proxy) ++ } ++ return true ++ } ++ + /// Reconcile from one geometry sample, not separately delivered rounded + /// and fractional callbacks. `report` de-duplicates a page PHP knows. + private func reconcile(_ position: PagerPosition) { @@ -254,7 +320,7 @@ + private func verifySelection(_ proxy: ScrollViewProxy) { + guard resume.active, resume.armed else { return } + if resume.phase == .idle, let position = resume.position, position.isOn(resume.accepted) { -+ resume.settled() ++ resume.settled(preservingResize: true) + reconcile(position) + return + } @@ -278,6 +344,14 @@ + resume.pending = nil + } + guard generation == resume.generation, resume.active, resume.armed, resume.phase == .idle else { return } ++ if let resize = resume.resize { ++ if resize.exhausted { return } ++ if resize.overdue { ++ failResume() ++ return ++ } ++ if !resize.stable { return } ++ } + guard resume.count > 0 else { + resume.settled() + return @@ -289,20 +363,21 @@ + } + let page = min(max(resume.accepted, 0), resume.count - 1) + if position.isOn(page) { -+ resume.settled() ++ resume.settled(preservingResize: true) + reconcile(position) + return + } + if let waiting = resume.waitingForGeometry, resume.geometryVersion <= waiting { + return + } -+ if resume.attempts >= 2 { ++ if (resume.resize?.attempts ?? resume.attempts) >= 2 { + failResume() + return + } + resume.restoring = true + resume.target = page + resume.attempts += 1 ++ resume.resize?.attempts += 1 + resume.waitingForGeometry = resume.geometryVersion + let attempt = resume.attempts + var transaction = Transaction() @@ -322,14 +397,17 @@ + private func failResume() { + NSLog("NativeUI pager resume correction failed after \(resume.attempts) attempt(s)") + let target = resume.target -+ resume.settled() ++ resume.resize?.exhausted = true ++ resume.settled(preservingResize: true) + resume.target = target + if let position = resume.position { + reconcile(position) - } ++ resume.resize?.page = resume.accepted ++ } } -@@ -330,13 +555,23 @@ + /// The start page as a point of the scroll range. Every page is one +@@ -330,13 +616,23 @@ private func selectTab(_ index: Int) { guard index >= 0, index < count else { return } @@ -354,7 +432,7 @@ withAnimation { position = index } } -@@ -941,3 +1176,94 @@ +@@ -941,3 +1237,122 @@ } } } @@ -393,6 +471,9 @@ + var count: Int + /// The latest scroll geometry; nil while the pager has no size. + var position: PagerPosition? ++ /// Last measurable width survives zero-size layout samples. ++ var width: CGFloat? ++ var resize: PagerResize? + var active = true + var phase: ScrollPhase = .idle + var userScrolling = false @@ -441,11 +522,36 @@ + } + + /// The user scrolled: whatever was pending is theirs to decide. -+ func settled() { ++ func settled(preservingResize: Bool = false) { + suspend() + restoring = false + armed = false + userScrolling = false + target = nil ++ if preservingResize { ++ resize?.checking = false ++ resize?.overdue = false ++ resize?.timerVersion += 1 ++ } else { ++ resize = nil ++ } ++ } ++} ++ ++/// Selection survives an acknowledgement; command budget does not reset on ++/// alignment, tracking or duplicate geometry. A later width burst gets a new ++/// episode, while a same-width late clamp still belongs to this one. ++private final class PagerResize { ++ var page: Int ++ var stable = false ++ var exhausted = false ++ var attempts = 0 ++ var version = 0 ++ var checking = false ++ var overdue = false ++ var timerVersion = 0 ++ ++ init(page: Int) { ++ self.page = page + } +} From 64ebdfeaf5d4ff8e5452755125e7700257d0306d Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 5 Nov 2026 22:45:39 +0000 Subject: [PATCH 05/42] Keep live feed and conversation panes across Duo resizing Use an iOS adaptive native host with stable conversation slots, compact navigation, pane-local composer insets and revision-checked native commands. Coordinate retained panes for outbox settlement, optimistic stars, deletion and failed-draft recovery without duplicating side effects. Preserve exact attached child receivers for async completion through a focused mobile patch. Cover retained state, stale commands, shared writes and recovery/resubmission with component regressions. Amp-Thread-ID: https://ampcode.com/threads/T-01a1ac67-a129-7066-90e3-bf362ac34436 Co-authored-by: Connor McManus --- app/NativeComponents/CommitDetail.php | 30 +- .../Concerns/AttachesPhotos.php | 22 +- .../Concerns/OpensCommitSheet.php | 9 +- app/NativeComponents/Concerns/OpensInTab.php | 8 + .../Concerns/ShowsCommits.php | 13 +- app/NativeComponents/Concerns/SyncsOutbox.php | 91 +++- app/NativeComponents/FeedWorkspace.php | 190 ++++++++ app/NativeComponents/Log.php | 2 + app/Providers/NativeServiceProvider.php | 2 + composer.json | 9 + composer.lock | 41 +- packages/adaptive-layout/composer.json | 27 ++ packages/adaptive-layout/nativephp.json | 20 + .../resources/ios/AdaptiveFeedRenderer.swift | 423 ++++++++++++++++++ .../src/AdaptiveLayoutServiceProvider.php | 7 + .../src/Components/AdaptiveDetail.php | 13 + .../src/Components/AdaptiveFeed.php | 13 + .../src/Elements/AdaptiveDetail.php | 61 +++ .../src/Elements/AdaptiveFeed.php | 58 +++ .../nativephp-mobile-live-child-async.patch | 24 + .../views/native/commit-detail.blade.php | 2 +- .../views/native/feed-workspace.blade.php | 10 + .../views/native/partials/log-page.blade.php | 2 +- routes/mobile.php | 4 +- tests/Feature/Screens/FeedWorkspaceTest.php | 327 ++++++++++++++ tests/TestCase.php | 2 + 26 files changed, 1393 insertions(+), 17 deletions(-) create mode 100644 app/NativeComponents/FeedWorkspace.php create mode 100644 packages/adaptive-layout/composer.json create mode 100644 packages/adaptive-layout/nativephp.json create mode 100644 packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift create mode 100644 packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php create mode 100644 packages/adaptive-layout/src/Components/AdaptiveDetail.php create mode 100644 packages/adaptive-layout/src/Components/AdaptiveFeed.php create mode 100644 packages/adaptive-layout/src/Elements/AdaptiveDetail.php create mode 100644 packages/adaptive-layout/src/Elements/AdaptiveFeed.php create mode 100644 patches/nativephp-mobile-live-child-async.patch create mode 100644 resources/views/native/feed-workspace.blade.php create mode 100644 tests/Feature/Screens/FeedWorkspaceTest.php diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index f20c88a3..56651b03 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -77,6 +77,11 @@ class CommitDetail extends NativeComponent public string $id = ''; + public bool $embedded = false; + + /** @var array */ + public array $conversationData = []; + public ?Commit $commit = null; /** @var list */ @@ -169,10 +174,28 @@ class CommitDetail extends NativeComponent */ protected bool $forceFullFrames = true; + public function data(string $key, $default = null): mixed + { + return $this->embedded ? ($this->conversationData[$key] ?? $default) : parent::data($key, $default); + } + + /** @param array $data */ + public function applyConversationIntent(array $data): void + { + $this->conversationData = $data; + if (($data['reply'] ?? false) === true) { + $this->focusReply = true; + } + if (($data['replies'] ?? false) === true) { + $this->repliesShown = true; + } + $this->editFromData(); + } + public function mount(): void { $this->focusReply = $this->data('reply', false) === true; - $this->repliesShown = $this->data('replies', false) === true; + $this->repliesShown = $this->embedded || $this->data('replies', false) === true; $draft = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); $this->reply = $draft->body; $this->replyImages = $draft->images; @@ -434,6 +457,11 @@ protected function showCommitTurnedDown(PendingWrite $write): void $this->keepStagedReplies(); } + protected function opensComposer(): bool + { + return $this->embedded; + } + /** * A reply to this commit from the bar goes back into the bar, marked as * restored, when the bar's free. One from the sheet opens the sheet again, diff --git a/app/NativeComponents/Concerns/AttachesPhotos.php b/app/NativeComponents/Concerns/AttachesPhotos.php index d04c60fd..590ebbef 100644 --- a/app/NativeComponents/Concerns/AttachesPhotos.php +++ b/app/NativeComponents/Concerns/AttachesPhotos.php @@ -5,6 +5,7 @@ use App\Devnull\Data\DraftImage; use App\Devnull\DraftImages; use App\Devnull\Exceptions\InvalidDraftImage; +use App\NativeComponents\FeedWorkspace; use Native\Mobile\Attributes\On; use Native\Mobile\Events\Camera\PermissionDenied; use Native\Mobile\Events\Camera\PhotoTaken; @@ -59,6 +60,9 @@ abstract protected function photoFailed(?string $error): void; public function pickPhotos(): void { if ($this->hasRoomForPhotos()) { + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->claimPhotoPicker($this); + } Camera::pickImages('images', true, DraftImages::MaxImages - count($this->attachedPhotos()))->start(); } } @@ -66,6 +70,9 @@ public function pickPhotos(): void public function takePhoto(): void { if ($this->hasRoomForPhotos()) { + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->claimPhotoPicker($this); + } Camera::getPhoto()->start(); } } @@ -73,6 +80,10 @@ public function takePhoto(): void #[On(PhotoTaken::class)] public function photoTaken(string $path = ''): void { + if (! $this->ownsPhotoPicker()) { + return; + } + $this->attachPhotos([$path]); } @@ -82,17 +93,26 @@ public function photoTaken(string $path = ''): void #[On(MediaSelected::class)] public function photosPicked(array $files = []): void { + if (! $this->ownsPhotoPicker()) { + return; + } + $this->attachPhotos(array_map(fn (mixed $file): string => is_array($file) && is_string($file['path'] ?? null) ? $file['path'] : '', $files)); } #[On(PermissionDenied::class)] public function cameraDenied(): void { - if ($this->takesPhotos()) { + if ($this->ownsPhotoPicker() && $this->takesPhotos()) { $this->photoFailed(DraftImages::CameraDenied); } } + private function ownsPhotoPicker(): bool + { + return ! $this->nativeParentComponent instanceof FeedWorkspace || $this->nativeParentComponent->ownsPhotoPicker($this); + } + /** * The ✕ on a photo: it comes off the draft, and off the phone. */ diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 19ac3922..90deb8e5 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -16,6 +16,7 @@ use App\Devnull\Timezone; use App\Devnull\Toasts; use App\Models\PendingWrite; +use App\NativeComponents\FeedWorkspace; use Carbon\CarbonImmutable; use Closure; use Native\Mobile\Facades\System; @@ -344,7 +345,7 @@ public function commitFromSheet(): void app(Stash::class)->put($me->id, $stashName, new StashedDraft); $this->sheetImages = []; $this->closeCommitSheet(); - $this->showCommitted($write->stagedCommit($me)); + $this->applyStagedCommit($write->stagedCommit($me)); $this->sendFromOutbox($write); } @@ -391,7 +392,11 @@ public function commitSheetClosed(): void $write = $held === null ? null : app(Outbox::class)->find($held); if ($write !== null && $write->isCommit() && $write->isFailed()) { - $this->openFailedCommit($write); + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->recoverOutboxDraft($write); + } else { + $this->openFailedCommit($write); + } return; } diff --git a/app/NativeComponents/Concerns/OpensInTab.php b/app/NativeComponents/Concerns/OpensInTab.php index 381c4991..83e3dbe4 100644 --- a/app/NativeComponents/Concerns/OpensInTab.php +++ b/app/NativeComponents/Concerns/OpensInTab.php @@ -2,7 +2,9 @@ namespace App\NativeComponents\Concerns; +use App\NativeComponents\FeedWorkspace; use App\NativeComponents\Layouts\TabsLayout; +use App\NativeComponents\Log; /** * Opens commits and profiles inside the tab they were tapped in, and never @@ -26,6 +28,12 @@ trait OpensInTab */ protected function openInTab(string $path, array $data = []): void { + if ($this->nativeParentComponent instanceof FeedWorkspace && str_starts_with($path, '/commits/')) { + $this->emit('open-conversation', substr($path, strlen('/commits/')), $data, $this instanceof Log); + + return; + } + $uri = $this->tabRoot().$path; if ($this->isShowing($uri)) { diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index da0f7fea..bb689fbc 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -16,6 +16,7 @@ use App\Devnull\SiteLink; use App\Devnull\Timezone; use App\Devnull\Toasts; +use App\NativeComponents\FeedWorkspace; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Edge\SharedValue; @@ -220,7 +221,6 @@ public function toggleStar(string $id): void return; } - $this->showStar($id, ! $commit->starred); $this->queueStar($id, ! $commit->starred); } @@ -489,10 +489,19 @@ public function deleteCommit(string $id): void if ($deleted === true) { Toasts::say('rm '.$commit->shortHash.' → /dev/null', short: true); - $this->showDeleted($id); + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->conversationDeleted($id); + } else { + $this->showDeleted($id); + } } } + public function applyWorkspaceDeletion(string $id): void + { + $this->showDeleted($id); + } + /** * A commit you deleted leaves the screen as one deleted on its own page does * when you come back: the screen shows what the cache kept, then asks devnull. diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php index 52b9a63e..6ebf80fa 100644 --- a/app/NativeComponents/Concerns/SyncsOutbox.php +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -17,6 +17,9 @@ use App\Devnull\Toasts; use App\Devnull\VoiceNotes; use App\Models\PendingWrite; +use App\NativeComponents\CommitDetail; +use App\NativeComponents\FeedWorkspace; +use App\NativeComponents\Log; use Native\Mobile\Attributes\On; use Native\Mobile\Facades\Browser; use Throwable; @@ -74,6 +77,9 @@ protected function queueStar(string $id, bool $starred): void $write = $outbox->add($starred ? PendingWrite::Star : PendingWrite::Unstar, $id, $starred ? [] : ['kept' => $cache->keptCommit($id)]); $cache->starChanged($id, $starred); + foreach ($this->outboxViews() as $view) { + $view->applyPendingStar($id, $starred); + } if ($write !== null && ! $outbox->starOnItsWay($id)) { $this->sendFromOutbox($write); @@ -129,6 +135,16 @@ protected function sendFromOutbox(PendingWrite $write): void #[On(Outbox::Answered)] public function outboxAnswered(string $id = '', string $status = 'finished', mixed $result = null, ?string $message = null): void { + if ($this->nativeParentComponent instanceof FeedWorkspace && $this instanceof CommitDetail) { + foreach ($this->outboxViews() as $view) { + if ($view instanceof Log) { + $view->outboxAnswered($id, $status, $result, $message); + + return; + } + } + } + $write = app(Outbox::class)->forTask($id); if ($write === null) { @@ -206,6 +222,10 @@ protected function stagedCommits(?string $parentId): array #[On(ToastActionPressed::class)] public function toastActionPressed(string $toastId = '', string $actionId = '', array $data = []): void { + if ($this->nativeParentComponent instanceof FeedWorkspace && $this->nativeParentComponent->foregroundPane() !== $this) { + return; + } + match ($actionId) { 'retry-star' => $this->retryStar($data), 'retry-message' => $this->retryMessage($data), @@ -234,7 +254,7 @@ private function writeWent(PendingWrite $write, array $json): void if (! $later && $data !== []) { app(ResponseCache::class)->commitReacted($data); app(ResponseCache::class)->commitStarred($data); - $this->showStar((string) $write->target, ($data['starred'] ?? false) === true, Commit::fromArray($data)); + $this->broadcastOutboxChange('star', $write, $data, ($data['starred'] ?? false) === true); } return; @@ -246,7 +266,7 @@ private function writeWent(PendingWrite $write, array $json): void if ($write->isCommit()) { app(ResponseCache::class)->commitAdded($data); - $this->showCommitWent($write, Commit::fromArray($data)); + $this->broadcastOutboxChange('commit-sent', $write, $data); return; } @@ -273,7 +293,7 @@ private function writeTurnedDown(PendingWrite $write, string $error, bool $reope } app(ResponseCache::class)->starChanged((string) $write->target, $write->wasStarred(), is_array($write->payload['kept'] ?? null) ? $write->payload['kept'] : null); - $this->showStar((string) $write->target, $write->wasStarred()); + $this->broadcastOutboxChange('star', $write, starred: $write->wasStarred()); Toasts::failed($error, ['label' => 'Retry', 'id' => 'retry-star', 'data' => ['commit' => $write->target, 'starred' => $starred]]); @@ -309,9 +329,12 @@ private function turnDownCommit(PendingWrite $write, string $error, bool $reopen $stash->put($this->me()?->id, $name, new StashedDraft($write->body())); } - $this->showCommitTurnedDown($write); + $this->broadcastOutboxChange('commit-rejected', $write); - if ($reopen && $this->reopenFailedCommit($write)) { + $recovered = $reopen && ($this->nativeParentComponent instanceof FeedWorkspace + ? $this->nativeParentComponent->recoverOutboxDraft($write) + : $this->reopenFailedCommit($write)); + if ($recovered) { Toasts::failed($error); return; @@ -320,6 +343,60 @@ private function turnDownCommit(PendingWrite $write, string $error, bool $reopen Toasts::failed($error, ['label' => 'Edit', 'id' => 'edit', 'data' => ['write' => $write->id]]); } + public function applyStagedCommit(Commit $commit): void + { + $this->showCommitted($commit); + + if ($this instanceof CommitDetail && $commit->parentId === null) { + foreach ($this->outboxViews() as $view) { + if ($view instanceof Log) { + $view->applyStagedCommit($commit); + } + } + } + } + + /** @return list */ + private function outboxViews(): array + { + return $this->nativeParentComponent instanceof FeedWorkspace + ? array_values(array_filter($this->nativeParentComponent->nativeChildComponents, fn ($child): bool => $child instanceof Log || $child instanceof CommitDetail)) + : [$this]; + } + + /** @param array $data */ + private function broadcastOutboxChange(string $state, PendingWrite $write, array $data = [], ?bool $starred = null): void + { + foreach ($this->outboxViews() as $view) { + $view->applyOutboxChange($state, $write, $data, $starred); + } + } + + /** + * Applies only view state; the single answering owner settles storage, + * cache, files, subsequent writes and toasts before broadcasting here. + * + * @param array $data + */ + public function applyOutboxChange(string $state, PendingWrite $write, array $data = [], ?bool $starred = null): void + { + match ($state) { + 'star' => $this->showStar((string) $write->target, $starred === true, $data === [] ? null : Commit::fromArray($data)), + 'commit-sent' => $this->showCommitWent($write, Commit::fromArray($data)), + 'commit-rejected' => $this->showCommitTurnedDown($write), + }; + } + + public function reopenOutboxDraft(PendingWrite $write): bool + { + return $this->reopenFailedCommit($write); + } + + public function applyPendingStar(string $id, bool $starred): void + { + $this->showStar($id, $starred); + } + /** * Signs a write as devnull takes it, here on the UI thread. */ @@ -349,7 +426,6 @@ private function retryStar(array $data): void return; } - $this->showStar($data['commit'], $data['starred']); $this->queueStar($data['commit'], $data['starred']); } @@ -391,6 +467,9 @@ private function editFromToast(array $data): void $write = is_numeric($data['write'] ?? null) ? app(Outbox::class)->find((int) $data['write']) : null; if ($write !== null && $write->isCommit() && $write->isFailed()) { + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->navigationRevision++; + } $this->editFailedCommit($write); } } diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php new file mode 100644 index 00000000..059bcbaa --- /dev/null +++ b/app/NativeComponents/FeedWorkspace.php @@ -0,0 +1,190 @@ +}> */ + public array $conversations = []; + + public int $navigationRevision = 0; + + private int $nextSlot = 0; + + private ?NativeComponent $photoOwner = null; + + public function claimPhotoPicker(NativeComponent $owner): void + { + $this->photoOwner = $owner; + } + + public function ownsPhotoPicker(NativeComponent $owner): bool + { + return $this->photoOwner === $owner; + } + + public function foregroundPane(): Log|CommitDetail|null + { + $current = end($this->conversations); + + return $current === false + ? $this->feed() + : ($this->nativeChildComponents['commit-detail|key:conversation-'.$current['slot']] ?? null); + } + + public function recoverOutboxDraft(PendingWrite $write): bool + { + if ($this->foregroundPane()?->reopenOutboxDraft($write)) { + $this->navigationRevision++; + + return true; + } + + return false; + } + + #[On('open-conversation')] + public function openConversation(string $id, array $data = [], bool $fromFeed = false): void + { + $this->navigationRevision++; + $current = end($this->conversations); + + if ($current !== false && $current['id'] === $id) { + $index = array_key_last($this->conversations); + $this->conversations[$index]['data'] = $data; + $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$current['slot']] ?? null; + if ($detail instanceof CommitDetail) { + $detail->applyConversationIntent($data); + } + + return; + } + + if ($fromFeed) { + $this->conversations = []; + } + + $this->conversations[] = ['slot' => ++$this->nextSlot, 'id' => $id, 'data' => $data]; + } + + public function conversationDeleted(string $id): void + { + foreach ($this->conversations as $index => $conversation) { + if ($conversation['id'] === $id) { + $this->conversations = array_slice($this->conversations, 0, $index); + $this->navigationRevision++; + break; + } + } + + $retained = array_map(fn (array $conversation): string => 'commit-detail|key:conversation-'.$conversation['slot'], $this->conversations); + foreach ($this->nativeChildComponents as $key => $child) { + if ($child instanceof Log || ($child instanceof CommitDetail && in_array($key, $retained, true))) { + $child->applyWorkspaceDeletion($id); + } + } + } + + public function hostChanged(string $event): void + { + $change = json_decode($event, true); + + if (! is_array($change)) { + return; + } + + if (in_array($change['action'] ?? null, ['back', 'close', 'delete'], true)) { + $identities = array_map(fn (array $conversation): string => 'conversation-'.$conversation['slot'], $this->conversations); + $expected = $change['expected_history'] ?? null; + $matches = $expected === $identities; + if (($change['action'] ?? null) === 'back' && is_array($expected)) { + $matches = array_slice($expected, 0, count($identities)) === $identities; + } + if (! is_array($expected) || ($change['expected_revision'] ?? null) !== $this->navigationRevision || ($change['expected_depth'] ?? null) !== count($expected) || ! $matches) { + return; + } + } + + switch ($change['action'] ?? null) { + case 'back': + $depth = $change['depth'] ?? count($this->conversations) - 1; + if (is_int($depth) && $depth >= 0 && $depth < count($this->conversations)) { + $this->conversations = array_slice($this->conversations, 0, $depth); + } + break; + case 'close': + $this->conversations = []; + break; + case 'compose': + $this->feed()?->composeInSheet(); + break; + case 'search': + $this->feed()?->openSearch(); + break; + case 'delete': + $current = end($this->conversations); + if ($current !== false) { + $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$current['slot']] ?? null; + if ($detail instanceof CommitDetail) { + $detail->confirmDelete($current['id']); + } + } + break; + } + } + + public function conversationCanDelete(int $slot): bool + { + $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$slot] ?? null; + + return $detail instanceof CommitDetail && $detail->isOwn(); + } + + public function onResume(): void + { + foreach ($this->nativeChildComponents as $child) { + $child->onResume(); + } + } + + #[Poll(30000)] + public function checkForNewCommits(): void + { + $this->feed()?->checkForNewCommits(); + } + + private function feed(): ?Log + { + foreach ($this->nativeChildComponents as $child) { + if ($child instanceof Log) { + return $child; + } + } + + return null; + } + + public function navTitle(): string + { + return 'devnull'; + } + + public function navigationOptions(): ?NavBarOptions + { + return NavBarOptions::make()->hidden(); + } + + public function render(): View + { + return view('native.feed-workspace'); + } +} diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 050e79e2..fd312381 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -163,6 +163,8 @@ class Log extends NativeComponent public string $log = 'following'; + public bool $embedded = false; + /** * The list key of each log you're not on. The log you're on uses $listKey. * diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index 9820f532..13a4e9c8 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Codingwithrk\NativephpSocialAuth\NativephpSocialAuthServiceProvider; +use Devnull\AdaptiveLayout\AdaptiveLayoutServiceProvider; use Devnull\Calls\CallsServiceProvider; use Illuminate\Support\ServiceProvider; use Native\Mobile\Providers\BrowserServiceProvider; @@ -51,6 +52,7 @@ public function plugins(): array { return [ NativeUIServiceProvider::class, + AdaptiveLayoutServiceProvider::class, BrowserServiceProvider::class, CameraServiceProvider::class, MicrophoneServiceProvider::class, diff --git a/composer.json b/composer.json index 583b35b8..91b9d2a7 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,11 @@ ], "license": "MIT", "repositories": [ + { + "name": "adaptive-layout", + "type": "path", + "url": "packages/adaptive-layout" + }, { "name": "calls", "type": "path", @@ -30,6 +35,7 @@ "php": "^8.3", "codingwithrk/nativephp-social-auth": "^1.0", "cweagans/composer-patches": "^1.7", + "devnull/adaptive-layout": "^1.0", "devnull/nativephp-calls": "^1.0", "laravel/boost": "^2.0", "laravel/framework": "^13.0", @@ -115,6 +121,9 @@ }, "composer-exit-on-patch-failure": true, "patches": { + "nativephp/mobile": { + "Async callbacks retain their exact live child receiver within the dispatch root": "patches/nativephp-mobile-live-child-async.patch" + }, "nativephp/mobile-ui": { "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch" } diff --git a/composer.lock b/composer.lock index 358b1cbc..b9b8e22f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c03857c5fc1f57902e875041719b3ec3", + "content-hash": "706b0a8092b0d482ad97f3567ab6dc91", "packages": [ { "name": "bacon/bacon-qr-code", @@ -440,6 +440,43 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "devnull/adaptive-layout", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "packages/adaptive-layout", + "reference": "e16069926c3f6cc321e3f78e46f12d6e5ff7e9e5" + }, + "require": { + "nativephp/mobile": "^4.7", + "nativephp/mobile-ui": "^0.9", + "php": "^8.3" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Devnull\\AdaptiveLayout\\AdaptiveLayoutServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Devnull\\AdaptiveLayout\\": "src/" + } + }, + "license": [ + "MIT" + ], + "description": "Retained iOS feed and conversation navigation for devnull", + "transport-options": { + "relative": true + } + }, { "name": "devnull/nativephp-calls", "version": "1.0.0", @@ -11572,5 +11609,5 @@ "php": "^8.3" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" } diff --git a/packages/adaptive-layout/composer.json b/packages/adaptive-layout/composer.json new file mode 100644 index 00000000..8f6647e4 --- /dev/null +++ b/packages/adaptive-layout/composer.json @@ -0,0 +1,27 @@ +{ + "name": "devnull/adaptive-layout", + "description": "Retained iOS feed and conversation navigation for devnull", + "type": "nativephp-plugin", + "version": "1.0.0", + "license": "MIT", + "require": { + "php": "^8.3", + "nativephp/mobile": "^4.7", + "nativephp/mobile-ui": "^0.9" + }, + "autoload": { + "psr-4": { + "Devnull\\AdaptiveLayout\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Devnull\\AdaptiveLayout\\AdaptiveLayoutServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + } +} diff --git a/packages/adaptive-layout/nativephp.json b/packages/adaptive-layout/nativephp.json new file mode 100644 index 00000000..7ba42057 --- /dev/null +++ b/packages/adaptive-layout/nativephp.json @@ -0,0 +1,20 @@ +{ + "namespace": "AdaptiveLayout", + "platforms": ["ios"], + "components": [ + { + "type": "adaptive_feed", + "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptiveFeed", + "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptiveFeed", + "ios_renderer": "AdaptiveFeedRenderer", + "self_closing": false + }, + { + "type": "adaptive_detail", + "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptiveDetail", + "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptiveDetail", + "ios_renderer": "AdaptiveDetailRenderer", + "self_closing": false + } + ] +} diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift new file mode 100644 index 00000000..c823ece2 --- /dev/null +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -0,0 +1,423 @@ +import SwiftUI +import UIKit + +struct AdaptiveFeedRenderer: View { + let node: NativeUINode + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + AdaptiveFeedHost(node: node) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.nativeUITheme, themeStore.resolve(for: colorScheme)) + } +} + +// The detail tag is a descriptor consumed by the feed host, not a second router. +struct AdaptiveDetailRenderer: View { + let node: NativeUINode + + var body: some View { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } + } +} + +private struct AdaptiveFeedHost: UIViewControllerRepresentable { + let node: NativeUINode + + func makeUIViewController(context: Context) -> AdaptiveWorkspaceController { + let controller = AdaptiveWorkspaceController() + controller.receive(node, environment: context.environment) + return controller + } + + func updateUIViewController(_ controller: AdaptiveWorkspaceController, context: Context) { + controller.receive(node, environment: context.environment) + } +} + +/// A stable root type for every retained hosting controller. Only the direct +/// bottom_bar sentinel is removed; all native node ids and ancestors survive. +private struct AdaptiveNodeRoot: View { + let content: NativeUINode + let bottom: NativeUINode? + let inherited: EnvironmentValues + + var body: some View { + GeometryReader { geometry in + GeometryReader { contentGeometry in + NodeView(node: content) + .equatable() + .environment(\.availableHeight, contentGeometry.size.height) + } + .modifier(AdaptiveBottomInset(bottom: bottom)) + .environment(\.availableWidth, geometry.size.width) + .environment(\.availableHeight, geometry.size.height) + .environment(\.nativeSafeAreaTop, geometry.safeAreaInsets.top) + .environment(\.nativeSafeAreaBottom, geometry.safeAreaInsets.bottom) + } + // The outer tab host already removes the system rail. UIKit still + // propagates its window-side inset into each nested hosting root. + .ignoresSafeArea(.container, edges: .horizontal) + // UIHostingController is a new SwiftUI root, so it does not inherit the + // representable's theme, window breakpoints, locale, or custom keys. + .environment(\.self, inherited) + } +} + +private struct AdaptiveBottomInset: ViewModifier { + let bottom: NativeUINode? + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + if let bottom { + let dark = colorScheme == .dark ? bottom.props.getColor("dark_bg_color", default: 0) : 0 + let background = dark != 0 ? dark : (bottom.style?.bgColor ?? 0) + content.safeAreaInset(edge: .bottom, spacing: 0) { + NodeView(node: bottom) + .equatable() + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .background((background == 0 ? Color.clear : Color(argb: background)) + .ignoresSafeArea(edges: .bottom)) + } + } else { + content + } + } +} + +private final class AdaptivePageController: UIHostingController { + private(set) var source: NativeUINode + private var contentNode: NativeUINode + private var bottomNode: NativeUINode? + + init(node: NativeUINode, detail: Bool, environment: EnvironmentValues) { + source = node + let root = detail ? (node.children.first ?? node) : node + let sentinel = detail ? root.children.first { $0.type == "bottom_bar" } : nil + let content = sentinel == nil ? root : root.copy(children: root.children.filter { $0.type != "bottom_bar" }) + let bottom = sentinel?.children.first + contentNode = content + bottomNode = bottom + super.init(rootView: AdaptiveNodeRoot(content: content, bottom: bottom, inherited: environment)) + view.backgroundColor = .clear + navigationItem.largeTitleDisplayMode = .never + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func update(_ node: NativeUINode, detail: Bool, environment: EnvironmentValues) { + if source !== node { + source = node + let root = detail ? (node.children.first ?? node) : node + let sentinel = detail ? root.children.first { $0.type == "bottom_bar" } : nil + contentNode = sentinel == nil ? root : root.copy(children: root.children.filter { $0.type != "bottom_bar" }) + bottomNode = sentinel?.children.first + } + // A resize never copies text from PHP or replaces a controller. The + // unchanged node references also let equatable NodeView skip echoes. + rootView = AdaptiveNodeRoot(content: contentNode, bottom: bottomNode, inherited: environment) + } +} + +private final class AdaptiveWorkspaceController: UIViewController, UINavigationControllerDelegate { + private let primary = UINavigationController() + private let secondary = UINavigationController() + private let divider = UIView() + private var pages: [Int: AdaptivePageController] = [:] + private var latest: NativeUINode? + private var inherited: EnvironmentValues? + private var wide: Bool? + private var applying = false + private var transitioning = false + private var expectedPrimary: [UIViewController] = [] + private var expectedSecondary: [UIViewController] = [] + private var history: [Int] = [] + private var historyIdentities: [String] = [] + private var logicalDepth = 0 + private var intentRevision = 0 + // Hold the completed native pop until PHP acknowledges it. Repeated old + // publishes must not immediately push the conversation back on screen. + private var awaitingHistory: [Int]? + private var poppedDepth: Int? + private var reconciling = false + private var revision: UInt64 = 0 + private var appliedRevision: UInt64? + private var logoName: String? + private var logoView: UIImageView? + + override func viewDidLoad() { + super.viewDidLoad() + for navigation in [primary, secondary] { + navigation.delegate = self + navigation.navigationBar.prefersLargeTitles = false + addChild(navigation) + view.addSubview(navigation.view) + navigation.didMove(toParent: self) + } + view.addSubview(divider) + secondary.view.isHidden = true + divider.isHidden = true + } + + func receive(_ node: NativeUINode, environment: EnvironmentValues) { + latest = node + inherited = environment + revision &+= 1 + guard isViewLoaded else { return } + reconcile() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + reconcile() + layoutColumns() + } + + private func layoutColumns() { + let bounds = view.bounds + if wide == true { + let feedWidth = min(420, bounds.width * 0.42) + primary.view.frame = CGRect(x: 0, y: 0, width: feedWidth, height: bounds.height) + secondary.view.frame = CGRect(x: feedWidth + 1, y: 0, width: max(0, bounds.width - feedWidth - 1), height: bounds.height) + divider.frame = CGRect(x: feedWidth, y: 0, width: 1, height: bounds.height) + } else { + primary.view.frame = bounds + } + } + + private func reconcile() { + guard !reconciling, !applying, !transitioning, + primary.transitionCoordinator == nil, secondary.transitionCoordinator == nil, + let latest, let inherited, view.bounds.width > 0, view.bounds.height > 0, + let feed = latest.children.first(where: { $0.type != "adaptive_detail" }) else { return } + let landscape = view.window?.windowScene?.interfaceOrientation.isLandscape ?? (view.bounds.width > view.bounds.height) + let wantsWide = view.bounds.width >= 820 && landscape + // Root-view assignment can schedule another UIKit layout. A bounds-only + // layout in the same mode must not assign all hosting roots again. + guard appliedRevision != revision || wide != wantsWide else { return } + reconciling = true + defer { reconciling = false } + appliedRevision = revision + + let details = latest.children.filter { $0.type == "adaptive_detail" } + let requestedDepth = max(0, latest.props.getInt("depth")) + let requestedIntent = latest.props.getInt("intent_revision") + let currentHistory = details.map(\.id) + if let awaitingHistory, let poppedDepth { + // Two completed pops can outrun PHP. An intermediate ancestor + // acknowledgement must not repush the second screen just popped. + let ancestor = currentHistory.count <= awaitingHistory.count + && currentHistory == Array(awaitingHistory.prefix(currentHistory.count)) + if requestedIntent != intentRevision || !ancestor || requestedDepth <= poppedDepth { + self.awaitingHistory = nil + self.poppedDepth = nil + } + } + history = currentHistory + historyIdentities = details.map { $0.props.getString("identity", default: String($0.id)) } + logicalDepth = requestedDepth + intentRevision = requestedIntent + // PHP owns history. Truncate to its explicit depth; never invent a + // detail screen while children are still arriving asynchronously. + let visibleDepth = min(details.count, poppedDepth ?? requestedDepth) + let activeDetails = Array(details.prefix(visibleDepth)) + let migrating = wide != wantsWide + let oldSelection = (wide == true ? secondary.topViewController : primary.topViewController) + let focused = migrating ? view.adaptiveFirstResponder() : nil + + for (node, detail) in [(feed, false)] + details.map({ ($0, true) }) { + if let page = pages[node.id] { + page.update(node, detail: detail, environment: inherited) + } else { + pages[node.id] = AdaptivePageController(node: node, detail: detail, environment: inherited) + } + } + configureBars(feed: feed, details: details, environment: inherited, wide: wantsWide) + guard let feedPage = pages[feed.id] else { return } + let detailPages: [UIViewController] = activeDetails.compactMap { pages[$0.id] } + let primaryStack: [UIViewController] = wantsWide ? [feedPage] : [feedPage] + detailPages + let secondaryStack: [UIViewController] = wantsWide ? detailPages : [] + + applying = true + if migrating { + // Release both old parents BEFORE adding any page to the other + // nav controller. UINavigationController cannot share children. + primary.setViewControllers([], animated: false) + secondary.setViewControllers([], animated: false) + } + wide = wantsWide + expectedPrimary = primaryStack + expectedSecondary = secondaryStack + if !sameControllers(primary.viewControllers, primaryStack) { + primary.setViewControllers(primaryStack, animated: false) + } + if !sameControllers(secondary.viewControllers, secondaryStack) { + secondary.setViewControllers(secondaryStack, animated: false) + } + applying = false + secondary.view.isHidden = !wantsWide + divider.isHidden = !wantsWide + layoutColumns() + let validIDs = Set([feed.id] + currentHistory) + pages = pages.filter { validIDs.contains($0.key) } + + let selection = wantsWide ? secondary.topViewController : primary.topViewController + if migrating, oldSelection === selection, let focused { + // Best effort only. Do not steal a newly acquired focus or write + // any text/selection value; SwiftUI/UIKit may discard the field. + DispatchQueue.main.async { [weak self, weak focused, weak selection] in + guard let self, let focused, let selection, + (self.wide == true ? self.secondary.topViewController : self.primary.topViewController) === selection, + focused.window != nil, focused.canBecomeFirstResponder, + focused.window?.adaptiveFirstResponder() == nil else { return } + focused.becomeFirstResponder() + } + } + } + + private func sameControllers(_ left: [UIViewController], _ right: [UIViewController]) -> Bool { + left.count == right.count && zip(left, right).allSatisfy { $0.0 === $0.1 } + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + guard !applying, animated else { return } + transitioning = true + } + + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + guard !applying else { return } + transitioning = false + let expected = navigationController === primary ? expectedPrimary : expectedSecondary + let actual = navigationController.viewControllers + // didShow fires after a cancelled gesture too: only a STRICT prefix + // removal is a committed user pop. Resize mutations are synchronous + // and guarded by applying, so cannot produce a back event. + if actual.count < expected.count, + sameControllers(actual, Array(expected.prefix(actual.count))) { + let depth = navigationController === primary ? max(0, actual.count - 1) : actual.count + awaitingHistory = history + poppedDepth = depth + if navigationController === primary { + expectedPrimary = actual + } else { + expectedSecondary = actual + } + send("back", depth: depth) + } + // The coordinator may still be non-nil in didShow. Wait one turn; + // updates received during the gesture remain in latest until here. + DispatchQueue.main.async { [weak self] in self?.reconcile() } + } + + private func configureBars(feed: NativeUINode, details: [NativeUINode], environment: EnvironmentValues, wide: Bool) { + let tokens = environment.nativeUITheme + view.backgroundColor = UIColor(tokens.background) + divider.backgroundColor = UIColor(tokens.outlineVariant) + for navigation in [primary, secondary] { + navigation.navigationBar.tintColor = UIColor(tokens.onSurface) + } + guard let feedPage = pages[feed.id] else { return } + feedPage.title = "Feed" + let name = environment.colorScheme == .dark ? "logo-dark@3x.png" : "logo-light@3x.png" + if name != logoName { + logoName = name + if let image = UIImage(contentsOfFile: AppUpdateManager.shared.getAppPath() + "/public/images/" + name) { + let imageView = UIImageView(image: image) + imageView.contentMode = .scaleAspectFit + imageView.frame = CGRect(x: 0, y: 0, width: 120, height: 28) + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = "devnull" + logoView = imageView + } else { + logoView = nil + } + } + feedPage.navigationItem.titleView = logoView + if feedPage.navigationItem.rightBarButtonItems == nil { + let compose = UIBarButtonItem(image: UIImage(systemName: "square.and.pencil"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.compose)) + compose.accessibilityLabel = "Compose" + let search = UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.search)) + search.accessibilityLabel = "Search" + feedPage.navigationItem.rightBarButtonItems = [compose, search] + } + for detail in details { + guard let page = pages[detail.id] else { continue } + let title = detail.props.getString("title", default: "Commit") + let subtitle = detail.props.getString("subtitle", default: "") + if page.title != title || page.navigationItem.titleView?.accessibilityLabel != title + ", " + subtitle { + page.title = title + let heading = UILabel() + heading.text = title + heading.font = .preferredFont(forTextStyle: .headline) + heading.adjustsFontForContentSizeCategory = true + let hash = UILabel() + hash.text = subtitle + hash.font = .preferredFont(forTextStyle: .caption1) + hash.adjustsFontForContentSizeCategory = true + hash.lineBreakMode = .byTruncatingMiddle + let stack = UIStackView(arrangedSubviews: [heading, hash]) + stack.axis = .vertical + stack.alignment = .center + stack.isAccessibilityElement = true + stack.accessibilityLabel = title + ", " + subtitle + page.navigationItem.titleView = stack + } + if let stack = page.navigationItem.titleView as? UIStackView { + (stack.arrangedSubviews.first as? UILabel)?.textColor = UIColor(tokens.onSurface) + (stack.arrangedSubviews.last as? UILabel)?.textColor = UIColor(tokens.onSurfaceVariant) + } + var actions: [UIBarButtonItem] = [] + if wide { + let close = UIBarButtonItem(title: "Close", style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.close)) + close.accessibilityLabel = "Close conversation" + actions.append(close) + } + if detail.props.getBool("can_delete") { + let delete = UIBarButtonItem(image: UIImage(systemName: "trash"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.deleteConversation)) + delete.accessibilityLabel = "Delete commit" + actions.append(delete) + } + page.navigationItem.rightBarButtonItems = actions + } + } + + @objc private func compose() { send("compose") } + @objc private func search() { send("search") } + @objc private func close() { send("close") } + @objc private func deleteConversation() { send("delete") } + + private func send(_ action: String, depth: Int? = nil) { + guard let latest else { return } + let callback = latest.props.getCallbackId("on_change") + guard callback != 0 else { return } + // These describe the last APPLIED PHP history, not `latest`: a newer + // publish may already be queued during an interactive pop. PHP must + // compare both before accepting a delayed back/close notification. + var payload: [String: Any] = [ + "action": action, + "expected_depth": logicalDepth, + "expected_revision": intentRevision, + "expected_history": historyIdentities, + "expected_node_ids": history, + ] + if let depth { payload["depth"] = depth } + guard let bytes = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: bytes, encoding: .utf8) else { return } + NativeElementBridge.sendTextChangeEvent(callback, nodeId: latest.id, text: text) + } +} + +private extension UIView { + func adaptiveFirstResponder() -> UIView? { + if isFirstResponder { return self } + for child in subviews { + if let responder = child.adaptiveFirstResponder() { return responder } + } + return nil + } +} diff --git a/packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php b/packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php new file mode 100644 index 00000000..5bcb67b4 --- /dev/null +++ b/packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php @@ -0,0 +1,7 @@ + 'Commit', 'subtitle' => '']; + + public static function make(): static + { + return new static; + } + + public function title(string $title): static + { + $this->componentProps['title'] = $title; + + return $this; + } + + public function subtitle(string $subtitle): static + { + $this->componentProps['subtitle'] = $subtitle; + + return $this; + } + + public function identity(string $identity): static + { + $this->componentProps['identity'] = $identity; + + return $this; + } + + public function applyAttributes(array $attrs): void + { + if (isset($attrs['title'])) { + $this->title((string) $attrs['title']); + } + + if (isset($attrs['subtitle'])) { + $this->subtitle((string) $attrs['subtitle']); + } + + if (isset($attrs['identity'])) { + $this->identity((string) $attrs['identity']); + } + + $this->componentProps['can_delete'] = (bool) ($attrs['can-delete'] ?? $attrs['can_delete'] ?? false); + } + + protected function resolveProps(CallbackRegistry $registry): array + { + return $this->componentProps; + } +} diff --git a/packages/adaptive-layout/src/Elements/AdaptiveFeed.php b/packages/adaptive-layout/src/Elements/AdaptiveFeed.php new file mode 100644 index 00000000..7fc4df55 --- /dev/null +++ b/packages/adaptive-layout/src/Elements/AdaptiveFeed.php @@ -0,0 +1,58 @@ + 0, 'intent_revision' => 0]; + + public static function make(): static + { + return new static; + } + + public function depth(int $depth): static + { + $this->componentProps['depth'] = max(0, $depth); + + return $this; + } + + public function onChange(string $method): static + { + $this->componentProps['on_change'] = $method; + + return $this; + } + + public function applyAttributes(array $attrs): void + { + if (isset($attrs['depth'])) { + $this->depth((int) $attrs['depth']); + } + + if (isset($attrs['intent-revision'])) { + $this->componentProps['intent_revision'] = (int) $attrs['intent-revision']; + } + + if (isset($attrs['_change'])) { + $this->onChange($attrs['_change']); + } + } + + protected function resolveProps(CallbackRegistry $registry): array + { + $props = $this->componentProps; + + if (isset($props['on_change'])) { + $props['on_change'] = $registry->register($props['on_change']); + } + + return $props; + } +} diff --git a/patches/nativephp-mobile-live-child-async.patch b/patches/nativephp-mobile-live-child-async.patch new file mode 100644 index 00000000..794ebc84 --- /dev/null +++ b/patches/nativephp-mobile-live-child-async.patch @@ -0,0 +1,24 @@ +--- a/src/Edge/NativeComponent.php ++++ b/src/Edge/NativeComponent.php +@@ -2655,10 +2655,18 @@ + $callback = app($callback); + } + +- // Bind the closure to this live component so finished()/failed() can +- // mutate state via $this. Static closures run without $this. ++ // A live child's callback must retain its original receiver. The ++ // dispatch-root check above still guards the screen session; exact ++ // tree membership also rejects detached or same-key replacements. + if ($callback instanceof \Closure && ! (new \ReflectionFunction($callback))->isStatic()) { +- $callback = \Closure::bind($callback, $this, static::class); ++ $owner = (new \ReflectionFunction($callback))->getClosureThis(); ++ if ($owner instanceof self) { ++ if (! in_array($owner, iterator_to_array($this->componentTree(), false), true)) { ++ return; ++ } ++ } else { ++ $callback = \Closure::bind($callback, $this, static::class); ++ } + } + + if ($failed) { diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index b4263b13..c9408c75 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -7,7 +7,7 @@ - + @if (! $commit && $error === null) diff --git a/resources/views/native/feed-workspace.blade.php b/resources/views/native/feed-workspace.blade.php new file mode 100644 index 00000000..c24822e1 --- /dev/null +++ b/resources/views/native/feed-workspace.blade.php @@ -0,0 +1,10 @@ + + + + + @foreach ($conversations as $conversation) + + + + @endforeach + diff --git a/resources/views/native/partials/log-page.blade.php b/resources/views/native/partials/log-page.blade.php index e50a9218..1e2073d9 100644 --- a/resources/views/native/partials/log-page.blade.php +++ b/resources/views/native/partials/log-page.blade.php @@ -16,7 +16,7 @@ 'http://devnull.test']); + signIn(); +}); + +function serveWorkspace(): void +{ + serveApi(function (Request $request) { + $path = parse_url($request->url(), PHP_URL_PATH); + + if (preg_match('~/commits/([^/]+)$~', $path, $matches)) { + return Http::response(commitPageJson(commitJson(['id' => $matches[1], 'body' => 'Conversation '.$matches[1]]))); + } + + if (str_contains($path, '/log/')) { + return Http::response(pageJson([commitJson(['id' => 'c2', 'body' => 'Browsable feed'])])); + } + + return Http::response(pageJson([])); + }); +} + +it('keeps the feed live beside conversation history and ignores obsolete native back', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->assertSee('Browsable feed'); + + $screen->call('openConversation', 'c2', ['replies' => true], true) + ->assertSee('Conversation c2') + ->assertSee('Browsable feed') + ->call('openConversation', 'c3') + ->assertSee('Conversation c3'); + $oldBack = json_encode(['action' => 'back', 'depth' => 1, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']]); + + $screen->call('openConversation', 'c4', [], true)->call('hostChanged', $oldBack); + + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c4']); + $screen->assertSee('Conversation c4')->assertSee('Browsable feed')->assertDontSee('Conversation c3'); + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 0, 'expected_depth' => 1, 'expected_revision' => 3, 'expected_history' => ['conversation-3']])); + expect($screen->get('conversations'))->toBe([]); + $screen->assertSee('Browsable feed')->assertDontSee('Conversation c4'); +}); + +it('applies held child refreshes and stashes the selected conversation draft', function () { + serveWorkspace(); + holdRefreshes(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios'); + finishRefreshes($screen); + + $screen->call('openConversation', 'c2', ['replies' => true], true); + finishRefreshes($screen)->assertSee('Conversation c2'); + $screen->input('reply-input', 'A draft for c2'); + + expect(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe('A draft for c2'); + $screen->call('openConversation', 'c3', [], true); + finishRefreshes($screen)->assertSee('Conversation c3')->assertDontSee('Conversation c2'); + $screen->call('openConversation', 'c2', [], true); + finishRefreshes($screen)->assertSee('A draft for c2'); +}); + +it('keeps all three feed tabs interactive without replacing the selected conversation', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios'); + $pager = nodeByRef($screen, 'log-pager')['id']; + $screen->tap('commit-c2')->input('reply-input', 'Keep this conversation draft'); + + foreach ([1, 2, 0] as $page) { + $screen->changeTab('showPage', $page) + ->assertSee('Conversation c2') + ->assertSee('Keep this conversation draft') + ->assertNoNavigation(); + + expect(nodeByRef($screen, 'log-pager')['id'])->toBe($pager); + expect(nodeByRef($screen, 'log-pager')['props']['page'])->toBe($page); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + } +}); + +it('retries a child server failure and recovers an API error on resume', function () { + holdRefreshes(); + $attempts = 0; + serveApi(function (Request $request) use (&$attempts) { + $path = parse_url($request->url(), PHP_URL_PATH); + if ($path === '/api/v1/commits/c2') { + $attempts++; + + return match ($attempts) { + 1 => Http::response(['message' => 'Temporary failure'], 503), + 2 => Http::response(['message' => 'Conversation unavailable'], 422), + default => Http::response(commitPageJson(commitJson(['id' => 'c2', 'body' => 'Recovered conversation']))), + }; + } + + return Http::response(pageJson([])); + }); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios')); + finishRefreshes($screen->call('openConversation', 'c2', [], true)) + ->assertSee('Conversation unavailable'); + + expect($attempts)->toBe(2); + finishRefreshes($screen->call('onResume'))->assertSee('Recovered conversation')->assertDontSee('Conversation unavailable'); + expect($attempts)->toBe(3); +}); + +it('drops a detached conversation completion rather than caching or displaying its result', function () { + serveWorkspace(); + holdRefreshes(); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios')); + $screen->call('openConversation', 'c2', [], true) + ->call('openConversation', 'c3', [], true); + + finishRefreshes($screen)->assertSee('Conversation c3')->assertDontSee('Conversation c2'); + expect(kept('commits/c2'))->toBeNull(); + expect(kept('commits/c3')['data']['body'])->toBe('Conversation c3'); +}); + +it('accepts consecutive ancestor back events but rejects a stale close and divergent back', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios') + ->call('openConversation', 'c2') + ->call('openConversation', 'c3') + ->call('openConversation', 'c4'); + $event = ['expected_depth' => 3, 'expected_revision' => 3, 'expected_history' => ['conversation-1', 'conversation-2', 'conversation-3']]; + $screen->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 2])) + ->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 1])) + ->call('hostChanged', json_encode([...$event, 'action' => 'close'])); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + + $screen->call('openConversation', 'c5', [], true) + ->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 0])); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c5']); +}); + +it('settles a held reply once and updates the live detail or restores its rejected draft', function (bool $accepted) { + holdRefreshes(); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Reply here'])])], + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'body' => 'Reply here']))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + 'GET /api/v1/commits/c2/related' => [pageJson([])], + 'POST /api/v1/commits' => $accepted + ? [['data' => replyJson(['id' => 'r-real', 'parent_id' => 'c2', 'body' => 'Held reply'])], 201] + : [['message' => 'Reply rejected'], 422], + ]); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios'))->tap('commit-c2'); + finishRefreshes($screen)->input('reply-input', 'Held reply')->tap('send-reply')->assertSee('Held reply'); + expect(PendingWrite::query()->count())->toBe(1); + + finishRefreshes($screen)->assertSee('Reply here'); + expect(PendingWrite::query()->count())->toBe(0); + if ($accepted) { + expect(nodeByRef($screen, 'thread-reply-r-real'))->not->toBeNull(); + expect(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe(''); + } else { + $screen->assertSee('Held reply')->assertSee('stash@{0}'); + expect(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe('Held reply'); + } +})->with([true, false]); + +it('opens Edit for the already selected conversation without replacing its native identity', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')['id']; + $write = app(Outbox::class)->add(PendingWrite::Commit, null, ['body' => 'Recovered from Edit', 'parent_id' => 'c2', 'composer' => 'bar', 'stash' => 'reply-c2']); + app(Outbox::class)->failed($write, 'Try editing'); + + $screen->emitNative(ToastActionPressed::class, ['actionId' => 'edit', 'data' => ['write' => $write->id]]) + ->assertSee('Recovered from Edit')->assertSee('stash@{0}'); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')['id'])->toBe($detail); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + expect(PendingWrite::query()->count())->toBe(0); +}); + +it('recovers and resubmits a rejected feed composer through the selected pane', function (int $page, bool $quote) { + holdRefreshes(); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Browse during submission'])])], + 'GET /api/v1/log/latest' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Browse during submission'])])], + 'GET /api/v1/log/trending' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Browse during submission'])])], + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'body' => 'Browse during submission']))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + 'GET /api/v1/commits/c2/related' => [pageJson([])], + 'POST /api/v1/commits' => [['message' => 'Controlled rejection'], 422], + ]); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios')); + finishRefreshes($screen->changeTab('showPage', $page)); + if ($quote) { + $screen->tap('quote-c2'); + } else { + $screen->call('hostChanged', json_encode(['action' => 'compose'])); + } + $screen->input('sheet-body', 'Rejected feed draft')->tap('sheet-commit') + ->call('openConversation', 'c2', [], true); + + finishRefreshes($screen, newestFirst: true); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); + expect(findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet')['props']['visible'])->toBeTrue(); + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); + $screen->assertSee('Rejected feed draft'); + + serveApi([ + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'c-new', 'body' => 'Rejected feed draft'])], 201], + ]); + $screen->input('reply-input', 'Keep my unrelated inline draft'); + $commit = findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-commit'); + $screen->fireEvent('hostChanged', TestableComponent::EVENT_PRESS, ['callback_id' => $commit['on_press']]); + $staged = PendingWrite::query()->sole(); + expect(nodeByRef($screen, 'commit-'.PendingWrite::stagedId($staged->client_id)))->not->toBeNull(); + $screen->assertSee('Keep my unrelated inline draft'); + + finishRefreshes($screen); + expect(nodeByRef($screen, 'commit-c-new'))->not->toBeNull(); + expect(PendingWrite::query()->count())->toBe(0); + $screen->assertSee('Keep my unrelated inline draft'); +})->with([[0, false], [1, false], [2, false], [2, true]]); + +it('invalidates old native commands when a rejected sheet reopens after dismissal', function (string $action) { + holdRefreshes(); + serveWorkspace(); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios'))->tap('commit-c2'); + finishRefreshes($screen)->input('reply-input', 'Fast rejected reply')->tap('reply-expand')->tap('sheet-commit'); + $old = ['action' => $action, 'depth' => 0, 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']]; + serveApi(['POST /api/v1/commits' => [['message' => 'Fast rejection'], 422]]); + finishRefreshes($screen); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); + $sheet = findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet'); + expect($sheet['props']['visible'])->toBeFalse(); + + $screen->fireEvent('hostChanged', TestableComponent::EVENT_SHEET_DISMISS, ['callback_id' => $sheet['props']['on_dismiss']]) + ->call('hostChanged', json_encode($old)) + ->assertSee('Fast rejected reply')->assertNativeNotCalled('Dialog.Alert'); + + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + expect($screen->get('navigationRevision'))->toBe(2); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); + expect(findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet')['props']['visible'])->toBeTrue(); +})->with(['back', 'close', 'delete']); + +it('rejects old native commands after a newer same-conversation intent', function (string $action) { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios') + ->call('openConversation', 'c2') + ->call('openConversation', 'c3'); + $detail = findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')[1]['id']; + $event = ['action' => $action, 'depth' => 1, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']]; + + $screen->call('openConversation', 'c3', ['reply' => true])->call('hostChanged', json_encode($event)) + ->assertNativeNotCalled('Dialog.Alert'); + + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2', 'c3']); + expect(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')[1]['id'])->toBe($detail); + expect($screen->tree()['props']['intent_revision'])->toBe(3); + if ($action === 'delete') { + $screen->call('hostChanged', json_encode([...$event, 'expected_revision' => 3])) + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Delete this commit?'); + } +})->with(['back', 'close', 'delete']); + +it('synchronizes successful deletion from either pane without replacing the feed', function (bool $fromDetail) { + $deleted = false; + serveApi(function (Request $request) use (&$deleted) { + if ($request->method() === 'DELETE') { + $deleted = true; + + return Http::response(null, 204); + } + $commit = commitJson(['id' => 'c2', 'body' => 'Delete from either pane']); + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(str_ends_with($path, '/commits/c2') ? commitPageJson($commit) : pageJson(str_contains($path, '/log/') && ! $deleted ? [$commit] : [])); + }); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $feed = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'pager')['id']; + if ($fromDetail) { + $screen->call('hostChanged', json_encode(['action' => 'delete', 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']])); + } else { + $screen->press('delete-c2'); + } + $screen->emitNative(ButtonPressed::class, ['label' => 'Delete', 'id' => 'delete-commit'])->assertDontSee('Delete from either pane'); + + expect($screen->get('conversations'))->toBe([]); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'pager')['id'])->toBe($feed); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && str_ends_with($request->url(), '/commits/c2')); +})->with([true, false]); + +it('keeps held cross-pane stars and toast retry consistent before transport settles', function () { + holdRefreshes(); + $commit = commitJson(['id' => 'c2', 'body' => 'Star in either pane', 'author' => userJson(['id' => 'u2', 'handle' => 'ada'])]); + serveApi(function (Request $request) use ($commit) { + if ($request->method() !== 'GET') { + return Http::response(['message' => 'Try again'], 422); + } + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(str_ends_with($path, '/commits/c2') ? commitPageJson($commit) : pageJson(str_contains($path, '/log/') ? [$commit] : [])); + }); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios'))->tap('commit-c2'); + finishRefreshes($screen); + $starLabels = fn (): array => array_column(array_column(findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'star-c2'), 'props'), 'a11y_label'); + + $screen->tap('star-c2'); + expect($starLabels())->toBe(['Unstar commit, 1 star', 'Unstar commit, 1 star']); + $detailStar = findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'star-c2')[1]; + $screen->fireEvent('hostChanged', TestableComponent::EVENT_PRESS, ['callback_id' => $detailStar['on_press']]); + expect($starLabels())->toBe(['Star commit', 'Star commit']); + expect(PendingWrite::query()->orderBy('id')->pluck('kind')->all())->toBe([PendingWrite::Star, PendingWrite::Unstar]); + finishRefreshes($screen); + + $screen->emitNative(ToastActionPressed::class, ['actionId' => 'retry-star', 'data' => ['commit' => 'c2', 'starred' => true]]); + expect($starLabels())->toBe(['Unstar commit, 1 star', 'Unstar commit, 1 star']); + expect(PendingWrite::query()->count())->toBe(1); + finishRefreshes($screen); + expect($starLabels())->toBe(['Star commit', 'Star commit']); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 80975918..623e7061 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\File; use Native\Mobile\AsyncTask; use Native\Mobile\Support\AsyncTaskTransport; +use Native\Mobile\System; abstract class TestCase extends BaseTestCase { @@ -36,6 +37,7 @@ protected function setUp(): void protected function tearDown(): void { AsyncTask::clearFake(); + System::rememberPlatform(null); File::deleteDirectory(AsyncTaskTransport::directory()); parent::tearDown(); From 854f3b1f2b84d1f28b467bd80d6882b54b736e6e Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 6 Nov 2026 22:10:32 +0000 Subject: [PATCH 06/42] Fix Duo runtime layout after display transitions Recover malformed native tab-container layout after transitions, preserve retained-page geometry, and add unselected conversation guidance with background-only bottom coverage. Amp-Thread-ID: https://ampcode.com/threads/T-01a1ac67-a129-7066-90e3-bf362ac34436 Co-authored-by: Connor McManus --- .../resources/ios/AdaptiveFeedRenderer.swift | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index c823ece2..40ba0184 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -9,6 +9,10 @@ struct AdaptiveFeedRenderer: View { var body: some View { AdaptiveFeedHost(node: node) .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + themeStore.resolve(for: colorScheme).background + .ignoresSafeArea(.container, edges: .bottom) + } .environment(\.nativeUITheme, themeStore.resolve(for: colorScheme)) } } @@ -128,6 +132,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC private let primary = UINavigationController() private let secondary = UINavigationController() private let divider = UIView() + private let emptySelection = UILabel() private var pages: [Int: AdaptivePageController] = [:] private var latest: NativeUINode? private var inherited: EnvironmentValues? @@ -160,6 +165,12 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC navigation.didMove(toParent: self) } view.addSubview(divider) + emptySelection.text = "Select a commit to view its conversation" + emptySelection.textAlignment = .center + emptySelection.numberOfLines = 0 + emptySelection.adjustsFontForContentSizeCategory = true + emptySelection.isHidden = true + view.addSubview(emptySelection) secondary.view.isHidden = true divider.isHidden = true } @@ -178,6 +189,20 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC layoutColumns() } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + // Duo can leave its system tab bar covering the entire container after + // resizing. Ask the owning container to lay out outside the transition + // callback, without overriding its frames or retrying child layout. + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + DispatchQueue.main.async { [weak self] in + guard let self, self.viewIfLoaded?.window != nil, let tabs = self.tabBarController else { return } + tabs.view.setNeedsLayout() + tabs.view.layoutIfNeeded() + } + } + } + private func layoutColumns() { let bounds = view.bounds if wide == true { @@ -185,6 +210,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC primary.view.frame = CGRect(x: 0, y: 0, width: feedWidth, height: bounds.height) secondary.view.frame = CGRect(x: feedWidth + 1, y: 0, width: max(0, bounds.width - feedWidth - 1), height: bounds.height) divider.frame = CGRect(x: feedWidth, y: 0, width: 1, height: bounds.height) + emptySelection.frame = secondary.view.frame.insetBy(dx: 32, dy: 0) } else { primary.view.frame = bounds } @@ -195,8 +221,10 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC primary.transitionCoordinator == nil, secondary.transitionCoordinator == nil, let latest, let inherited, view.bounds.width > 0, view.bounds.height > 0, let feed = latest.children.first(where: { $0.type != "adaptive_detail" }) else { return } - let landscape = view.window?.windowScene?.interfaceOrientation.isLandscape ?? (view.bounds.width > view.bounds.height) - let wantsWide = view.bounds.width >= 820 && landscape + // The window shape survives keyboard resizing and does not depend on + // an interface orientation that may lag a Duo display switch. + let viewport = view.window?.bounds ?? view.bounds + let wantsWide = view.bounds.width >= 820 && viewport.width > viewport.height // Root-view assignment can schedule another UIKit layout. A bounds-only // layout in the same mode must not assign all hosting roots again. guard appliedRevision != revision || wide != wantsWide else { return } @@ -251,6 +279,12 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC secondary.setViewControllers([], animated: false) } wide = wantsWide + // Establish destination visibility and bounds before attaching retained + // hosting roots, rather than first mounting them at the old geometry. + secondary.view.isHidden = !wantsWide + divider.isHidden = !wantsWide + emptySelection.isHidden = !wantsWide || !detailPages.isEmpty + layoutColumns() expectedPrimary = primaryStack expectedSecondary = secondaryStack if !sameControllers(primary.viewControllers, primaryStack) { @@ -259,10 +293,9 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC if !sameControllers(secondary.viewControllers, secondaryStack) { secondary.setViewControllers(secondaryStack, animated: false) } + primary.view.layoutIfNeeded() + secondary.view.layoutIfNeeded() applying = false - secondary.view.isHidden = !wantsWide - divider.isHidden = !wantsWide - layoutColumns() let validIDs = Set([feed.id] + currentHistory) pages = pages.filter { validIDs.contains($0.key) } @@ -318,6 +351,10 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC let tokens = environment.nativeUITheme view.backgroundColor = UIColor(tokens.background) divider.backgroundColor = UIColor(tokens.outlineVariant) + emptySelection.textColor = UIColor(tokens.onSurfaceVariant) + let font = NativeChromeFontResolver.postScriptName(for: "default") + .flatMap { UIFont(name: $0, size: 15) } ?? UIFont.systemFont(ofSize: 15) + emptySelection.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: font) for navigation in [primary, secondary] { navigation.navigationBar.tintColor = UIColor(tokens.onSurface) } From 6eddad71d86d5c1c1c58a33dc3e195bad3d807da Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 7 Nov 2026 00:02:25 +0000 Subject: [PATCH 07/42] Extend Duo feed content beneath bottom safe area Preserve keyboard avoidance and retained control insets while allocating the full container to the adaptive workspace. Amp-Thread-ID: https://ampcode.com/threads/T-01a1ac67-a129-7066-90e3-bf362ac34436 Co-authored-by: Connor McManus --- .../adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 40ba0184..233fdf8a 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -9,6 +9,9 @@ struct AdaptiveFeedRenderer: View { var body: some View { AdaptiveFeedHost(node: node) .frame(maxWidth: .infinity, maxHeight: .infinity) + // Allocate the full container to UIKit; retained navigation/page + // controllers still inset controls and extend scrolling content. + .ignoresSafeArea(.container, edges: .bottom) .background { themeStore.resolve(for: colorScheme).background .ignoresSafeArea(.container, edges: .bottom) From 153365d12fbadb9713fc2d1ed53a25b4a0e532a7 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 18:17:52 -0800 Subject: [PATCH 08/42] Preserve Duo navigation chrome and composer clearance --- app/NativeComponents/CommitDetail.php | 2 +- app/NativeComponents/Concerns/SyncsOutbox.php | 3 +- app/NativeComponents/FeedWorkspace.php | 3 + config/native-ui.php | 2 + packages/adaptive-layout/nativephp.json | 6 + .../resources/ios/AdaptiveFeedRenderer.swift | 141 ++++++++++++++++-- .../views/native/commit-detail.blade.php | 6 +- tests/Feature/Screens/FeedWorkspaceTest.php | 19 +++ 8 files changed, 162 insertions(+), 20 deletions(-) diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index 56651b03..6d85dc84 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -174,7 +174,7 @@ class CommitDetail extends NativeComponent */ protected bool $forceFullFrames = true; - public function data(string $key, $default = null): mixed + public function data(string $key, mixed $default = null): mixed { return $this->embedded ? ($this->conversationData[$key] ?? $default) : parent::data($key, $default); } diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php index 6ebf80fa..5182190b 100644 --- a/app/NativeComponents/Concerns/SyncsOutbox.php +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -21,6 +21,7 @@ use App\NativeComponents\FeedWorkspace; use App\NativeComponents\Log; use Native\Mobile\Attributes\On; +use Native\Mobile\Edge\NativeComponent; use Native\Mobile\Facades\Browser; use Throwable; use Victorycodedev\ToastKit\Events\ToastActionPressed; @@ -360,7 +361,7 @@ public function applyStagedCommit(Commit $commit): void private function outboxViews(): array { return $this->nativeParentComponent instanceof FeedWorkspace - ? array_values(array_filter($this->nativeParentComponent->nativeChildComponents, fn ($child): bool => $child instanceof Log || $child instanceof CommitDetail)) + ? array_values(array_filter($this->nativeParentComponent->nativeChildComponents, fn (NativeComponent $child): bool => $child instanceof Log || $child instanceof CommitDetail)) : [$this]; } diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php index 059bcbaa..a227e00b 100644 --- a/app/NativeComponents/FeedWorkspace.php +++ b/app/NativeComponents/FeedWorkspace.php @@ -130,6 +130,9 @@ public function hostChanged(string $event): void case 'search': $this->feed()?->openSearch(); break; + case 'game': + $this->feed()?->playNataNullers(); + break; case 'delete': $current = end($this->conversations); if ($current !== false) { diff --git a/config/native-ui.php b/config/native-ui.php index 171b025b..edd8d940 100644 --- a/config/native-ui.php +++ b/config/native-ui.php @@ -62,6 +62,7 @@ 'on-surface-variant' => '#5b616b', 'outline' => '#d3d7dd', + 'outline-variant' => '#e1e4e8', // Rm Red. 'destructive' => '#c92a2a', @@ -130,6 +131,7 @@ 'on-surface-variant' => '#a1a1a6', 'outline' => '#36363a', + 'outline-variant' => '#2a2a2d', 'destructive' => '#ff6b6b', 'on-destructive' => '#111112', diff --git a/packages/adaptive-layout/nativephp.json b/packages/adaptive-layout/nativephp.json index 7ba42057..f12de102 100644 --- a/packages/adaptive-layout/nativephp.json +++ b/packages/adaptive-layout/nativephp.json @@ -1,6 +1,12 @@ { "namespace": "AdaptiveLayout", "platforms": ["ios"], + "android": { + "min_version": 26 + }, + "ios": { + "min_version": "18.2" + }, "components": [ { "type": "adaptive_feed", diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 233fdf8a..f8e26d72 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -9,12 +9,15 @@ struct AdaptiveFeedRenderer: View { var body: some View { AdaptiveFeedHost(node: node) .frame(maxWidth: .infinity, maxHeight: .infinity) - // Allocate the full container to UIKit; retained navigation/page - // controllers still inset controls and extend scrolling content. - .ignoresSafeArea(.container, edges: .bottom) + // UIKit owns vertical safe areas. Insetting this host at the top + // as well would reserve the header space twice in portrait. + // Retained pages still inset controls and scrolling content. + .ignoresSafeArea(.container, edges: .vertical) .background { - themeStore.resolve(for: colorScheme).background - .ignoresSafeArea(.container, edges: .bottom) + // Backgrounds fill the rail region; readable rows and controls + // continue to use the tab host's asymmetric safe area. + themeStore.resolve(for: colorScheme).surface + .ignoresSafeArea(.container, edges: .all) } .environment(\.nativeUITheme, themeStore.resolve(for: colorScheme)) } @@ -45,12 +48,17 @@ private struct AdaptiveFeedHost: UIViewControllerRepresentable { } } +private final class AdaptivePaneInsets: ObservableObject { + @Published var bottom: CGFloat = 0 +} + /// A stable root type for every retained hosting controller. Only the direct /// bottom_bar sentinel is removed; all native node ids and ancestors survive. private struct AdaptiveNodeRoot: View { let content: NativeUINode let bottom: NativeUINode? let inherited: EnvironmentValues + @ObservedObject var paneInsets: AdaptivePaneInsets var body: some View { GeometryReader { geometry in @@ -59,7 +67,7 @@ private struct AdaptiveNodeRoot: View { .equatable() .environment(\.availableHeight, contentGeometry.size.height) } - .modifier(AdaptiveBottomInset(bottom: bottom)) + .modifier(AdaptiveBottomInset(bottom: bottom, clearance: max(0, paneInsets.bottom - geometry.safeAreaInsets.bottom))) .environment(\.availableWidth, geometry.size.width) .environment(\.availableHeight, geometry.size.height) .environment(\.nativeSafeAreaTop, geometry.safeAreaInsets.top) @@ -76,6 +84,7 @@ private struct AdaptiveNodeRoot: View { private struct AdaptiveBottomInset: ViewModifier { let bottom: NativeUINode? + let clearance: CGFloat @Environment(\.colorScheme) private var colorScheme func body(content: Content) -> some View { @@ -87,6 +96,7 @@ private struct AdaptiveBottomInset: ViewModifier { .equatable() .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) + .padding(.bottom, clearance) .background((background == 0 ? Color.clear : Color(argb: background)) .ignoresSafeArea(edges: .bottom)) } @@ -96,7 +106,14 @@ private struct AdaptiveBottomInset: ViewModifier { } } -private final class AdaptivePageController: UIHostingController { +/// UIKit owns the navigation item; SwiftUI only owns the retained page content. +/// A hosting controller used directly as a navigation destination can replace +/// its title item when it moves between the two navigation controllers. +private final class AdaptivePageController: UIViewController { + private let hosting: UIHostingController + private let paneInsets: AdaptivePaneInsets + private var requestedBottomInset: CGFloat = 0 + var onSafeAreaChange: (() -> Void)? private(set) var source: NativeUINode private var contentNode: NativeUINode private var bottomNode: NativeUINode? @@ -109,14 +126,48 @@ private final class AdaptivePageController: UIHostingController 0.5 else { return } + requestedBottomInset = inset + // UIKit may be laying out during a SwiftUI update. Publish after + // that pass, and discard obsolete measurements from a transition. + DispatchQueue.main.async { [weak self] in + guard let self, self.requestedBottomInset == inset else { return } + self.paneInsets.bottom = inset + } + } + func update(_ node: NativeUINode, detail: Bool, environment: EnvironmentValues) { if source !== node { source = node @@ -127,10 +178,16 @@ private final class AdaptivePageController: UIHostingController CGSize { intrinsicContentSize } +} + private final class AdaptiveWorkspaceController: UIViewController, UINavigationControllerDelegate { private let primary = UINavigationController() private let secondary = UINavigationController() @@ -190,6 +247,12 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC super.viewDidLayoutSubviews() reconcile() layoutColumns() + updateBottomBarClearance() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + view.setNeedsLayout() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -202,10 +265,37 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC guard let self, self.viewIfLoaded?.window != nil, let tabs = self.tabBarController else { return } tabs.view.setNeedsLayout() tabs.view.layoutIfNeeded() + self.view.setNeedsLayout() } } } + private func updateBottomBarClearance() { + var bottomBar: UIView? + if let bar = tabBarController?.tabBar, !bar.isHidden, bar.alpha > 0, + bar.window === view.window { + let overlap = view.bounds.intersection(bar.convert(bar.bounds, to: view)) + // Only the horizontal bottom bar obstructs the reply field. A + // side rail is already excluded by the outer SwiftUI tab host. + // Reject the full-window tab-bar frame seen during Duo resizing. + if !overlap.isNull, overlap.width > overlap.height, + overlap.height < view.bounds.height / 2 { + bottomBar = bar + } + } + for navigation in [primary, secondary] { + guard let page = navigation.topViewController as? AdaptivePageController, + page.isViewLoaded, page.view.window === view.window else { continue } + let required = bottomBar.map { bar in + max(0, page.view.bounds.maxY - bar.convert(bar.bounds, to: page.view).minY) + } ?? 0 + // SwiftUI subtracts its own live safe area from this reservation. + // Updating UIKit's additional insets is insufficient after a page + // moves: its hosting view can keep the previous parent's insets. + page.reserveBottom(required) + } + } + private func layoutColumns() { let bounds = view.bounds if wide == true { @@ -265,10 +355,11 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC if let page = pages[node.id] { page.update(node, detail: detail, environment: inherited) } else { - pages[node.id] = AdaptivePageController(node: node, detail: detail, environment: inherited) + let page = AdaptivePageController(node: node, detail: detail, environment: inherited) + page.onSafeAreaChange = { [weak self] in self?.view.setNeedsLayout() } + pages[node.id] = page } } - configureBars(feed: feed, details: details, environment: inherited, wide: wantsWide) guard let feedPage = pages[feed.id] else { return } let detailPages: [UIViewController] = activeDetails.compactMap { pages[$0.id] } let primaryStack: [UIViewController] = wantsWide ? [feedPage] : [feedPage] + detailPages @@ -296,6 +387,14 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC if !sameControllers(secondary.viewControllers, secondaryStack) { secondary.setViewControllers(secondaryStack, animated: false) } + if migrating { + // Removing an item from its old bar can detach the title view + // after it has been assigned to the destination. Rebuild only + // the chrome, once both retained stacks have their new parents. + logoName = nil + for page in pages.values { page.navigationItem.titleView = nil } + } + configureBars(feed: feed, details: details, environment: inherited, wide: wantsWide) primary.view.layoutIfNeeded() secondary.view.layoutIfNeeded() applying = false @@ -352,7 +451,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC private func configureBars(feed: NativeUINode, details: [NativeUINode], environment: EnvironmentValues, wide: Bool) { let tokens = environment.nativeUITheme - view.backgroundColor = UIColor(tokens.background) + view.backgroundColor = UIColor(tokens.surface) divider.backgroundColor = UIColor(tokens.outlineVariant) emptySelection.textColor = UIColor(tokens.onSurfaceVariant) let font = NativeChromeFontResolver.postScriptName(for: "default") @@ -363,15 +462,24 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC } guard let feedPage = pages[feed.id] else { return } feedPage.title = "Feed" + if #available(iOS 27.2, *) { + feedPage.navigationItem.titleAlignment = .leading + } let name = environment.colorScheme == .dark ? "logo-dark@3x.png" : "logo-light@3x.png" if name != logoName { logoName = name if let image = UIImage(contentsOfFile: AppUpdateManager.shared.getAppPath() + "/public/images/" + name) { - let imageView = UIImageView(image: image) + let imageView = AdaptiveWordmarkView(image: image) imageView.contentMode = .scaleAspectFit - imageView.frame = CGRect(x: 0, y: 0, width: 120, height: 28) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageView.intrinsicContentSize.width), + imageView.heightAnchor.constraint(equalToConstant: imageView.intrinsicContentSize.height), + ]) imageView.isAccessibilityElement = true imageView.accessibilityLabel = "devnull" + imageView.isUserInteractionEnabled = true + imageView.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(AdaptiveWorkspaceController.playGame(_:)))) logoView = imageView } else { logoView = nil @@ -428,6 +536,9 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC @objc private func compose() { send("compose") } @objc private func search() { send("search") } + @objc private func playGame(_ gesture: UILongPressGestureRecognizer) { + if gesture.state == .began { send("game") } + } @objc private func close() { send("close") } @objc private func deleteConversation() { send("delete") } diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index c9408c75..eac60027 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -105,11 +105,11 @@ @endif {{-- - A pushed screen runs under its bottom bar, so the page clears the reply bar itself (see - Display::replyBarClearance): the last reply and its actions end above the bar, keyboard up or down. + A pushed screen clears its overlaid reply bar itself (see Display::replyBarClearance). + The embedded workspace reserves the actual bar height natively, so it only needs the final gap. --}} @if ($commit) - + @endif diff --git a/tests/Feature/Screens/FeedWorkspaceTest.php b/tests/Feature/Screens/FeedWorkspaceTest.php index 92c74ecc..3dae8801 100644 --- a/tests/Feature/Screens/FeedWorkspaceTest.php +++ b/tests/Feature/Screens/FeedWorkspaceTest.php @@ -1,5 +1,6 @@ assertSee('Browsable feed')->assertDontSee('Conversation c4'); }); +it('opens the existing game from the retained feed logo when it is installed', function () { + skipWithoutSkia(); + serveWorkspace(); + + Native::test(FeedWorkspace::class, platform: 'ios') + ->call('hostChanged', json_encode(['action' => 'game'])) + ->assertNavigatedTo(Game::Route); +}); + +it('ignores the retained logo shortcut when the game is disabled', function () { + config(['devnull.nata_nullers' => false]); + serveWorkspace(); + + Native::test(FeedWorkspace::class, platform: 'ios') + ->call('hostChanged', json_encode(['action' => 'game'])) + ->assertNoNavigation(); +}); + it('applies held child refreshes and stashes the selected conversation draft', function () { serveWorkspace(); holdRefreshes(); From 07c5580cb082126712a6bf85ddb5984da5befaf6 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 18:32:46 -0800 Subject: [PATCH 09/42] Enable iPhone landscape and fill the profile viewport --- config/nativephp.php | 4 ++-- resources/views/native/profile.blade.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/nativephp.php b/config/nativephp.php index 2e38a8c5..9b975365 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -477,8 +477,8 @@ 'iphone' => [ 'portrait' => true, 'upside_down' => false, - 'landscape_left' => false, - 'landscape_right' => false, + 'landscape_left' => true, + 'landscape_right' => true, ], 'android' => [ 'portrait' => true, diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index a24ffb0f..a2b42920 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -10,7 +10,7 @@ The pager or the list stays alone in here, so the banners above coming and going never add a node beside it. Keyed so nothing above it, like the image viewer opening, gives it a new id and the pager a new list at the top. --}} - + @if ($profile && ! $unavailable) @php($user = $profile->user) {{-- @@ -27,7 +27,7 @@ ref="profile-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }}" + class="w-full flex-1 bg-theme-surface" :count="count($pages)" :page="$pageIndex" on-page-change="showPage" @@ -53,7 +53,7 @@ class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }} @else - + @if ($unavailable) @include('native.partials.profile-unavailable') @elseif ($error === null) From f2ff64ec3177dc1946d2dc70917713f1d20ab173 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 18:51:15 -0800 Subject: [PATCH 10/42] Keep nested navigation controls tappable after Duo rotation --- composer.json | 3 ++- composer.lock | 2 +- ...nativephp-mobile-hidden-nav-hit-area.patch | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 patches/nativephp-mobile-hidden-nav-hit-area.patch diff --git a/composer.json b/composer.json index f7a0d62e..09456f27 100644 --- a/composer.json +++ b/composer.json @@ -123,7 +123,8 @@ "composer-exit-on-patch-failure": true, "patches": { "nativephp/mobile": { - "Async callbacks retain their exact live child receiver within the dispatch root": "patches/nativephp-mobile-live-child-async.patch" + "Async callbacks retain their exact live child receiver within the dispatch root": "patches/nativephp-mobile-live-child-async.patch", + "Hidden navigation bars release the top layout and hit region for native child hosts": "patches/nativephp-mobile-hidden-nav-hit-area.patch" }, "nativephp/mobile-ui": { "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch" diff --git a/composer.lock b/composer.lock index 69665d86..9dc28273 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d559b057403966c564c40db84ed1c403", + "content-hash": "7ccfe76ed47a4e12a171397ada6d01a6", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/patches/nativephp-mobile-hidden-nav-hit-area.patch b/patches/nativephp-mobile-hidden-nav-hit-area.patch new file mode 100644 index 00000000..b3334586 --- /dev/null +++ b/patches/nativephp-mobile-hidden-nav-hit-area.patch @@ -0,0 +1,27 @@ +--- a/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift ++++ b/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift +@@ -968,9 +968,10 @@ + /// opted out via PHP-side `$hidesNavBar` / `navigationOptions()->hidden()`, + /// folded onto the chrome sentinel as `hide_nav_bar`. The enclosing + /// `NavigationStack` survives — push / pop and per-URI caching keep +-/// working; only the toolbar chrome disappears. Unlike the tab bar, +-/// hiding the nav bar releases its space automatically, and the +-/// status-bar safe area stays intact. ++/// working; only the toolbar chrome disappears. Hidden-bar screens own ++/// their top safe area. Extend the destination's layout and hit region ++/// together so a nested native navigation bar remains tappable after a ++/// resize, rather than only drawing above an inset parent. + /// + /// Note: SwiftUI disables the interactive edge-swipe-back gesture while + /// the bar is hidden — screens that hide it on a pushed level should +@@ -985,7 +986,9 @@ + + func body(content: Content) -> some View { + if hidden { +- content.toolbar(.hidden, for: .navigationBar) ++ content ++ .toolbar(.hidden, for: .navigationBar) ++ .ignoresSafeArea(.container, edges: .top) + } else { + content + } From 6c3a75b3a6d5dc41bae6511b72bc7c32e0977c34 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 20:29:02 -0800 Subject: [PATCH 11/42] Encapsulate retained panes and cover photo event ownership --- app/NativeComponents/Concerns/SyncsOutbox.php | 3 +- app/NativeComponents/FeedWorkspace.php | 9 +++ tests/Feature/Screens/FeedWorkspaceTest.php | 63 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php index 5182190b..50b700eb 100644 --- a/app/NativeComponents/Concerns/SyncsOutbox.php +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -21,7 +21,6 @@ use App\NativeComponents\FeedWorkspace; use App\NativeComponents\Log; use Native\Mobile\Attributes\On; -use Native\Mobile\Edge\NativeComponent; use Native\Mobile\Facades\Browser; use Throwable; use Victorycodedev\ToastKit\Events\ToastActionPressed; @@ -361,7 +360,7 @@ public function applyStagedCommit(Commit $commit): void private function outboxViews(): array { return $this->nativeParentComponent instanceof FeedWorkspace - ? array_values(array_filter($this->nativeParentComponent->nativeChildComponents, fn (NativeComponent $child): bool => $child instanceof Log || $child instanceof CommitDetail)) + ? $this->nativeParentComponent->panes() : [$this]; } diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php index a227e00b..6b2d6955 100644 --- a/app/NativeComponents/FeedWorkspace.php +++ b/app/NativeComponents/FeedWorkspace.php @@ -22,6 +22,15 @@ class FeedWorkspace extends NativeComponent private ?NativeComponent $photoOwner = null; + /** @return list */ + public function panes(): array + { + return array_values(array_filter( + $this->nativeChildComponents, + fn (NativeComponent $child): bool => $child instanceof Log || $child instanceof CommitDetail, + )); + } + public function claimPhotoPicker(NativeComponent $owner): void { $this->photoOwner = $owner; diff --git a/tests/Feature/Screens/FeedWorkspaceTest.php b/tests/Feature/Screens/FeedWorkspaceTest.php index 3dae8801..8cc801fc 100644 --- a/tests/Feature/Screens/FeedWorkspaceTest.php +++ b/tests/Feature/Screens/FeedWorkspaceTest.php @@ -1,13 +1,18 @@ toBe(['Star commit', 'Star commit']); }); + +it('attaches a photo only to the pane that opened the picker', function (bool $fromDetail, bool $fromCamera) { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $workspace = $screen->instance(); + $feed = array_find($workspace->panes(), fn (Log|CommitDetail $pane): bool => $pane instanceof Log); + $detail = $workspace->foregroundPane(); + $feed->composeInSheet(); + $detail->replyInSheet('c2'); + $owner = $fromDetail ? $detail : $feed; + $other = $fromDetail ? $feed : $detail; + + if ($fromCamera) { + $owner->takePhoto(); + $screen->emitNative(PhotoTaken::class, ['path' => testPhoto()]); + } else { + $owner->pickPhotos(); + photosPicked($screen, [testPhoto()]); + } + + try { + expect($owner->sheetImages)->toHaveCount(1) + ->and($other->sheetImages)->toBe([]) + ->and($other->sheetError)->toBeNull(); + } finally { + app(DraftImages::class)->discard($owner->sheetImages); + } +})->with([[false, false], [false, true], [true, false], [true, true]]); + +it('ignores a detached conversation picker result rather than attaching it to another pane', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $screen->instance()->foregroundPane()->replyInSheet('c2'); + $screen->instance()->foregroundPane()->pickPhotos(); + + $screen->call('openConversation', 'c3', [], true); + $screen->instance()->foregroundPane()->replyInSheet('c3'); + photosPicked($screen, [testPhoto()]); + + foreach ($screen->instance()->panes() as $pane) { + expect($pane->sheetImages)->toBe([]); + } +}); + +it('shows a camera permission error only on the pane that requested it', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $workspace = $screen->instance(); + $feed = array_find($workspace->panes(), fn (Log|CommitDetail $pane): bool => $pane instanceof Log); + $feed->composeInSheet(); + $workspace->foregroundPane()->replyInSheet('c2'); + $feed->takePhoto(); + + $screen->emitNative(PermissionDenied::class, ['action' => 'camera']); + + expect($feed->sheetError)->toBe(DraftImages::CameraDenied) + ->and($workspace->foregroundPane()->sheetError)->toBeNull(); +}); From f2e38d0d3475e93aad00af3e233de69a8c38b29b Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 20:48:31 -0800 Subject: [PATCH 12/42] Resume retained panes after native back and close --- app/NativeComponents/FeedWorkspace.php | 6 ++- tests/Feature/Screens/FeedWorkspaceTest.php | 59 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php index 6b2d6955..2788ad00 100644 --- a/app/NativeComponents/FeedWorkspace.php +++ b/app/NativeComponents/FeedWorkspace.php @@ -128,10 +128,14 @@ public function hostChanged(string $event): void $depth = $change['depth'] ?? count($this->conversations) - 1; if (is_int($depth) && $depth >= 0 && $depth < count($this->conversations)) { $this->conversations = array_slice($this->conversations, 0, $depth); + $this->foregroundPane()?->onResume(); } break; case 'close': - $this->conversations = []; + if ($this->conversations !== []) { + $this->conversations = []; + $this->feed()?->onResume(); + } break; case 'compose': $this->feed()?->composeInSheet(); diff --git a/tests/Feature/Screens/FeedWorkspaceTest.php b/tests/Feature/Screens/FeedWorkspaceTest.php index 8cc801fc..0ae7f173 100644 --- a/tests/Feature/Screens/FeedWorkspaceTest.php +++ b/tests/Feature/Screens/FeedWorkspaceTest.php @@ -10,9 +10,11 @@ use App\NativeComponents\Log; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Native\Mobile\Edge\NativeRouter; use Native\Mobile\Events\Alert\ButtonPressed; use Native\Mobile\Events\Camera\PermissionDenied; use Native\Mobile\Events\Camera\PhotoTaken; +use Native\Mobile\System; use Native\Mobile\Testing\Native; use Native\Mobile\Testing\TestableComponent; use Victorycodedev\ToastKit\Events\ToastActionPressed; @@ -39,6 +41,21 @@ function serveWorkspace(): void }); } +it('selects the native Log route for the platform present at application boot', function (string $platform, string $component) { + try { + System::rememberPlatform($platform); + $this->refreshApplication(); + + expect(NativeRouter::resolve('/log')['class'])->toBe($component); + } finally { + System::rememberPlatform(null); + $this->refreshApplication(); + } +})->with([ + 'iOS retained workspace' => ['ios', FeedWorkspace::class], + 'Android feed' => ['android', Log::class], +]); + it('keeps the feed live beside conversation history and ignores obsolete native back', function () { serveWorkspace(); $screen = Native::test(FeedWorkspace::class, platform: 'ios')->assertSee('Browsable feed'); @@ -69,6 +86,48 @@ function serveWorkspace(): void ->assertNavigatedTo(Game::Route); }); +it('refreshes the retained destination after native back without losing its draft', function () { + $body = 'Parent before opening another conversation'; + serveApi(function (Request $request) use (&$body) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(match ($path) { + '/api/v1/commits/c2' => commitPageJson(commitJson(['id' => 'c2', 'body' => $body])), + '/api/v1/commits/c3' => commitPageJson(commitJson(['id' => 'c3', 'body' => 'Deeper conversation'])), + default => pageJson([]), + }); + }); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->call('openConversation', 'c2'); + $parent = $screen->instance()->foregroundPane(); + $screen->input('reply-input', 'Keep this parent draft')->call('openConversation', 'c3'); + $body = 'Parent refreshed after returning'; + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 1, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']])) + ->assertSee('Parent refreshed after returning')->assertSee('Keep this parent draft'); + + expect($screen->instance()->foregroundPane())->toBe($parent); +}); + +it('refreshes the live feed when closing its selected conversation', function () { + $body = 'Feed before opening a conversation'; + serveApi(function (Request $request) use (&$body) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(match ($path) { + '/api/v1/log/following' => pageJson([commitJson(['id' => 'c2', 'body' => $body])]), + '/api/v1/commits/c2' => commitPageJson(commitJson(['id' => 'c2', 'body' => 'Selected conversation'])), + default => pageJson([]), + }); + }); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $body = 'Feed refreshed after closing'; + + $screen->call('hostChanged', json_encode(['action' => 'close', 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']])) + ->assertSee('Feed refreshed after closing'); + + expect($screen->get('conversations'))->toBe([]); +}); + it('ignores the retained logo shortcut when the game is disabled', function () { config(['devnull.nata_nullers' => false]); serveWorkspace(); From 56a57fab9fd8a1969d7290c4c8de40de282ee279 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 20:57:56 -0800 Subject: [PATCH 13/42] Align retained navigation actions with the pane safe area --- .../resources/ios/AdaptiveFeedRenderer.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index f8e26d72..1d142409 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -188,9 +188,22 @@ private final class AdaptiveWordmarkView: UIImageView { override func sizeThatFits(_ size: CGSize) -> CGSize { intrinsicContentSize } } +/// The outer tab host already removes the rail from each pane. Limit the +/// bar's horizontal safe area to what actually overlaps its parent container. +private final class AdaptiveNavigationBar: UINavigationBar { + override var safeAreaInsets: UIEdgeInsets { + var insets = super.safeAreaInsets + guard let superview else { return insets } + let safeFrame = convert(superview.safeAreaLayoutGuide.layoutFrame, from: superview) + insets.left = max(0, min(bounds.width, safeFrame.minX - bounds.minX)) + insets.right = max(0, min(bounds.width, bounds.maxX - safeFrame.maxX)) + return insets + } +} + private final class AdaptiveWorkspaceController: UIViewController, UINavigationControllerDelegate { - private let primary = UINavigationController() - private let secondary = UINavigationController() + private let primary = UINavigationController(navigationBarClass: AdaptiveNavigationBar.self, toolbarClass: nil) + private let secondary = UINavigationController(navigationBarClass: AdaptiveNavigationBar.self, toolbarClass: nil) private let divider = UIView() private let emptySelection = UILabel() private var pages: [Int: AdaptivePageController] = [:] From 1188dedad554ea79d90f31c710153a909854fa18 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 21:10:08 -0800 Subject: [PATCH 14/42] Deliver retained pane state during sheet dismissal --- .../resources/ios/AdaptiveFeedRenderer.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 1d142409..a2a2c4c5 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -253,6 +253,12 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC inherited = environment revision &+= 1 guard isViewLoaded else { return } + // Deliver state even during transitions: a sheet's dismissed false + // must reach its retained root before the next request to show it. + // Only navigation-stack mutations wait for reconciliation below. + for child in node.children { + pages[child.id]?.update(child, detail: child.type == "adaptive_detail", environment: environment) + } reconcile() } @@ -365,9 +371,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC let focused = migrating ? view.adaptiveFirstResponder() : nil for (node, detail) in [(feed, false)] + details.map({ ($0, true) }) { - if let page = pages[node.id] { - page.update(node, detail: detail, environment: inherited) - } else { + if pages[node.id] == nil { let page = AdaptivePageController(node: node, detail: detail, environment: inherited) page.onSafeAreaChange = { [weak self] in self?.view.setNeedsLayout() } pages[node.id] = page From ea3f91a95ab1c3b6c51de53c613c0e0bbd30067e Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 21:11:55 -0800 Subject: [PATCH 15/42] Keep search separators and notification filters inside the rail --- patches/nativephp-mobile-ui-pager-side-insets.patch | 10 +++++++++- resources/views/native/search.blade.php | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/patches/nativephp-mobile-ui-pager-side-insets.patch b/patches/nativephp-mobile-ui-pager-side-insets.patch index 78334f17..7ceeee79 100644 --- a/patches/nativephp-mobile-ui-pager-side-insets.patch +++ b/patches/nativephp-mobile-ui-pager-side-insets.patch @@ -432,7 +432,15 @@ withAnimation { position = index } } -@@ -941,3 +1237,122 @@ +@@ -476,6 +772,7 @@ + row(theme) + .padding(.horizontal, tabs.edgePadding) + } ++ .clipShape(PagerSideClip(horizontal: true)) + .scrollIndicators(.hidden) + .scrollPosition($rowPosition) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { viewportWidth = $0 } +@@ -941,3 +1238,122 @@ } } } diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php index fe094cc8..3f380e97 100644 --- a/resources/views/native/search.blade.php +++ b/resources/views/native/search.blade.php @@ -12,7 +12,7 @@ @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => true, 'a11yLabel' => 'Search developers or commits']) - + @if ($term === '') From b143b2013e77817df57c3b43670456366c4ae2c1 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 21:22:31 -0800 Subject: [PATCH 16/42] Use a consistent themed surface for iOS navigation headers --- app/NativeComponents/Layouts/TabsLayout.php | 5 +++++ .../adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 4b7ce13d..6d6004bc 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -13,6 +13,7 @@ use Native\Mobile\Edge\Layouts\Builders\TabBar; use Native\Mobile\Edge\Layouts\NativeLayout; use Native\Mobile\Edge\NativeComponent; +use Native\Mobile\Facades\System; /** * The top-level sections: Log, Notifications, Remotes and your Profile, with @@ -63,6 +64,10 @@ public function navBar(NativeComponent $screen): ?NavBar ->title($screen->navTitle()) ->displayMode('inline'); + if (System::isIos()) { + $navBar->backgroundColor((string) theme('surface')); + } + if ($screen instanceof Log) { $logo = Image::make(self::logo())->height(28)->alt('devnull')->ref('wordmark'); diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index a2a2c4c5..752c6232 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -474,8 +474,13 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC let font = NativeChromeFontResolver.postScriptName(for: "default") .flatMap { UIFont(name: $0, size: 15) } ?? UIFont.systemFont(ofSize: 15) emptySelection.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: font) + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = UIColor(tokens.surface) for navigation in [primary, secondary] { navigation.navigationBar.tintColor = UIColor(tokens.onSurface) + navigation.navigationBar.standardAppearance = appearance + navigation.navigationBar.scrollEdgeAppearance = appearance } guard let feedPage = pages[feed.id] else { return } feedPage.title = "Feed" From b32201b8a7644224e0209e674a6b243cd71d4bf8 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Fri, 6 Nov 2026 21:32:24 -0800 Subject: [PATCH 17/42] Keep native separators within their content bounds --- resources/views/native/add-org-members.blade.php | 4 ++-- resources/views/native/conversation.blade.php | 2 +- resources/views/native/new-org.blade.php | 4 ++-- resources/views/native/new-remote.blade.php | 2 +- resources/views/native/org-members.blade.php | 4 ++-- .../views/native/partials/achievements-sheet.blade.php | 4 ++-- resources/views/native/partials/achievements.blade.php | 2 +- resources/views/native/partials/activity.blade.php | 2 +- resources/views/native/partials/commit-sheet.blade.php | 8 ++++---- .../views/native/partials/commit-tombstone.blade.php | 2 +- .../views/native/partials/connection-loading.blade.php | 2 +- resources/views/native/partials/connection.blade.php | 2 +- resources/views/native/partials/day-marker.blade.php | 2 +- resources/views/native/partials/earlier-commits.blade.php | 2 +- resources/views/native/partials/following-empty.blade.php | 2 +- .../views/native/partials/last-visit-marker.blade.php | 4 ++-- .../views/native/partials/live-stream-sheet.blade.php | 2 +- resources/views/native/partials/notification.blade.php | 2 +- .../views/native/partials/organization-header.blade.php | 2 +- resources/views/native/partials/pinned-bar.blade.php | 2 +- resources/views/native/partials/pinned-sheet.blade.php | 2 +- resources/views/native/partials/poll-editor.blade.php | 2 +- resources/views/native/partials/profile-header.blade.php | 2 +- resources/views/native/partials/push-prompt.blade.php | 2 +- resources/views/native/partials/rail-loading.blade.php | 2 +- resources/views/native/partials/refresh-failed.blade.php | 2 +- .../views/native/partials/remote-row-loading.blade.php | 2 +- resources/views/native/partials/remote-row.blade.php | 2 +- resources/views/native/partials/streaks.blade.php | 2 +- resources/views/native/partials/thread-reply.blade.php | 4 ++-- resources/views/native/partials/timezone-sheet.blade.php | 2 +- resources/views/native/partials/who-to-follow.blade.php | 4 ++-- resources/views/native/safety.blade.php | 4 ++-- resources/views/native/scheduled-commits.blade.php | 6 +++--- resources/views/native/starred.blade.php | 2 +- resources/views/native/this-device.blade.php | 6 +++--- 36 files changed, 51 insertions(+), 51 deletions(-) diff --git a/resources/views/native/add-org-members.blade.php b/resources/views/native/add-org-members.blade.php index 384211e7..70a92749 100644 --- a/resources/views/native/add-org-members.blade.php +++ b/resources/views/native/add-org-members.blade.php @@ -14,7 +14,7 @@ @include('native.partials.people-search', ['placeholder' => 'Find people by name or @handle', 'autofocus' => true]) @include('native.partials.picked-people') - + @include('native.partials.people-results') @@ -24,7 +24,7 @@ - + Share history diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index c01b14b7..9d466716 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -81,7 +81,7 @@ --}} - + @if ($canPush) {{-- The members to mention, what you're replying to and the photos on the draft each keep a slot, diff --git a/resources/views/native/new-org.blade.php b/resources/views/native/new-org.blade.php index 44fc6974..351dd3b6 100644 --- a/resources/views/native/new-org.blade.php +++ b/resources/views/native/new-org.blade.php @@ -24,7 +24,7 @@ class="w-full text-theme-ink" @include('native.partials.people-search', ['placeholder' => 'Find people by name or @handle', 'autofocus' => true]) @include('native.partials.picked-people') - + @include('native.partials.people-results') @@ -34,7 +34,7 @@ class="w-full text-theme-ink" - + {{ $pickError ?? '' }} diff --git a/resources/views/native/new-remote.blade.php b/resources/views/native/new-remote.blade.php index b3c96031..2e41cb33 100644 --- a/resources/views/native/new-remote.blade.php +++ b/resources/views/native/new-remote.blade.php @@ -8,7 +8,7 @@ @include('native.partials.people-search', ['placeholder' => 'Message a developer', 'autofocus' => true]) - + @if ($term === '') diff --git a/resources/views/native/org-members.blade.php b/resources/views/native/org-members.blade.php index 7f8cbf4b..43291dcd 100644 --- a/resources/views/native/org-members.blade.php +++ b/resources/views/native/org-members.blade.php @@ -45,7 +45,7 @@ class="w-full text-theme-ink" @endif @foreach ($rows as $row) @if ($isMaintainer || ! $loop->first) - + @endif @include('native.partials.org-member-row', $row + ['showsSlot' => $isMaintainer]) @endforeach @@ -58,7 +58,7 @@ class="w-full text-theme-ink" @foreach (range(1, 3) as $skeleton) @if (! $loop->first) - + @endif diff --git a/resources/views/native/partials/achievements-sheet.blade.php b/resources/views/native/partials/achievements-sheet.blade.php index 76e0f646..9d7ae925 100644 --- a/resources/views/native/partials/achievements-sheet.blade.php +++ b/resources/views/native/partials/achievements-sheet.blade.php @@ -49,7 +49,7 @@ @endforeach - + @if ($achievementsTab === 'badges') @@ -112,7 +112,7 @@ @endif - + XP: 10/commit · 15/day streak · 5/star received · 5/reply · 2/star given diff --git a/resources/views/native/partials/achievements.blade.php b/resources/views/native/partials/achievements.blade.php index 6afc70b1..24db5c91 100644 --- a/resources/views/native/partials/achievements.blade.php +++ b/resources/views/native/partials/achievements.blade.php @@ -49,4 +49,4 @@ @endif - + diff --git a/resources/views/native/partials/activity.blade.php b/resources/views/native/partials/activity.blade.php index 79553704..f1ff0dcc 100644 --- a/resources/views/native/partials/activity.blade.php +++ b/resources/views/native/partials/activity.blade.php @@ -50,4 +50,4 @@ More - + diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 97a5b0b1..2222c980 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -63,7 +63,7 @@ @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => match (true) { $schedulingThis => 'Schedule commit', $replying => 'Reply', default => 'Commit' }, 'main' => true, 'loading' => $sendingSchedule, 'press' => $sendingSchedule || ! $canCommit || ($schedulingThis && ! $hasScheduledTime) ? null : 'commitFromSheet']) @endif - + @if ($sheetError) @@ -130,7 +130,7 @@ class="w-full text-theme-ink" {{-- Pinned under the scroll view, so it rides just above the keyboard, and above the home indicator without it. --}} - + @unless ($issueCommand) @include('native.partials.composer-tools') @@ -151,9 +151,9 @@ class="w-full text-theme-ink" - + {{ $sheetCommit->plainText() }} - + diff --git a/resources/views/native/partials/commit-tombstone.blade.php b/resources/views/native/partials/commit-tombstone.blade.php index 8d6e0929..47dd9a4d 100644 --- a/resources/views/native/partials/commit-tombstone.blade.php +++ b/resources/views/native/partials/commit-tombstone.blade.php @@ -15,7 +15,7 @@ @include('native.partials.series-position') @endif - + @include('native.partials.rail', ['previous' => $previousLane, 'lane' => \App\Devnull\Lane::Rail, 'tail' => $withTail ? \App\Devnull\Lane::Rail : null, 'node' => 'tombstone']) diff --git a/resources/views/native/partials/connection-loading.blade.php b/resources/views/native/partials/connection-loading.blade.php index 2d96c6e5..d5badc09 100644 --- a/resources/views/native/partials/connection-loading.blade.php +++ b/resources/views/native/partials/connection-loading.blade.php @@ -11,5 +11,5 @@ - + diff --git a/resources/views/native/partials/connection.blade.php b/resources/views/native/partials/connection.blade.php index a6307f22..faa96792 100644 --- a/resources/views/native/partials/connection.blade.php +++ b/resources/views/native/partials/connection.blade.php @@ -38,5 +38,5 @@ @endif - + diff --git a/resources/views/native/partials/day-marker.blade.php b/resources/views/native/partials/day-marker.blade.php index ace93ea0..391d797e 100644 --- a/resources/views/native/partials/day-marker.blade.php +++ b/resources/views/native/partials/day-marker.blade.php @@ -5,7 +5,7 @@ {{ $label }} - + @include('native.partials.rail', ['previous' => $lane]) diff --git a/resources/views/native/partials/earlier-commits.blade.php b/resources/views/native/partials/earlier-commits.blade.php index a33b471a..ca5a8183 100644 --- a/resources/views/native/partials/earlier-commits.blade.php +++ b/resources/views/native/partials/earlier-commits.blade.php @@ -16,7 +16,7 @@ {{ $label }} - + @include('native.partials.dashed-line', ['position' => 'top-0 h-[13]', 'left' => 31.5, 'lane' => \App\Devnull\Lane::Rail]) diff --git a/resources/views/native/partials/following-empty.blade.php b/resources/views/native/partials/following-empty.blade.php index 286befff..fae6421c 100644 --- a/resources/views/native/partials/following-empty.blade.php +++ b/resources/views/native/partials/following-empty.blade.php @@ -30,7 +30,7 @@ Follow @unless ($loop->last) - + @endunless @endforeach diff --git a/resources/views/native/partials/last-visit-marker.blade.php b/resources/views/native/partials/last-visit-marker.blade.php index a5290c8e..6426dca7 100644 --- a/resources/views/native/partials/last-visit-marker.blade.php +++ b/resources/views/native/partials/last-visit-marker.blade.php @@ -9,11 +9,11 @@ HEAD@{last visit} @foreach (range(1, 24) as $dash) - + @endforeach - + @include('native.partials.rail', ['previous' => $lane]) diff --git a/resources/views/native/partials/live-stream-sheet.blade.php b/resources/views/native/partials/live-stream-sheet.blade.php index d3dd87c2..f886ef25 100644 --- a/resources/views/native/partials/live-stream-sheet.blade.php +++ b/resources/views/native/partials/live-stream-sheet.blade.php @@ -25,7 +25,7 @@ Watch now @if ($liveStreamWithProfile) - + @include('native.partials.rail-avatar', ['user' => $streamer, 'lane' => $streamer->lane(), 'size' => 48]) diff --git a/resources/views/native/partials/notification.blade.php b/resources/views/native/partials/notification.blade.php index 0f89f27f..64284c52 100644 --- a/resources/views/native/partials/notification.blade.php +++ b/resources/views/native/partials/notification.blade.php @@ -97,7 +97,7 @@ @endif @endif - + {{-- The Rail Gray rail runs the whole inbox, through each node. --}} diff --git a/resources/views/native/partials/organization-header.blade.php b/resources/views/native/partials/organization-header.blade.php index 295a56b4..f303a8d1 100644 --- a/resources/views/native/partials/organization-header.blade.php +++ b/resources/views/native/partials/organization-header.blade.php @@ -86,5 +86,5 @@ @endif - + diff --git a/resources/views/native/partials/pinned-bar.blade.php b/resources/views/native/partials/pinned-bar.blade.php index bb9c3c8a..b6700889 100644 --- a/resources/views/native/partials/pinned-bar.blade.php +++ b/resources/views/native/partials/pinned-bar.blade.php @@ -14,6 +14,6 @@ - + diff --git a/resources/views/native/partials/pinned-sheet.blade.php b/resources/views/native/partials/pinned-sheet.blade.php index 35e9fa1f..0ceb222b 100644 --- a/resources/views/native/partials/pinned-sheet.blade.php +++ b/resources/views/native/partials/pinned-sheet.blade.php @@ -12,7 +12,7 @@ - + @foreach ($pinned as $message) @include('native.partials.pinned-message', ['message' => $message, 'key' => 'pin-'.$message->id, 'first' => $loop->first]) diff --git a/resources/views/native/partials/poll-editor.blade.php b/resources/views/native/partials/poll-editor.blade.php index 1e1f1807..f07ffacb 100644 --- a/resources/views/native/partials/poll-editor.blade.php +++ b/resources/views/native/partials/poll-editor.blade.php @@ -36,7 +36,7 @@ class="flex-1" @endif - + diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index 78c88ffc..f73fc2ae 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -131,7 +131,7 @@ @endif - + @if (! $sketched) @include('native.partials.activity', ['activity' => $profile->activity, 'lane' => $user->lane()]) diff --git a/resources/views/native/partials/push-prompt.blade.php b/resources/views/native/partials/push-prompt.blade.php index 317d40f4..f89c0427 100644 --- a/resources/views/native/partials/push-prompt.blade.php +++ b/resources/views/native/partials/push-prompt.blade.php @@ -27,5 +27,5 @@ - + @endif diff --git a/resources/views/native/partials/rail-loading.blade.php b/resources/views/native/partials/rail-loading.blade.php index f2807820..90f1f96d 100644 --- a/resources/views/native/partials/rail-loading.blade.php +++ b/resources/views/native/partials/rail-loading.blade.php @@ -17,7 +17,7 @@ - + @include('native.partials.rail', ['tail' => \App\Devnull\Lane::Rail, 'endsWithDot' => true, 'lane' => \App\Devnull\Lane::Rail, 'node' => 'skeleton']) diff --git a/resources/views/native/partials/refresh-failed.blade.php b/resources/views/native/partials/refresh-failed.blade.php index 8dbf3cd7..310300cf 100644 --- a/resources/views/native/partials/refresh-failed.blade.php +++ b/resources/views/native/partials/refresh-failed.blade.php @@ -8,6 +8,6 @@ warning: couldn't refresh Retry - + @endif diff --git a/resources/views/native/partials/remote-row-loading.blade.php b/resources/views/native/partials/remote-row-loading.blade.php index 427e51dd..d8ba43f1 100644 --- a/resources/views/native/partials/remote-row-loading.blade.php +++ b/resources/views/native/partials/remote-row-loading.blade.php @@ -8,6 +8,6 @@ - + diff --git a/resources/views/native/partials/remote-row.blade.php b/resources/views/native/partials/remote-row.blade.php index 9c67f07e..bbbdf04e 100644 --- a/resources/views/native/partials/remote-row.blade.php +++ b/resources/views/native/partials/remote-row.blade.php @@ -80,6 +80,6 @@ class="w-full" - + diff --git a/resources/views/native/partials/streaks.blade.php b/resources/views/native/partials/streaks.blade.php index dfb1342a..9a038c69 100644 --- a/resources/views/native/partials/streaks.blade.php +++ b/resources/views/native/partials/streaks.blade.php @@ -10,5 +10,5 @@ @endif - + @endif diff --git a/resources/views/native/partials/thread-reply.blade.php b/resources/views/native/partials/thread-reply.blade.php index 6a1d0a7c..d3f6dfdd 100644 --- a/resources/views/native/partials/thread-reply.blade.php +++ b/resources/views/native/partials/thread-reply.blade.php @@ -53,7 +53,7 @@ @include('native.partials.thread-more', ['commit' => $thread ?? $reply]) @endif @if ($last && ! $muted) - + @endif @include('native.partials.thread-links') @@ -92,7 +92,7 @@ @include('native.partials.thread-more', ['commit' => $thread ?? $reply]) @endif @if ($last && ! $muted) - + @endif @include('native.partials.thread-links') diff --git a/resources/views/native/partials/timezone-sheet.blade.php b/resources/views/native/partials/timezone-sheet.blade.php index f4769719..5f81ec1e 100644 --- a/resources/views/native/partials/timezone-sheet.blade.php +++ b/resources/views/native/partials/timezone-sheet.blade.php @@ -15,7 +15,7 @@ @forelse ($timezones['zones'] as $zone) @if (! $loop->first) - + @endif diff --git a/resources/views/native/partials/who-to-follow.blade.php b/resources/views/native/partials/who-to-follow.blade.php index 0482fed4..4f81df78 100644 --- a/resources/views/native/partials/who-to-follow.blade.php +++ b/resources/views/native/partials/who-to-follow.blade.php @@ -83,12 +83,12 @@ @unless ($loop->last) - + @endunless @endforeach @endif - + diff --git a/resources/views/native/safety.blade.php b/resources/views/native/safety.blade.php index 9080687e..56943fbf 100644 --- a/resources/views/native/safety.blade.php +++ b/resources/views/native/safety.blade.php @@ -18,7 +18,7 @@ @foreach (range(1, 2) as $skeleton) @if (! $loop->first) - + @endif @@ -39,7 +39,7 @@ @foreach ($people[$key] as $person) @if (! $loop->first) - + @endif diff --git a/resources/views/native/scheduled-commits.blade.php b/resources/views/native/scheduled-commits.blade.php index 06f07ac1..64fa03c7 100644 --- a/resources/views/native/scheduled-commits.blade.php +++ b/resources/views/native/scheduled-commits.blade.php @@ -21,7 +21,7 @@ Times in {{ $timezone }} - + @if (! $loaded) @if ($error === null) @@ -31,7 +31,7 @@ - + @endforeach @endif @elseif ($scheduled === []) @@ -81,7 +81,7 @@ {{ $item->isDue($now) ? 'Waiting for the scheduler. If submission fails, it is retried automatically.' : 'Private until submitted. You can commit it now or cancel it before submission.' }} @endif - + @endforeach @if ($nextPage !== null) diff --git a/resources/views/native/starred.blade.php b/resources/views/native/starred.blade.php index 61117acc..20ce9d17 100644 --- a/resources/views/native/starred.blade.php +++ b/resources/views/native/starred.blade.php @@ -18,7 +18,7 @@ Commits you starred, newest first. - + @if (! $loaded && $error === null) diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 5f228385..f71a111b 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -33,7 +33,7 @@ @foreach ($fields as $field) @if (! $loop->first) - + @endif {{ $field['label'] }} @@ -134,7 +134,7 @@ @endif @if ($phoneTimezone !== null) - + this phone {{ \App\Devnull\Timezone::offset($phoneTimezone) }} {{ $phoneTimezone }} @@ -331,7 +331,7 @@ {{ $fingerprint ?: 'null' }} @if ($remote !== null) - + remote {{ $remote }} From 9b8cdbf0b1447d2117f83c543e669221f68eb3d1 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:35:22 -0800 Subject: [PATCH 18/42] Announce an author's avatar by name, not as an unfinished template --- resources/views/native/partials/rail.blade.php | 3 ++- tests/Feature/Screens/LiveAvatarTest.php | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/resources/views/native/partials/rail.blade.php b/resources/views/native/partials/rail.blade.php index f64abf05..770c2c99 100644 --- a/resources/views/native/partials/rail.blade.php +++ b/resources/views/native/partials/rail.blade.php @@ -70,7 +70,8 @@ @php($nodeSize = $node === 'avatar' ? $avatarSize : 48) @php($nodeAt = 'absolute top-['.($center - intdiv($nodeSize, 2)).'] left-['.(32 - intdiv($nodeSize, 2)).']') @if ($node === 'avatar' && $press) - name} is live" : "{$user->name}'s profile" }}"> + @php($avatarLabel = $user->isLive() ? "{$user->name} is live" : "{$user->name}'s profile") + @include('native.partials.rail-avatar', ['user' => $user, 'lane' => $lane, 'size' => $avatarSize]) @elseif ($node === 'avatar' || $node === 'staged') diff --git a/tests/Feature/Screens/LiveAvatarTest.php b/tests/Feature/Screens/LiveAvatarTest.php index b7a214eb..f9b2d9aa 100644 --- a/tests/Feature/Screens/LiveAvatarTest.php +++ b/tests/Feature/Screens/LiveAvatarTest.php @@ -81,6 +81,15 @@ function railRingColour(TestableComponent $screen, string $ref): string ->and(railRingColour($screen, 'commit-c1'))->toBe(strtolower(config('native-ui.theme.light.lane-2'))); }); +it("announces an author's avatar by name, and as live while they stream", function (?array $live, string $label) { + $avatar = findNode(nodeByRef(logByStreamer($live), 'commit-c1'), fn (array $node): bool => ($node['props']['a11y_label'] ?? null) === $label); + + expect($avatar)->not->toBeNull(); +})->with([ + 'not live' => [null, "Ada's profile"], + 'live' => [fn (): array => adaStream(now()->addHour()->toIso8601ZuluString()), 'Ada is live'], +]); + it('keeps the lane ring once the stream has ended', function (?array $live) { $screen = logByStreamer($live); From 765073deea81677d977fe58014f44fa9473deb39 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:39:01 -0800 Subject: [PATCH 19/42] Point Remotes' empty state at the pencil button, not a label nobody sees --- resources/views/native/partials/remotes-page.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/native/partials/remotes-page.blade.php b/resources/views/native/partials/remotes-page.blade.php index 328cd592..4980f4eb 100644 --- a/resources/views/native/partials/remotes-page.blade.php +++ b/resources/views/native/partials/remotes-page.blade.php @@ -22,7 +22,7 @@ class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }} @include('native.partials.empty', match ($page['tab']) { 'stashed' => ['command' => 'nothing stashed', 'message' => 'Stash a conversation from its menu. It pops back into Open when someone writes.'], 'fetched' => ['command' => 'no requests fetched', 'message' => 'First messages from people you don’t follow wait here until you merge or drop them.'], - default => ['command' => 'no conversations yet', 'message' => 'Start one with New remote, or from a profile with Remote.'], + default => ['command' => 'no conversations yet', 'message' => 'Start one with the pencil button, or from a profile with Remote.'], }) @endif From 2b89bc5a9394608d5ac975a9670fe0f9a41e98db Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:48:56 -0800 Subject: [PATCH 20/42] Reflow the profile into two columns on the unfolded inner display Who they are, their activity and their level scroll in a leading column and the tabs and their commits in a trailing one, with the gap centred on the fold. The closed display keeps the single column. --- .../native/partials/profile-header.blade.php | 9 ++++++++ resources/views/native/profile.blade.php | 21 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index f73fc2ae..5a51bf2c 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -3,8 +3,13 @@ lane, Follow (or, on your own, Edit on GitHub), who they are, what GitHub says about them, their counts and, in pastéis mode, the pastéis they've been given, then their activity, streaks and achievements. It scrolls up with the tab you're on until the tabs pin under the nav bar. + + Once the window is expanded the pager's copy is hidden, as profile.blade.php draws this header again as the + leading column. A hairline stays in its place: the pager's page lists stop drawing when their header row has + no height at all. --}} + {{-- @@ -141,3 +146,7 @@ @endif @endif +@if ($inPager ?? false) + diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index a2b42920..061ce89e 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -10,9 +10,22 @@ The pager or the list stays alone in here, so the banners above coming and going never add a node beside it. Keyed so nothing above it, like the image viewer opening, gives it a new id and the pager a new list at the top. --}} - + @if ($profile && ! $unavailable) @php($user = $profile->user) + {{-- + Once the window is expanded (a foldable's inner display) the profile reflows into two columns: who they are, + their activity and their level in the leading one, scrolling by itself, and the tabs and their commits in + the trailing one. The header below, which the pager draws above its tabs on a phone, is hidden then, so + every control is on screen once in either layout. + + The fold is in the middle of the display, but the window stops short of it by the vertical bar on the + trailing edge (84pt), which puts the middle of the window left of the fold. The columns are weighted 11:9 so + the gap between them, 48pt wide, is centred on the fold rather than on the window. + --}} + {{-- Commits and Replies as pages you swipe between, under the tabs the pager draws, as on the Log, with the underline in your colour. The header goes to the pager too, which draws it at the top of the page you're on, @@ -27,7 +40,9 @@ ref="profile-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface" + class="w-full flex-1 bg-theme-surface expanded:h-full" + :flexGrow="9" + :flexBasis="0" :count="count($pages)" :page="$pageIndex" on-page-change="showPage" @@ -41,7 +56,7 @@ class="w-full flex-1 bg-theme-surface" a11y-label="Commits and replies" > - @include('native.partials.profile-header') + @include('native.partials.profile-header', ['inPager' => true]) @foreach ($pages as $page) From 8e9836ac578ea3a7eae5fe8c8b0b4e3cd95bdd67 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:39:01 -0800 Subject: [PATCH 21/42] Keep a sheet bar button's icon out of VoiceOver and call a muted one unavailable --- .../partials/sheet-bar-button.blade.php | 20 +++++++++++-------- tests/Feature/Screens/ComposeSheetTest.php | 17 ++++++++++++++++ tests/Pest.php | 4 +--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/resources/views/native/partials/sheet-bar-button.blade.php b/resources/views/native/partials/sheet-bar-button.blade.php index a554285b..86ae3bf8 100644 --- a/resources/views/native/partials/sheet-bar-button.blade.php +++ b/resources/views/native/partials/sheet-bar-button.blade.php @@ -13,16 +13,20 @@ @php($iconColour = $filled ? 'text-theme-on-'.$myColour : ($press === null ? 'text-theme-ink-muted' : 'text-theme-ink')) @if ($press !== null) - + @else - - @if ($loading) - - @else - - @endif - + {{-- The label sits on a container around the circle, as the pressable above does. --}} + @php($mutedLabel = $loading ? $label : $label.', unavailable') + + + @if ($loading) + + @else + + @endif + + @endif diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index d0f319ee..610123b1 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -291,3 +291,20 @@ function sheetCommitDisabled(TestableComponent $screen): bool Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); + +it("names the sheet's round buttons for VoiceOver, with nothing else announced inside them", function () { + $screen = composeSheetOnLog()->assertSee('New commit'); + $cancel = nodeByRef($screen, 'sheet-cancel'); + $commit = nodeByRef($screen, 'sheet-commit'); + + expect($cancel['type'])->toBe('pressable') + ->and($cancel['props'])->toMatchArray(['a11y_label' => 'Cancel']) + ->and($commit['props']['a11y_label'])->toBe('Commit, unavailable') + ->and(findNodes($cancel, fn (array $node): bool => isset($node['ref'])))->toHaveCount(1) + ->and(findNodes($commit, fn (array $node): bool => isset($node['ref'])))->toHaveCount(1); + + $screen = $screen->input('sheet-body', 'Shipped'); + + expect(nodeByRef($screen, 'sheet-commit'))->toMatchArray(['type' => 'pressable']) + ->and(nodeByRef($screen, 'sheet-commit')['props'])->toMatchArray(['a11y_label' => 'Commit']); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 65830772..a442db23 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -774,9 +774,7 @@ function sheetButtonEnabled(TestableComponent $screen, string $ref): bool */ function sheetButtonCircle(TestableComponent $screen, string $ref): array { - $button = nodeByRef($screen, $ref); - - return $button['type'] === 'pressable' ? $button['children'][0] : $button; + return nodeByRef($screen, $ref)['children'][0]; } /** From 8c1c103157938cf9e44e840fc9b03a8df4a5411f Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:41:33 -0800 Subject: [PATCH 22/42] Keep the onboarding lockup inside its half of the inner display, with the buttons' margin --- resources/views/native/onboarding.blade.php | 11 ++++++++--- tests/Feature/Screens/OnboardingTest.php | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index a40cbceb..4cba8d57 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -7,9 +7,14 @@ window. --}} - {{-- The lockup, two thirds of the screen wide at its own 15:4 (as wide as its half, once side by side), centred in its space. --}} - - + {{-- + The lockup, two thirds of the screen wide at its own 15:4, centred in its space. Side by side it has to fit its half + less the buttons' 24pt margin on each side. EDGE resolves a percentage against the window, not the parent, and a + variant can't swap a width for `w-full`, so it's 40% of the window: at most that room from 840pt up (336pt of 340pt + there, 380pt of 395pt on the inner display). + --}} + + diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index 9027e23b..bfb68885 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -142,6 +142,23 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent } }); +it("keeps the lockup inside its half, with the buttons' margin, once the window is wide enough to put them side by side", function (int $window) { + signInBridge(testSeed()); + + $screen = Native::visit('/onboarding'); + $variant = fn (array $node): array => json_decode($node['props']['_variants'], true)[0]; + $logo = $variant(nodeByRef($screen, 'logo')); + $margin = $variant(nodeByRef($screen, 'logo-space'))['layout']['padding'][1]; + $half = ($window - $variant($screen->tree())['layout']['gap']) / 2; + + expect($logo['min'])->toBe(840) + ->and($margin)->toEqual(nodeByRef($screen, 'actions')['layout']['padding'][1]) + ->and(floatval($logo['layout']['width']) / 100 * $window)->toBeLessThanOrEqual($half - 2 * $margin); +})->with([ + 'the narrowest expanded window' => 840, + 'the inner display held sideways' => 951, +]); + it('keeps the same key across launches', function () { signInBridge(testSeed()); From 7e03d004c4fbc0cb2d4d8b3ad9f0430b597ee270 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:41:33 -0800 Subject: [PATCH 23/42] Line up the Notifications and Remotes tabs with their readable column --- resources/views/native/notifications.blade.php | 4 ++-- resources/views/native/partials/notifications-page.blade.php | 4 ++-- resources/views/native/partials/remotes-page.blade.php | 4 ++-- resources/views/native/remotes.blade.php | 4 ++-- resources/views/native/search.blade.php | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/resources/views/native/notifications.blade.php b/resources/views/native/notifications.blade.php index 512cbd34..9789442e 100644 --- a/resources/views/native/notifications.blade.php +++ b/resources/views/native/notifications.blade.php @@ -1,4 +1,4 @@ - + @include('native.partials.error') @include('native.partials.refresh-failed') @include('native.partials.push-prompt') @@ -16,7 +16,7 @@ ref="notifications-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface" + class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }}" :count="count($types)" :page="$pageIndex" :from="$pagesFrom" diff --git a/resources/views/native/partials/notifications-page.blade.php b/resources/views/native/partials/notifications-page.blade.php index 18659e2c..56669408 100644 --- a/resources/views/native/partials/notifications-page.blade.php +++ b/resources/views/native/partials/notifications-page.blade.php @@ -7,11 +7,11 @@ laid out off screen still shows its rows, and every page keeps pull to refresh, since iOS builds a new list when a list gains or loses it. --}} - + + + @if ($off) fatal: path '/remotes' does not exist in 'HEAD' @@ -22,7 +22,7 @@ ref="remotes-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface" + class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }}" :count="count($tabLabels)" :page="$pageIndex" :from="$pagesFrom" diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php index 3f380e97..85d0192a 100644 --- a/resources/views/native/search.blade.php +++ b/resources/views/native/search.blade.php @@ -12,7 +12,7 @@ @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => true, 'a11yLabel' => 'Search developers or commits']) - + @if ($term === '') From fe21cb5cc8cbd8b9064ee80cfef9ed683fc71ae3 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:41:33 -0800 Subject: [PATCH 24/42] Line a pushed commit's reply bar up with its readable column --- resources/views/native/commit-detail.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index eac60027..230ccc95 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -127,7 +127,7 @@ the field on Android. --}} - + {{-- The people to mention keep a slot above the field, empty and no height when there's no one, so the From 0e23aab0e2cec33d7539088dc4e70b45f77dfdff Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 17:59:08 -0800 Subject: [PATCH 25/42] Make the Duo feed workspace fold-aware and follow the system bars The split now changes mode on the horizontal size class instead of a width breakpoint and an orientation check, and parts its columns on the fold's division region, an even split when no region crosses it. The workspace fills the window, so UIKit no longer suppresses the vertical bar for its navigation controllers: on the outer display the Log's search and compose and a commit's back button move into the trailing bar like every other screen. Each column measures its own horizontal safe area, since a navigation controller passes the window's insets to pages that never reach the bar. The feed row whose conversation is open beside it is selected, the split drops its Close button, and the empty detail is a themed EDGE placeholder with an icon, title and hint. --- app/Devnull/RowInputs.php | 1 + app/NativeComponents/FeedWorkspace.php | 16 +- app/NativeComponents/Log.php | 5 + packages/adaptive-layout/nativephp.json | 7 + .../resources/ios/AdaptiveFeedRenderer.swift | 164 +++++++++++++----- .../src/Components/AdaptivePlaceholder.php | 13 ++ .../src/Elements/AdaptivePlaceholder.php | 25 +++ .../views/native/feed-workspace.blade.php | 9 +- .../native/partials/commit-row.blade.php | 3 +- .../native/partials/thread-reply.blade.php | 2 +- tests/Feature/Screens/FeedWorkspaceTest.php | 33 +++- 11 files changed, 213 insertions(+), 65 deletions(-) create mode 100644 packages/adaptive-layout/src/Components/AdaptivePlaceholder.php create mode 100644 packages/adaptive-layout/src/Elements/AdaptivePlaceholder.php diff --git a/app/Devnull/RowInputs.php b/app/Devnull/RowInputs.php index 0659a309..31b3625a 100644 --- a/app/Devnull/RowInputs.php +++ b/app/Devnull/RowInputs.php @@ -37,6 +37,7 @@ public static function for(Commit $commit, array $scope): array 'tombstoneReplies' => array_key_exists($commit->id, $scope['tombstoneReplies'] ?? []), 'shown' => [self::changing($commit), $commit->quotedCommit === null ? null : self::changing($commit->quotedCommit)], 'poll' => [$scope['pollChoices'][$commit->id] ?? null, in_array($commit->id, $scope['voting'] ?? [], true)], + 'selected' => ($scope['selectedCommit'] ?? null) === $commit->id, ]; } diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php index 2788ad00..cd55986f 100644 --- a/app/NativeComponents/FeedWorkspace.php +++ b/app/NativeComponents/FeedWorkspace.php @@ -111,7 +111,7 @@ public function hostChanged(string $event): void return; } - if (in_array($change['action'] ?? null, ['back', 'close', 'delete'], true)) { + if (in_array($change['action'] ?? null, ['back', 'delete'], true)) { $identities = array_map(fn (array $conversation): string => 'conversation-'.$conversation['slot'], $this->conversations); $expected = $change['expected_history'] ?? null; $matches = $expected === $identities; @@ -131,12 +131,6 @@ public function hostChanged(string $event): void $this->foregroundPane()?->onResume(); } break; - case 'close': - if ($this->conversations !== []) { - $this->conversations = []; - $this->feed()?->onResume(); - } - break; case 'compose': $this->feed()?->composeInSheet(); break; @@ -158,6 +152,14 @@ public function hostChanged(string $event): void } } + /** + * The commit opened from the feed, which its row marks while it is open beside it. + */ + public function selectedCommit(): ?string + { + return $this->conversations[0]['id'] ?? null; + } + public function conversationCanDelete(int $slot): bool { $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$slot] ?? null; diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index fd312381..b1e7c0dc 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -165,6 +165,11 @@ class Log extends NativeComponent public bool $embedded = false; + /** + * The commit open beside the log in a split workspace, whose row shows as selected. + */ + public ?string $selectedCommit = null; + /** * The list key of each log you're not on. The log you're on uses $listKey. * diff --git a/packages/adaptive-layout/nativephp.json b/packages/adaptive-layout/nativephp.json index f12de102..68438fff 100644 --- a/packages/adaptive-layout/nativephp.json +++ b/packages/adaptive-layout/nativephp.json @@ -21,6 +21,13 @@ "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptiveDetail", "ios_renderer": "AdaptiveDetailRenderer", "self_closing": false + }, + { + "type": "adaptive_placeholder", + "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptivePlaceholder", + "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptivePlaceholder", + "ios_renderer": "AdaptivePlaceholderRenderer", + "self_closing": false } ] } diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 752c6232..8355a29b 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -9,13 +9,12 @@ struct AdaptiveFeedRenderer: View { var body: some View { AdaptiveFeedHost(node: node) .frame(maxWidth: .infinity, maxHeight: .infinity) - // UIKit owns vertical safe areas. Insetting this host at the top - // as well would reserve the header space twice in portrait. - // Retained pages still inset controls and scrolling content. - .ignoresSafeArea(.container, edges: .vertical) + // The workspace fills the window and its pages inset themselves, as + // in any UIKit container. UIKit only moves a navigation bar into the + // vertical bar when its controller reaches the bar's edge; inset + // here, the nested bars stay horizontal on the outer display. + .ignoresSafeArea(.container, edges: .all) .background { - // Backgrounds fill the rail region; readable rows and controls - // continue to use the tab host's asymmetric safe area. themeStore.resolve(for: colorScheme).surface .ignoresSafeArea(.container, edges: .all) } @@ -23,7 +22,8 @@ struct AdaptiveFeedRenderer: View { } } -// The detail tag is a descriptor consumed by the feed host, not a second router. +// The detail and placeholder tags are descriptors consumed by the feed host, +// not a second router. struct AdaptiveDetailRenderer: View { let node: NativeUINode @@ -34,6 +34,16 @@ struct AdaptiveDetailRenderer: View { } } +struct AdaptivePlaceholderRenderer: View { + let node: NativeUINode + + var body: some View { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } + } +} + private struct AdaptiveFeedHost: UIViewControllerRepresentable { let node: NativeUINode @@ -50,6 +60,12 @@ private struct AdaptiveFeedHost: UIViewControllerRepresentable { private final class AdaptivePaneInsets: ObservableObject { @Published var bottom: CGFloat = 0 + @Published var horizontal = HorizontalInsets() + + struct HorizontalInsets: Equatable { + var leading: CGFloat = 0 + var trailing: CGFloat = 0 + } } /// A stable root type for every retained hosting controller. Only the direct @@ -73,8 +89,11 @@ private struct AdaptiveNodeRoot: View { .environment(\.nativeSafeAreaTop, geometry.safeAreaInsets.top) .environment(\.nativeSafeAreaBottom, geometry.safeAreaInsets.bottom) } - // The outer tab host already removes the system rail. UIKit still - // propagates its window-side inset into each nested hosting root. + .padding(.leading, paneInsets.horizontal.leading) + .padding(.trailing, paneInsets.horizontal.trailing) + // A navigation controller hands its pages the window's horizontal + // insets even in a column that stops short of the vertical bar. The + // workspace measures what each column actually overlaps instead. .ignoresSafeArea(.container, edges: .horizontal) // UIHostingController is a new SwiftUI root, so it does not inherit the // representable's theme, window breakpoints, locale, or custom keys. @@ -113,6 +132,7 @@ private final class AdaptivePageController: UIViewController { private let hosting: UIHostingController private let paneInsets: AdaptivePaneInsets private var requestedBottomInset: CGFloat = 0 + private var requestedHorizontalInsets = AdaptivePaneInsets.HorizontalInsets() var onSafeAreaChange: (() -> Void)? private(set) var source: NativeUINode private var contentNode: NativeUINode @@ -168,6 +188,16 @@ private final class AdaptivePageController: UIViewController { } } + func reserveHorizontal(leading: CGFloat, trailing: CGFloat) { + let insets = AdaptivePaneInsets.HorizontalInsets(leading: leading, trailing: trailing) + guard insets != requestedHorizontalInsets else { return } + requestedHorizontalInsets = insets + DispatchQueue.main.async { [weak self] in + guard let self, self.requestedHorizontalInsets == insets else { return } + self.paneInsets.horizontal = insets + } + } + func update(_ node: NativeUINode, detail: Bool, environment: EnvironmentValues) { if source !== node { source = node @@ -188,8 +218,9 @@ private final class AdaptiveWordmarkView: UIImageView { override func sizeThatFits(_ size: CGSize) -> CGSize { intrinsicContentSize } } -/// The outer tab host already removes the rail from each pane. Limit the -/// bar's horizontal safe area to what actually overlaps its parent container. +/// A navigation controller gives its bar the window's horizontal insets even +/// in a column that stops short of the vertical bar. Limit them to what the +/// bar actually overlaps of its container's safe area. private final class AdaptiveNavigationBar: UINavigationBar { override var safeAreaInsets: UIEdgeInsets { var insets = super.safeAreaInsets @@ -205,8 +236,9 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC private let primary = UINavigationController(navigationBarClass: AdaptiveNavigationBar.self, toolbarClass: nil) private let secondary = UINavigationController(navigationBarClass: AdaptiveNavigationBar.self, toolbarClass: nil) private let divider = UIView() - private let emptySelection = UILabel() private var pages: [Int: AdaptivePageController] = [:] + private var placeholder: AdaptivePageController? + private var hingeStatus: Int? private var latest: NativeUINode? private var inherited: EnvironmentValues? private var wide: Bool? @@ -238,14 +270,22 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC navigation.didMove(toParent: self) } view.addSubview(divider) - emptySelection.text = "Select a commit to view its conversation" - emptySelection.textAlignment = .center - emptySelection.numberOfLines = 0 - emptySelection.adjustsFontForContentSizeCategory = true - emptySelection.isHidden = true - view.addSubview(emptySelection) secondary.view.isHidden = true divider.isHidden = true + registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: Self, _: UITraitCollection) in + self.view.setNeedsLayout() + } + if #available(iOS 27.1, *) { + // The fold's division region turns active as the hinge leaves + // flat; angle updates in between do not move the columns. + view.addInteraction(UIHingeInteraction { [weak self] _, update in + guard let self else { return } + let status = update.hinge?.status.rawValue + guard status != self.hingeStatus else { return } + self.hingeStatus = status + self.view.setNeedsLayout() + }) + } } func receive(_ node: NativeUINode, environment: EnvironmentValues) { @@ -259,6 +299,9 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC for child in node.children { pages[child.id]?.update(child, detail: child.type == "adaptive_detail", environment: environment) } + if let placeholderNode = node.children.first(where: { $0.type == "adaptive_placeholder" }) { + placeholder?.update(placeholderNode, detail: false, environment: environment) + } reconcile() } @@ -267,6 +310,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC reconcile() layoutColumns() updateBottomBarClearance() + updateHorizontalInsets() } override func viewSafeAreaInsetsDidChange() { @@ -294,8 +338,8 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC if let bar = tabBarController?.tabBar, !bar.isHidden, bar.alpha > 0, bar.window === view.window { let overlap = view.bounds.intersection(bar.convert(bar.bounds, to: view)) - // Only the horizontal bottom bar obstructs the reply field. A - // side rail is already excluded by the outer SwiftUI tab host. + // Only the horizontal bottom bar obstructs the reply field. The + // vertical bar is a horizontal safe-area inset of each column. // Reject the full-window tab-bar frame seen during Duo resizing. if !overlap.isNull, overlap.width > overlap.height, overlap.height < view.bounds.height / 2 { @@ -315,28 +359,53 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC } } + private func updateHorizontalInsets() { + let safe = view.safeAreaLayoutGuide.layoutFrame + for navigation in [primary, secondary] where !navigation.view.isHidden { + let column = navigation.view.frame + let leading = max(0, min(column.width, safe.minX - column.minX)) + let trailing = max(0, min(column.width, column.maxX - safe.maxX)) + let isRightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft + for case let page as AdaptivePageController in navigation.viewControllers { + page.reserveHorizontal(leading: isRightToLeft ? trailing : leading, trailing: isRightToLeft ? leading : trailing) + } + } + } + private func layoutColumns() { let bounds = view.bounds - if wide == true { - let feedWidth = min(420, bounds.width * 0.42) - primary.view.frame = CGRect(x: 0, y: 0, width: feedWidth, height: bounds.height) - secondary.view.frame = CGRect(x: feedWidth + 1, y: 0, width: max(0, bounds.width - feedWidth - 1), height: bounds.height) - divider.frame = CGRect(x: feedWidth, y: 0, width: 1, height: bounds.height) - emptySelection.frame = secondary.view.frame.insetBy(dx: 32, dy: 0) - } else { + guard wide == true else { primary.view.frame = bounds + return + } + let fold = foldGap(in: bounds) + primary.view.frame = CGRect(x: bounds.minX, y: bounds.minY, width: max(0, fold.minX - bounds.minX), height: bounds.height) + secondary.view.frame = CGRect(x: fold.maxX, y: bounds.minY, width: max(0, bounds.maxX - fold.maxX), height: bounds.height) + let hairline = 1 / max(1, view.traitCollection.displayScale) + divider.frame = CGRect(x: fold.midX - hairline / 2, y: bounds.minY, width: hairline, height: bounds.height) + } + + /// Where the columns part: the fold, for an even split around the hinge. + /// Flat, they meet on it. Partly folded, its division region is active + /// and they stop at its margins, keeping controls out of the crease. + /// A display without a fold across the split gets an even split. + private func foldGap(in bounds: CGRect) -> CGRect { + let middle = CGRect(x: bounds.midX, y: bounds.minY, width: 0, height: bounds.height) + guard #available(iOS 27.1, *) else { return middle } + let divisions = view.reservedRegions(kind: .division, options: .includeInactive) + .filter { $0.frame.height > $0.frame.width && $0.frame.minX > bounds.minX && $0.frame.maxX < bounds.maxX } + guard let division = divisions.min(by: { abs($0.frame.midX - bounds.midX) < abs($1.frame.midX - bounds.midX) }) else { + return middle } + return division.isActive ? division.frame : CGRect(x: division.frame.midX, y: bounds.minY, width: 0, height: bounds.height) } private func reconcile() { guard !reconciling, !applying, !transitioning, primary.transitionCoordinator == nil, secondary.transitionCoordinator == nil, let latest, let inherited, view.bounds.width > 0, view.bounds.height > 0, - let feed = latest.children.first(where: { $0.type != "adaptive_detail" }) else { return } - // The window shape survives keyboard resizing and does not depend on - // an interface orientation that may lag a Duo display switch. - let viewport = view.window?.bounds ?? view.bounds - let wantsWide = view.bounds.width >= 820 && viewport.width > viewport.height + let feed = latest.children.first(where: { $0.type != "adaptive_detail" && $0.type != "adaptive_placeholder" }) else { return } + let wantsWide = traitCollection.horizontalSizeClass == .regular // Root-view assignment can schedule another UIKit layout. A bounds-only // layout in the same mode must not assign all hosting roots again. guard appliedRevision != revision || wide != wantsWide else { return } @@ -377,10 +446,14 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC pages[node.id] = page } } + if let placeholderNode = latest.children.first(where: { $0.type == "adaptive_placeholder" }), placeholder == nil { + placeholder = AdaptivePageController(node: placeholderNode, detail: false, environment: inherited) + } guard let feedPage = pages[feed.id] else { return } let detailPages: [UIViewController] = activeDetails.compactMap { pages[$0.id] } + let emptyDetail: [UIViewController] = placeholder.map { [$0] } ?? [] let primaryStack: [UIViewController] = wantsWide ? [feedPage] : [feedPage] + detailPages - let secondaryStack: [UIViewController] = wantsWide ? detailPages : [] + let secondaryStack: [UIViewController] = wantsWide ? (detailPages.isEmpty ? emptyDetail : detailPages) : [] applying = true if migrating { @@ -394,7 +467,6 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC // hosting roots, rather than first mounting them at the old geometry. secondary.view.isHidden = !wantsWide divider.isHidden = !wantsWide - emptySelection.isHidden = !wantsWide || !detailPages.isEmpty layoutColumns() expectedPrimary = primaryStack expectedSecondary = secondaryStack @@ -411,9 +483,10 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC logoName = nil for page in pages.values { page.navigationItem.titleView = nil } } - configureBars(feed: feed, details: details, environment: inherited, wide: wantsWide) + configureBars(feed: feed, details: details, environment: inherited) primary.view.layoutIfNeeded() secondary.view.layoutIfNeeded() + updateHorizontalInsets() applying = false let validIDs = Set([feed.id] + currentHistory) pages = pages.filter { validIDs.contains($0.key) } @@ -466,14 +539,10 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC DispatchQueue.main.async { [weak self] in self?.reconcile() } } - private func configureBars(feed: NativeUINode, details: [NativeUINode], environment: EnvironmentValues, wide: Bool) { + private func configureBars(feed: NativeUINode, details: [NativeUINode], environment: EnvironmentValues) { let tokens = environment.nativeUITheme view.backgroundColor = UIColor(tokens.surface) divider.backgroundColor = UIColor(tokens.outlineVariant) - emptySelection.textColor = UIColor(tokens.onSurfaceVariant) - let font = NativeChromeFontResolver.postScriptName(for: "default") - .flatMap { UIFont(name: $0, size: 15) } ?? UIFont.systemFont(ofSize: 15) - emptySelection.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: font) let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = UIColor(tokens.surface) @@ -482,6 +551,13 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC navigation.navigationBar.standardAppearance = appearance navigation.navigationBar.scrollEdgeAppearance = appearance } + if let placeholder { + // Nothing sits in the empty detail's bar, so it draws no edge. + let bare = appearance.copy() + bare.shadowColor = .clear + placeholder.navigationItem.standardAppearance = bare + placeholder.navigationItem.scrollEdgeAppearance = bare + } guard let feedPage = pages[feed.id] else { return } feedPage.title = "Feed" if #available(iOS 27.2, *) { @@ -542,11 +618,6 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC (stack.arrangedSubviews.last as? UILabel)?.textColor = UIColor(tokens.onSurfaceVariant) } var actions: [UIBarButtonItem] = [] - if wide { - let close = UIBarButtonItem(title: "Close", style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.close)) - close.accessibilityLabel = "Close conversation" - actions.append(close) - } if detail.props.getBool("can_delete") { let delete = UIBarButtonItem(image: UIImage(systemName: "trash"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.deleteConversation)) delete.accessibilityLabel = "Delete commit" @@ -561,7 +632,6 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC @objc private func playGame(_ gesture: UILongPressGestureRecognizer) { if gesture.state == .began { send("game") } } - @objc private func close() { send("close") } @objc private func deleteConversation() { send("delete") } private func send(_ action: String, depth: Int? = nil) { @@ -570,7 +640,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC guard callback != 0 else { return } // These describe the last APPLIED PHP history, not `latest`: a newer // publish may already be queued during an interactive pop. PHP must - // compare both before accepting a delayed back/close notification. + // compare both before accepting a delayed back notification. var payload: [String: Any] = [ "action": action, "expected_depth": logicalDepth, diff --git a/packages/adaptive-layout/src/Components/AdaptivePlaceholder.php b/packages/adaptive-layout/src/Components/AdaptivePlaceholder.php new file mode 100644 index 00000000..a0db5270 --- /dev/null +++ b/packages/adaptive-layout/src/Components/AdaptivePlaceholder.php @@ -0,0 +1,13 @@ + - + + + + + No commit selected + Choose a commit in the log to read its conversation here. + + @foreach ($conversations as $conversation) diff --git a/resources/views/native/partials/commit-row.blade.php b/resources/views/native/partials/commit-row.blade.php index 209350f2..db74e62d 100644 --- a/resources/views/native/partials/commit-row.blade.php +++ b/resources/views/native/partials/commit-row.blade.php @@ -3,6 +3,7 @@ The author's avatar is the node on the rail. A commit carrying on its author's run ($continuesRun) gets a lane dot instead. The segment above the node takes the previous author's lane, the one below takes this author's. $focused draws the commit a page is about: a larger body, its git date, and no link to itself. + In the iOS split workspace the row open beside the log ($selectedCommit) takes the wash, as a thread reply does. Rows in a list carry no press-* feedback. EDGE draws it with a zero-distance DragGesture (NodePressFeedbackModifier in nativephp/mobile), and on iOS that gesture keeps the list from scrolling when a drag starts on the row. @@ -17,7 +18,7 @@ @include('native.partials.commit-row-content') @else - + @include('native.partials.commit-row-content') @endif diff --git a/resources/views/native/partials/thread-reply.blade.php b/resources/views/native/partials/thread-reply.blade.php index d3f6dfdd..df577d77 100644 --- a/resources/views/native/partials/thread-reply.blade.php +++ b/resources/views/native/partials/thread-reply.blade.php @@ -64,7 +64,7 @@ @else @php($commit = $reply) - + diff --git a/tests/Feature/Screens/FeedWorkspaceTest.php b/tests/Feature/Screens/FeedWorkspaceTest.php index 0ae7f173..1d71f26c 100644 --- a/tests/Feature/Screens/FeedWorkspaceTest.php +++ b/tests/Feature/Screens/FeedWorkspaceTest.php @@ -77,6 +77,22 @@ function serveWorkspace(): void $screen->assertSee('Browsable feed')->assertDontSee('Conversation c4'); }); +it('marks the feed row whose conversation is open beside it', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios'); + $background = fn (): string => strtolower(nodeByRef($screen, 'commit-c2')['style']['bg_color']); + $surface = $background(); + + $screen->tap('commit-c2'); + expect($background())->toBe(strtolower((string) theme('wash'))); + + $screen->call('openConversation', 'c3'); + expect($background())->toBe(strtolower((string) theme('wash'))); + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 0, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']])); + expect($background())->toBe($surface)->not->toBe(strtolower((string) theme('wash'))); +}); + it('opens the existing game from the retained feed logo when it is installed', function () { skipWithoutSkia(); serveWorkspace(); @@ -108,7 +124,7 @@ function serveWorkspace(): void expect($screen->instance()->foregroundPane())->toBe($parent); }); -it('refreshes the live feed when closing its selected conversation', function () { +it('refreshes the live feed when going back from its selected conversation', function () { $body = 'Feed before opening a conversation'; serveApi(function (Request $request) use (&$body) { $path = parse_url($request->url(), PHP_URL_PATH); @@ -120,10 +136,10 @@ function serveWorkspace(): void }); }); $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); - $body = 'Feed refreshed after closing'; + $body = 'Feed refreshed after going back'; - $screen->call('hostChanged', json_encode(['action' => 'close', 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']])) - ->assertSee('Feed refreshed after closing'); + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 0, 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']])) + ->assertSee('Feed refreshed after going back'); expect($screen->get('conversations'))->toBe([]); }); @@ -210,7 +226,7 @@ function serveWorkspace(): void expect(kept('commits/c3')['data']['body'])->toBe('Conversation c3'); }); -it('accepts consecutive ancestor back events but rejects a stale close and divergent back', function () { +it('accepts consecutive ancestor back events but rejects a stale delete and divergent back', function () { serveWorkspace(); $screen = Native::test(FeedWorkspace::class, platform: 'ios') ->call('openConversation', 'c2') @@ -219,7 +235,8 @@ function serveWorkspace(): void $event = ['expected_depth' => 3, 'expected_revision' => 3, 'expected_history' => ['conversation-1', 'conversation-2', 'conversation-3']]; $screen->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 2])) ->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 1])) - ->call('hostChanged', json_encode([...$event, 'action' => 'close'])); + ->call('hostChanged', json_encode([...$event, 'action' => 'delete'])) + ->assertNativeNotCalled('Dialog.Alert'); expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); $screen->call('openConversation', 'c5', [], true) @@ -330,7 +347,7 @@ function serveWorkspace(): void expect($screen->get('navigationRevision'))->toBe(2); $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); expect(findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet')['props']['visible'])->toBeTrue(); -})->with(['back', 'close', 'delete']); +})->with(['back', 'delete']); it('rejects old native commands after a newer same-conversation intent', function (string $action) { serveWorkspace(); @@ -350,7 +367,7 @@ function serveWorkspace(): void $screen->call('hostChanged', json_encode([...$event, 'expected_revision' => 3])) ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Delete this commit?'); } -})->with(['back', 'close', 'delete']); +})->with(['back', 'delete']); it('synchronizes successful deletion from either pane without replacing the feed', function (bool $fromDetail) { $deleted = false; From b2b7fbc1481bedb5c49ea9655e80801a9102614f Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 18:13:48 -0800 Subject: [PATCH 26/42] Scope the onboarding lockup's window width to iOS, centre the reply bar's readable column, and label the muted sheet button on its circle Android resolves a percentage against the parent, so the 40% variant shrank its lockup; it is iOS-only now. Android seats a bottom bar's root at the leading edge, so the reply bar keeps a full-width root and centres a readable child. An a11y-label on a stack already hides its children from VoiceOver, so the muted sheet button needs no wrapper. --- .../views/native/commit-detail.blade.php | 89 ++++++++++--------- resources/views/native/onboarding.blade.php | 11 +-- .../partials/sheet-bar-button.blade.php | 17 ++-- tests/Feature/Screens/CommitDetailTest.php | 16 ++++ tests/Feature/Screens/OnboardingTest.php | 11 ++- tests/Pest.php | 4 +- 6 files changed, 88 insertions(+), 60 deletions(-) diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index 230ccc95..df5ac7fe 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -127,51 +127,54 @@ the field on Android. --}} - + - {{-- - The people to mention keep a slot above the field, empty and no height when there's no one, so the - field's row never moves. Android rebuilds a row that moves among its siblings, field and all, which - drops the keyboard. - --}} - @php($mentioning = ($mention['field'] ?? null) === 'reply' && ($mentionPeople !== [] || $mentionOrganizations !== [])) - - @include('native.partials.mention-suggestions', ['field' => 'reply', 'label' => 'Mention a developer']) - - - @include('native.partials.composer-plate', [ - 'ref' => 'reply', - 'field' => 'native.partials.reply-field', - 'tools' => [['ref' => 'reply-expand', 'icon' => \App\Icons\Lucide::Maximize2, 'label' => 'Reply with photos or a preview', 'press' => "replyInSheet('{$commit->id}')"]], - ]) - @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) - Reply - - @if (\App\Devnull\Data\CommitBody::hasOversizedUrl($reply)) - Each link must not be greater than {{ \App\Devnull\Data\BodyLink::MaxLength }} characters. - @endif - {{-- - A reply stashed when you left the commit comes back with the web's stash@{0} and drop under it. - Photos attached in the sheet show as a count on the left, which opens the sheet on them, and Reply - sends them. - --}} - @if ($replyRestored || $replyImages !== []) - - @if ($replyImages !== []) - @php($photoCount = \App\Devnull\Display::count(count($replyImages), 'photo', 'photos')) - - - - {{ $photoCount }} - - - - @endif - @if ($replyRestored) - @include('native.partials.stash-label', ['ref' => 'reply-stash', 'drop' => 'dropReplyStash', 'size' => 'text-xs']) - @endif + {{-- The readable width goes on a child: Android seats the bar's root at the leading edge of a full-width box, so the root stays full width and centres this. --}} + + {{-- + The people to mention keep a slot above the field, empty and no height when there's no one, so the + field's row never moves. Android rebuilds a row that moves among its siblings, field and all, which + drops the keyboard. + --}} + @php($mentioning = ($mention['field'] ?? null) === 'reply' && ($mentionPeople !== [] || $mentionOrganizations !== [])) + + @include('native.partials.mention-suggestions', ['field' => 'reply', 'label' => 'Mention a developer']) - @endif + + @include('native.partials.composer-plate', [ + 'ref' => 'reply', + 'field' => 'native.partials.reply-field', + 'tools' => [['ref' => 'reply-expand', 'icon' => \App\Icons\Lucide::Maximize2, 'label' => 'Reply with photos or a preview', 'press' => "replyInSheet('{$commit->id}')"]], + ]) + @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) + Reply + + @if (\App\Devnull\Data\CommitBody::hasOversizedUrl($reply)) + Each link must not be greater than {{ \App\Devnull\Data\BodyLink::MaxLength }} characters. + @endif + {{-- + A reply stashed when you left the commit comes back with the web's stash@{0} and drop under it. + Photos attached in the sheet show as a count on the left, which opens the sheet on them, and Reply + sends them. + --}} + @if ($replyRestored || $replyImages !== []) + + @if ($replyImages !== []) + @php($photoCount = \App\Devnull\Display::count(count($replyImages), 'photo', 'photos')) + + + + {{ $photoCount }} + + + + @endif + @if ($replyRestored) + @include('native.partials.stash-label', ['ref' => 'reply-stash', 'drop' => 'dropReplyStash', 'size' => 'text-xs']) + @endif + + @endif + @endif diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index 4cba8d57..892a13a8 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -8,13 +8,14 @@ --}} {{-- - The lockup, two thirds of the screen wide at its own 15:4, centred in its space. Side by side it has to fit its half - less the buttons' 24pt margin on each side. EDGE resolves a percentage against the window, not the parent, and a - variant can't swap a width for `w-full`, so it's 40% of the window: at most that room from 840pt up (336pt of 340pt - there, 380pt of 395pt on the inner display). + The lockup, two thirds wide at its own 15:4, centred in its space. Side by side it has to fit its half + less the buttons' 24pt margin on each side. iOS resolves a percentage against the window, not the parent, and a + variant can't swap a width for `w-full`, so there it's 40% of the window: at most that room from 840pt up (336pt + of 340pt there, 380pt of 395pt on the inner display). Android resolves it against the parent, which is already + the half, so it keeps two thirds. --}} - + diff --git a/resources/views/native/partials/sheet-bar-button.blade.php b/resources/views/native/partials/sheet-bar-button.blade.php index 86ae3bf8..2d645dfc 100644 --- a/resources/views/native/partials/sheet-bar-button.blade.php +++ b/resources/views/native/partials/sheet-bar-button.blade.php @@ -18,15 +18,12 @@ @else - {{-- The label sits on a container around the circle, as the pressable above does. --}} @php($mutedLabel = $loading ? $label : $label.', unavailable') - - - @if ($loading) - - @else - - @endif - - + + @if ($loading) + + @else + + @endif + @endif diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 28fe01fe..1d03c485 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -5,6 +5,7 @@ use App\Models\PendingWrite; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Native\Mobile\Edge\Enums\AlignItems; use Native\Mobile\Events\Alert\ButtonPressed; use Native\Mobile\Testing\Native; @@ -764,6 +765,21 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): ->and(nodeByRef($screen, 'reply-plate')['props']['glass'] ?? null)->toBe(1); }); +it("keeps the reply bar's root full width and centres a readable column in it, for Android's full-width bar box", function (string $platform) { + fakeThread(); + + $screen = Native::visit('/log/commits/c2', platform: $platform); + $bar = nodeByRef($screen, 'reply-bar'); + $content = nodeByRef($screen, 'reply-content'); + $variant = json_decode($content['props']['_variants'], true)[0]; + + expect($bar['props'])->not->toHaveKey('_variants') + ->and($bar['layout'])->toMatchArray(['width' => 'fill', 'align_items' => AlignItems::Center->value]) + ->and($variant)->toMatchArray(['min' => 840, 'layout' => ['width' => 672]]) + ->and(childRefs($screen, 'reply-bar'))->toBe([null, 'reply-content']) + ->and(childRefs($screen, 'reply-content'))->toContain('reply-mention-slot', 'reply-row'); +})->with(['ios', 'android']); + it('ends the page above the reply bar, and further as the draft grows', function () { fakeThread(replies: [replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'The last reply'])]); diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index bfb68885..85d1b571 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -145,7 +145,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent it("keeps the lockup inside its half, with the buttons' margin, once the window is wide enough to put them side by side", function (int $window) { signInBridge(testSeed()); - $screen = Native::visit('/onboarding'); + $screen = Native::visit('/onboarding', platform: 'ios'); $variant = fn (array $node): array => json_decode($node['props']['_variants'], true)[0]; $logo = $variant(nodeByRef($screen, 'logo')); $margin = $variant(nodeByRef($screen, 'logo-space'))['layout']['padding'][1]; @@ -159,6 +159,15 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent 'the inner display held sideways' => 951, ]); +it("leaves Android's lockup at two thirds of its half, since there a percentage is of the parent and not the window", function () { + signInBridge(testSeed()); + + $logo = nodeByRef(Native::visit('/onboarding', platform: 'android'), 'logo'); + + expect($logo['props'])->not->toHaveKey('_variants') + ->and($logo['layout']['width'])->toBe('67%'); +}); + it('keeps the same key across launches', function () { signInBridge(testSeed()); diff --git a/tests/Pest.php b/tests/Pest.php index a442db23..65830772 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -774,7 +774,9 @@ function sheetButtonEnabled(TestableComponent $screen, string $ref): bool */ function sheetButtonCircle(TestableComponent $screen, string $ref): array { - return nodeByRef($screen, $ref)['children'][0]; + $button = nodeByRef($screen, $ref); + + return $button['type'] === 'pressable' ? $button['children'][0] : $button; } /** From 0403f43002414b2645592393afc38b1e5ef2756f Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 18:15:33 -0800 Subject: [PATCH 27/42] Lay out the Duo workspace when the fold gap moves The hinge handler re-read layout only when the hinge status changed, assuming the division region turns active at the same moment. It now compares the fold gap with the one last applied and lays out only when it differs. The placeholder tag shares the detail renderer instead of a copy, the safe-area comment describes the vertical bar as observed behavior, and the selected-row docblocks cover the stacked layout too. --- app/NativeComponents/FeedWorkspace.php | 2 +- app/NativeComponents/Log.php | 2 +- packages/adaptive-layout/nativephp.json | 2 +- .../resources/ios/AdaptiveFeedRenderer.swift | 35 +++++++------------ .../native/partials/commit-row.blade.php | 3 +- 5 files changed, 17 insertions(+), 27 deletions(-) diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php index cd55986f..70e5dfde 100644 --- a/app/NativeComponents/FeedWorkspace.php +++ b/app/NativeComponents/FeedWorkspace.php @@ -153,7 +153,7 @@ public function hostChanged(string $event): void } /** - * The commit opened from the feed, which its row marks while it is open beside it. + * The commit of the conversation open from the log, which its row marks. */ public function selectedCommit(): ?string { diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index b1e7c0dc..0e853552 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -166,7 +166,7 @@ class Log extends NativeComponent public bool $embedded = false; /** - * The commit open beside the log in a split workspace, whose row shows as selected. + * The commit of the conversation open from the log, whose row shows as selected. */ public ?string $selectedCommit = null; diff --git a/packages/adaptive-layout/nativephp.json b/packages/adaptive-layout/nativephp.json index 68438fff..688a7627 100644 --- a/packages/adaptive-layout/nativephp.json +++ b/packages/adaptive-layout/nativephp.json @@ -26,7 +26,7 @@ "type": "adaptive_placeholder", "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptivePlaceholder", "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptivePlaceholder", - "ios_renderer": "AdaptivePlaceholderRenderer", + "ios_renderer": "AdaptiveDetailRenderer", "self_closing": false } ] diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 8355a29b..259eb30b 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -9,10 +9,10 @@ struct AdaptiveFeedRenderer: View { var body: some View { AdaptiveFeedHost(node: node) .frame(maxWidth: .infinity, maxHeight: .infinity) - // The workspace fills the window and its pages inset themselves, as - // in any UIKit container. UIKit only moves a navigation bar into the - // vertical bar when its controller reaches the bar's edge; inset - // here, the nested bars stay horizontal on the outer display. + // The workspace fills the window and its pages inset themselves. + // On the outer display, nested navigation bars were observed to + // move into the vertical bar only when their controller reaches + // its edge; inset here, they stayed horizontal. .ignoresSafeArea(.container, edges: .all) .background { themeStore.resolve(for: colorScheme).surface @@ -34,16 +34,6 @@ struct AdaptiveDetailRenderer: View { } } -struct AdaptivePlaceholderRenderer: View { - let node: NativeUINode - - var body: some View { - ForEach(node.children) { child in - NodeView(node: child).equatable() - } - } -} - private struct AdaptiveFeedHost: UIViewControllerRepresentable { let node: NativeUINode @@ -238,7 +228,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC private let divider = UIView() private var pages: [Int: AdaptivePageController] = [:] private var placeholder: AdaptivePageController? - private var hingeStatus: Int? + private var appliedFoldGap: CGRect? private var latest: NativeUINode? private var inherited: EnvironmentValues? private var wide: Bool? @@ -276,13 +266,11 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC self.view.setNeedsLayout() } if #available(iOS 27.1, *) { - // The fold's division region turns active as the hinge leaves - // flat; angle updates in between do not move the columns. - view.addInteraction(UIHingeInteraction { [weak self] _, update in - guard let self else { return } - let status = update.hinge?.status.rawValue - guard status != self.hingeStatus else { return } - self.hingeStatus = status + // Hinge updates say nothing about when the division region turns + // active, so re-read the gap and lay out only when it moves. + view.addInteraction(UIHingeInteraction { [weak self] _, _ in + guard let self, self.wide == true, + self.foldGap(in: self.view.bounds) != self.appliedFoldGap else { return } self.view.setNeedsLayout() }) } @@ -361,11 +349,11 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC private func updateHorizontalInsets() { let safe = view.safeAreaLayoutGuide.layoutFrame + let isRightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft for navigation in [primary, secondary] where !navigation.view.isHidden { let column = navigation.view.frame let leading = max(0, min(column.width, safe.minX - column.minX)) let trailing = max(0, min(column.width, column.maxX - safe.maxX)) - let isRightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft for case let page as AdaptivePageController in navigation.viewControllers { page.reserveHorizontal(leading: isRightToLeft ? trailing : leading, trailing: isRightToLeft ? leading : trailing) } @@ -379,6 +367,7 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC return } let fold = foldGap(in: bounds) + appliedFoldGap = fold primary.view.frame = CGRect(x: bounds.minX, y: bounds.minY, width: max(0, fold.minX - bounds.minX), height: bounds.height) secondary.view.frame = CGRect(x: fold.maxX, y: bounds.minY, width: max(0, bounds.maxX - fold.maxX), height: bounds.height) let hairline = 1 / max(1, view.traitCollection.displayScale) diff --git a/resources/views/native/partials/commit-row.blade.php b/resources/views/native/partials/commit-row.blade.php index db74e62d..d8462e04 100644 --- a/resources/views/native/partials/commit-row.blade.php +++ b/resources/views/native/partials/commit-row.blade.php @@ -3,7 +3,8 @@ The author's avatar is the node on the rail. A commit carrying on its author's run ($continuesRun) gets a lane dot instead. The segment above the node takes the previous author's lane, the one below takes this author's. $focused draws the commit a page is about: a larger body, its git date, and no link to itself. - In the iOS split workspace the row open beside the log ($selectedCommit) takes the wash, as a thread reply does. + In the iOS workspace the row of the conversation open from the log ($selectedCommit) takes the wash, as a thread + reply does. Rows in a list carry no press-* feedback. EDGE draws it with a zero-distance DragGesture (NodePressFeedbackModifier in nativephp/mobile), and on iOS that gesture keeps the list from scrolling when a drag starts on the row. From c0093578d062a7ead355761ffad408cbec108ca4 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 18:20:09 -0800 Subject: [PATCH 28/42] Have the pager put the profile header beside its pages once expanded The two-column profile drew the header twice, a hidden copy for each layout, so every profile, phones included, sent it twice on every render and every ref in it appeared twice. The pager now draws its own header beside the pages from the `expanded` breakpoint up (iOS), splitting at the fold it reads from reservedRegions, or evenly without one, so the header is in the tree once and the 11:9 weights and the hairline go away. Android keeps the header above the tabs. --- composer.json | 3 +- composer.lock | 2 +- ...ivephp-mobile-ui-pager-header-beside.patch | 187 ++++++++++++++++++ .../native/partials/profile-header.blade.php | 9 - resources/views/native/profile.blade.php | 27 +-- tests/Feature/Screens/ProfilePagerTest.php | 6 +- 6 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 patches/nativephp-mobile-ui-pager-header-beside.patch diff --git a/composer.json b/composer.json index 09456f27..731812ee 100644 --- a/composer.json +++ b/composer.json @@ -127,7 +127,8 @@ "Hidden navigation bars release the top layout and hit region for native child hosts": "patches/nativephp-mobile-hidden-nav-hit-area.patch" }, "nativephp/mobile-ui": { - "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch" + "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch", + "Pager: header-beside draws a horizontal pager's header in its own column beside the pages from a breakpoint up, split at the fold (iOS)": "patches/nativephp-mobile-ui-pager-header-beside.patch" } } }, diff --git a/composer.lock b/composer.lock index 9dc28273..ee18745a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ccfe76ed47a4e12a171397ada6d01a6", + "content-hash": "7c5ede59979ffef2bd5c093b8b43903d", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/patches/nativephp-mobile-ui-pager-header-beside.patch b/patches/nativephp-mobile-ui-pager-header-beside.patch new file mode 100644 index 00000000..586b7401 --- /dev/null +++ b/patches/nativephp-mobile-ui-pager-header-beside.patch @@ -0,0 +1,187 @@ +--- a/src/Elements/Pager.php ++++ b/src/Elements/Pager.php +@@ -3,8 +3,10 @@ + namespace Native\Mobile\UI\Elements; + + use Illuminate\Support\Str; ++use InvalidArgumentException; + use Native\Mobile\Edge\CallbackRegistry; + use Native\Mobile\Edge\Element; ++use Native\Mobile\Edge\TailwindParser; + use Native\Mobile\UI\Concerns\ResolvesColorValues; + use Native\Mobile\UI\Theme; + +@@ -71,6 +73,8 @@ + * that scrolls up with the page showing while the tabs pin under the nav + * bar: see {@see PagerHeader}. A {@see PagerOverlay} hangs under the tabs + * over the pages. Neither counts as a page, and neither is windowed. ++ * `header-beside="expanded"` moves the header beside the pages once the ++ * window is that wide (see headerBeside()). + * + * The page index rides the tab-change transport, so a single feed is + * capped at 32,767 pages per screen. +@@ -126,6 +130,10 @@ + if ($settle !== null) { + $this->reportOnSettle(filter_var($settle, FILTER_VALIDATE_BOOLEAN)); + } ++ $beside = $attrs['header_beside'] ?? $attrs['headerBeside'] ?? $attrs['header-beside'] ?? null; ++ if ($beside !== null) { ++ $this->headerBeside($beside); ++ } + $cb = $attrs['on_page_change'] ?? $attrs['onPageChange'] ?? $attrs['on-page-change'] ?? null; + if ($cb !== null) { + $this->onPageChange($cb); +@@ -196,7 +204,29 @@ + public function reportOnSettle(bool $value = true): static + { + $this->pagerProps['report_on_settle'] = $value; ++ ++ return $this; ++ } ++ ++ /** ++ * From a breakpoint (`expanded`, `lg`, … as the responsive classes name ++ * them) or a width in points up, the header scrolls by itself in a ++ * leading column and the tabs and pages fill the trailing one, split at ++ * a foldable's fold or evenly elsewhere. Narrower, it is above the tabs ++ * as usual. Native decides against the live window width, as it does ++ * for the classes, so the header is sent once for either layout. iOS ++ * only so far: Android keeps the header above the tabs at any width. ++ */ ++ public function headerBeside(string|int|float $from): static ++ { ++ $width = is_numeric($from) ? (float) $from : TailwindParser::breakpointMinWidth($from); + ++ if ($width === null) { ++ throw new InvalidArgumentException("Unknown breakpoint [{$from}] for header-beside."); ++ } ++ ++ $this->pagerProps['header_beside_from'] = $width; ++ + return $this; + } + +--- a/src/Elements/PagerHeader.php ++++ b/src/Elements/PagerHeader.php +@@ -26,6 +26,10 @@ + * always finds the header at the height it left it. Once the header has + * gone, each page keeps its own place. A page too short to scroll the + * header away gets room at its end to do so. ++ * ++ * With `header-beside` on the pager, a wide enough window has the header in ++ * a column of its own beside the pages instead, and the pages' lists draw ++ * only their own rows (see {@see Pager::headerBeside()}). + */ + class PagerHeader extends Element + { +--- a/resources/ios/NativeUIPagerRenderer.swift ++++ b/resources/ios/NativeUIPagerRenderer.swift +@@ -26,6 +26,10 @@ + struct NativeUIPagerRenderer: View { + let node: NativeUINode + ++ /// The window width responsive variants resolve against, so the header ++ /// moves beside the pages at the same width an `expanded:` class applies. ++ @Environment(\.windowWidth) private var windowWidth ++ + var body: some View { + let p = node.props + let horizontal = p.getBool("horizontal") +@@ -49,7 +53,13 @@ + return map + }() + +- PagerBody( ++ // Only a horizontal pager's pages scroll up and down under a header. ++ let header = horizontal ? node.children.first { $0.type == "pager_header" } : nil ++ let besideFrom = CGFloat(p.getFloat("header_beside_from")) ++ let beside = header != nil && besideFrom > 0 && windowWidth >= besideFrom ++ ++ PagerHeaderBeside(header: beside ? header : nil) { ++ PagerBody( + nodeId: node.id, + count: pageCount, + loaded: count, +@@ -60,12 +70,12 @@ + pageByIndex: pageByIndex, + placeholders: p.getStringList("placeholders"), + tabs: PagerTabs(props: p), +- // Only a horizontal pager's pages scroll up and down under a header. +- header: horizontal ? node.children.first { $0.type == "pager_header" } : nil, ++ header: beside ? nil : header, + overlay: node.children.first { $0.type == "pager_overlay" }, + background: PagerColor(light: node.style?.bgColor, dark: p.has("dark_bg_color") ? p.getColor("dark_bg_color", default: 0) : nil), + a11yLabel: p.getString("a11y_label") +- ) ++ ) ++ } + } + } + +@@ -880,6 +890,66 @@ + } + + // MARK: - Header ++ ++/// A pager with its header beside the pages rather than above them, from ++/// `header_beside_from` points of window up: the header scrolls by itself ++/// in the leading column, and the tabs and pages fill the trailing one. ++/// Without a header this is just the pages, so the pager keeps its state ++/// when the window narrows and the header goes back above the tabs. ++private struct PagerHeaderBeside: View { ++ let header: NativeUINode? ++ @ViewBuilder let pages: Pages ++ ++ @State private var split = PagerSplit.even(0) ++ ++ var body: some View { ++ HStack(spacing: 0) { ++ if let header { ++ ScrollView(.vertical) { ++ PagerChildren(node: header) ++ .frame(maxWidth: .infinity, alignment: .leading) ++ } ++ .frame(width: split.leading) ++ Color.clear ++ .frame(width: split.gap) ++ .accessibilityHidden(true) ++ } ++ pages ++ } ++ .onGeometryChange(for: PagerSplit.self) { PagerSplit(in: $0) } action: { split = $0 } ++ } ++} ++ ++/// Where a pager splits into its header and its pages. On a foldable it is ++/// the fold, flat or not, which neither column draws in: Apple's guidance ++/// keeps controls out of it. Anywhere else, or when the fold isn't across ++/// the pager, the columns share the width evenly. ++private struct PagerSplit: Equatable { ++ var leading: CGFloat ++ var gap: CGFloat ++ ++ static func even(_ width: CGFloat) -> PagerSplit { ++ PagerSplit(leading: (width / 2).rounded(), gap: 0) ++ } ++ ++ init(leading: CGFloat, gap: CGFloat) { ++ self.leading = leading ++ self.gap = gap ++ } ++ ++ init(in proxy: GeometryProxy) { ++ let width = proxy.size.width ++ self = .even(width) ++ #if compiler(>=6.4) ++ if #available(iOS 27.1, *) { ++ let folds = proxy.reservedRegions(kind: .division, options: .includeInactive) ++ if let fold = folds.first(where: { $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < width }) { ++ self = PagerSplit(leading: fold.frame.minX, gap: fold.frame.width) ++ } ++ } ++ #endif ++ } ++} + + /// A colour as PHP sent it, light and dark, nil where it sent none. + struct PagerColor: Equatable { diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index 5a51bf2c..f73fc2ae 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -3,13 +3,8 @@ lane, Follow (or, on your own, Edit on GitHub), who they are, what GitHub says about them, their counts and, in pastéis mode, the pastéis they've been given, then their activity, streaks and achievements. It scrolls up with the tab you're on until the tabs pin under the nav bar. - - Once the window is expanded the pager's copy is hidden, as profile.blade.php draws this header again as the - leading column. A hairline stays in its place: the pager's page lists stop drawing when their header row has - no height at all. --}} - {{-- @@ -146,7 +141,3 @@ @endif @endif -@if ($inPager ?? false) - diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index 061ce89e..c6713850 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -10,28 +10,20 @@ The pager or the list stays alone in here, so the banners above coming and going never add a node beside it. Keyed so nothing above it, like the image viewer opening, gives it a new id and the pager a new list at the top. --}} - + @if ($profile && ! $unavailable) @php($user = $profile->user) - {{-- - Once the window is expanded (a foldable's inner display) the profile reflows into two columns: who they are, - their activity and their level in the leading one, scrolling by itself, and the tabs and their commits in - the trailing one. The header below, which the pager draws above its tabs on a phone, is hidden then, so - every control is on screen once in either layout. - - The fold is in the middle of the display, but the window stops short of it by the vertical bar on the - trailing edge (84pt), which puts the middle of the window left of the fold. The columns are weighted 11:9 so - the gap between them, 48pt wide, is centred on the fold rather than on the window. - --}} - {{-- Commits and Replies as pages you swipe between, under the tabs the pager draws, as on the Log, with the underline in your colour. The header goes to the pager too, which draws it at the top of the page you're on, so it scrolls up with it until the tabs pin under the nav bar. It stays where it is while you swipe, and the pages keep level with it. + Once the window is expanded (a foldable's inner display) the pager on iOS draws the header beside the pages + instead: who they are, their activity and their level scroll by themselves in the leading column, and the tabs + and their commits fill the trailing one. The columns meet at the fold, or halve the width without one. Android + keeps it above the tabs. The header is sent once either way, so every control is on screen once. + The pill hangs under the tabs wherever they are. Keyed so the banners above coming and going don't make it a new pager, with new lists. --}} @@ -40,9 +32,8 @@ ref="profile-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface expanded:h-full" - :flexGrow="9" - :flexBasis="0" + header-beside="expanded" + class="w-full flex-1 bg-theme-surface" :count="count($pages)" :page="$pageIndex" on-page-change="showPage" @@ -56,7 +47,7 @@ class="w-full flex-1 bg-theme-surface expanded:h-full" a11y-label="Commits and replies" > - @include('native.partials.profile-header', ['inPager' => true]) + @include('native.partials.profile-header') @foreach ($pages as $page) diff --git a/tests/Feature/Screens/ProfilePagerTest.php b/tests/Feature/Screens/ProfilePagerTest.php index 38d67ec0..e0d0045e 100644 --- a/tests/Feature/Screens/ProfilePagerTest.php +++ b/tests/Feature/Screens/ProfilePagerTest.php @@ -1,6 +1,7 @@ changeTab('showPage', -1)->assertSet('tab', 'commits'); }); -it('draws the header once, for the pager to put above its tabs, and hangs the pill under them', function () { +it('draws the header once, for the pager to put above its tabs or beside them once the window is expanded, and hangs the pill under them', function () { holdRefreshes(); keepProfile('ada', [commitJson(['id' => 'c1'])], [commitJson(['id' => 'r1'])]); @@ -91,7 +92,10 @@ function profileList(TestableComponent $screen, string $tab): array $hasRef = fn (string $ref): Closure => fn (array $node): bool => ($node['ref'] ?? null) === $ref; expect(array_column($pager['children'], 'type'))->toBe(['pager_header', 'column', 'column', 'pager_overlay']) + ->and($pager['props']['header_beside_from'])->toBe(TailwindParser::breakpointMinWidth('expanded')) ->and(findNode($pager['children'][0], $hasRef('profile-header')))->not->toBeNull() + ->and(findNodes($screen->tree(), $hasRef('profile-header')))->toHaveCount(1) + ->and(findNodes($screen->tree(), $hasRef('follow')))->toHaveCount(1) ->and(findNode(showingProfile($screen), $hasRef('profile-header')))->toBeNull() ->and(findNode(profileList($screen, 'replies'), $hasRef('profile-header')))->toBeNull() ->and(findNode($pager['children'][3], $hasRef('new-commits-slot')))->not->toBeNull(); From 17a4d688fee68cf4f8fdce5438d841aa58f5e19d Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 18:26:52 -0800 Subject: [PATCH 29/42] Wrap the reply bar's readable-column comment and indent its closing tag --- resources/views/native/commit-detail.blade.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index df5ac7fe..473a4b58 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -129,7 +129,10 @@ - {{-- The readable width goes on a child: Android seats the bar's root at the leading edge of a full-width box, so the root stays full width and centres this. --}} + {{-- + The readable width goes on a child: Android seats the bar's root at the leading edge of a + full-width box, so the root stays full width and centres this. + --}} {{-- The people to mention keep a slot above the field, empty and no height when there's no one, so the @@ -174,7 +177,7 @@ @endif @endif - + @endif From 7f38a2fb2528bdd3c392f02d6c2826ba2893d36d Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 18:32:02 -0800 Subject: [PATCH 30/42] Keep the pager whole when its header moves beside the pages Crossing the header-beside breakpoint (unfolding, Split View) switched the pager between two layouts, so SwiftUI rebuilt its paging scroll view and every page's list. The pager now has one layout that shows or hides the header column and the tab row around the same scroll view, and the header's scroll tracking forgets the lists while the header is beside them, so it finds them again where they are once it is back. The columns now meet on the fold's midline while the device is flat and stop at the fold's margins only when it is partly folded, as the feed workspace does. The breakpoint falls back to the available width before the window has one, as the responsive classes do, so a cold start lays out once. The reservedRegions call no longer needs a compiler gate. --- ...ivephp-mobile-ui-pager-header-beside.patch | 186 +++++++++++++----- 1 file changed, 136 insertions(+), 50 deletions(-) diff --git a/patches/nativephp-mobile-ui-pager-header-beside.patch b/patches/nativephp-mobile-ui-pager-header-beside.patch index 586b7401..2f8f2b12 100644 --- a/patches/nativephp-mobile-ui-pager-header-beside.patch +++ b/patches/nativephp-mobile-ui-pager-header-beside.patch @@ -76,86 +76,148 @@ { --- a/resources/ios/NativeUIPagerRenderer.swift +++ b/resources/ios/NativeUIPagerRenderer.swift -@@ -26,6 +26,10 @@ +@@ -26,6 +26,11 @@ struct NativeUIPagerRenderer: View { let node: NativeUINode + /// The window width responsive variants resolve against, so the header + /// moves beside the pages at the same width an `expanded:` class applies. + @Environment(\.windowWidth) private var windowWidth ++ @Environment(\.availableWidth) private var availableWidth + var body: some View { let p = node.props let horizontal = p.getBool("horizontal") -@@ -49,7 +53,13 @@ +@@ -49,6 +54,14 @@ return map }() -- PagerBody( + // Only a horizontal pager's pages scroll up and down under a header. + let header = horizontal ? node.children.first { $0.type == "pager_header" } : nil + let besideFrom = CGFloat(p.getFloat("header_beside_from")) -+ let beside = header != nil && besideFrom > 0 && windowWidth >= besideFrom ++ // As NodeView resolves the classes: the window has no width yet on ++ // the first frame. ++ let width = windowWidth > 0 ? windowWidth : availableWidth ++ let beside = header != nil && besideFrom > 0 && width >= besideFrom + -+ PagerHeaderBeside(header: beside ? header : nil) { -+ PagerBody( + PagerBody( nodeId: node.id, count: pageCount, - loaded: count, -@@ -60,12 +70,12 @@ +@@ -60,8 +73,8 @@ pageByIndex: pageByIndex, placeholders: p.getStringList("placeholders"), tabs: PagerTabs(props: p), - // Only a horizontal pager's pages scroll up and down under a header. - header: horizontal ? node.children.first { $0.type == "pager_header" } : nil, + header: beside ? nil : header, ++ besideHeader: beside ? header : nil, overlay: node.children.first { $0.type == "pager_overlay" }, background: PagerColor(light: node.style?.bgColor, dark: p.has("dark_bg_color") ? p.getColor("dark_bg_color", default: 0) : nil), a11yLabel: p.getString("a11y_label") -- ) -+ ) -+ } - } - } +@@ -89,6 +102,9 @@ + let tabs: PagerTabs? + /// The `pager_header`, which each page's list draws above the tabs. + let header: NativeUINode? ++ /// The `pager_header` instead drawn in a column of its own beside the ++ /// pages, once the window is wide enough (`header_beside_from`). ++ let besideHeader: NativeUINode? + /// The `pager_overlay`, laid over the pages under the tabs. + let overlay: NativeUINode? + /// The pager's own background, behind the tabs a header pins. +@@ -116,6 +132,8 @@ + @State private var progress: PagerProgress + /// Where the header is, and each page's list, when there's a header. + @State private var headerScroll = PagerHeaderScroll() ++ /// Where the header beside the pages ends and the pages begin. ++ @State private var split = PagerSplit.even(0) + /// How a horizontal pager gets back on its page after iOS resizes it + /// while the app is in the background (see PagerResume). + @State private var resume: PagerResume +@@ -127,7 +145,7 @@ + /// long feed never fetches stills it may never reach. + static let placeholderRadius = 4 -@@ -880,6 +890,66 @@ - } +- init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, header: NativeUINode?, overlay: NativeUINode?, background: PagerColor, a11yLabel: String) { ++ init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, header: NativeUINode?, besideHeader: NativeUINode?, overlay: NativeUINode?, background: PagerColor, a11yLabel: String) { + self.nodeId = nodeId + self.count = count + self.loaded = loaded +@@ -139,6 +157,7 @@ + self.placeholders = placeholders + self.tabs = tabs + self.header = header ++ self.besideHeader = besideHeader + self.overlay = overlay + self.background = background + self.a11yLabel = a11yLabel +@@ -150,27 +169,47 @@ + _resume = State(initialValue: PagerResume(page: requestedPage, count: count)) + } - // MARK: - Header -+ -+/// A pager with its header beside the pages rather than above them, from -+/// `header_beside_from` points of window up: the header scrolls by itself -+/// in the leading column, and the tabs and pages fill the trailing one. -+/// Without a header this is just the pages, so the pager keeps its state -+/// when the window narrows and the header goes back above the tabs. -+private struct PagerHeaderBeside: View { -+ let header: NativeUINode? -+ @ViewBuilder let pages: Pages -+ -+ @State private var split = PagerSplit.even(0) -+ -+ var body: some View { ++ /// One layout whether the header is beside the pages, in them or absent, ++ /// so crossing `header_beside_from` (unfolding, Split View) shows or ++ /// hides the column and the tab row around the same paging scroll view ++ /// instead of rebuilding it and every page's list. + var body: some View { +- if header != nil { +- // The pages' lists draw the header and the tabs themselves, so +- // they scroll with the page (see NativeUIPagerHeader). +- pager +- .overlay(alignment: .top) { overlayView } +- .modifier(PagerA11yLabelModifier(label: a11yLabel)) +- .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in +- headerScroll.pageWidth = frame.width +- headerScroll.pagerMinY = frame.minY + HStack(spacing: 0) { -+ if let header { ++ if let besideHeader { ++ // The header scrolls by itself in the leading column, and ++ // the tabs and pages fill the trailing one. + ScrollView(.vertical) { -+ PagerChildren(node: header) ++ PagerChildren(node: besideHeader) + .frame(maxWidth: .infinity, alignment: .leading) -+ } + } +- } else { + .frame(width: split.leading) + Color.clear + .frame(width: split.gap) + .accessibilityHidden(true) + } -+ pages -+ } + VStack(spacing: 0) { +- if let tabs { ++ // Under a header the pages' lists draw the header and the ++ // tabs themselves, so they scroll with the page (see ++ // NativeUIPagerHeader). ++ if let tabs, header == nil { + PagerTabStrip(tabs: tabs, progress: progress, select: selectTab) + } + pager + .overlay(alignment: .top) { overlayView } + .modifier(PagerA11yLabelModifier(label: a11yLabel)) ++ .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in ++ headerScroll.pageWidth = frame.width ++ headerScroll.pagerMinY = frame.minY ++ } + } + } + .onGeometryChange(for: PagerSplit.self) { PagerSplit(in: $0) } action: { split = $0 } -+ } -+} -+ -+/// Where a pager splits into its header and its pages. On a foldable it is -+/// the fold, flat or not, which neither column draws in: Apple's guidance -+/// keeps controls out of it. Anywhere else, or when the fold isn't across -+/// the pager, the columns share the width evenly. ++ // Beside the pages, the header leaves their lists, which stop ++ // reporting where they are but keep their scroll views. Forgotten ++ // now, each is found again where it is once the header is back. ++ .onChange(of: header == nil) { _, gone in ++ if gone { headerScroll.reset() } ++ } + } + + @ViewBuilder +@@ -881,6 +920,37 @@ + + // MARK: - Header + ++/// Where a pager splits into its header and its pages: at a foldable's fold, ++/// for an even split around the hinge. Flat, the columns meet on it. Partly ++/// folded, its division region is active and they stop at its margins, ++/// keeping controls out of the crease. Without a fold across the pager the ++/// columns share the width evenly. +private struct PagerSplit: Equatable { + var leading: CGFloat + var gap: CGFloat @@ -172,16 +234,40 @@ + init(in proxy: GeometryProxy) { + let width = proxy.size.width + self = .even(width) -+ #if compiler(>=6.4) -+ if #available(iOS 27.1, *) { -+ let folds = proxy.reservedRegions(kind: .division, options: .includeInactive) -+ if let fold = folds.first(where: { $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < width }) { -+ self = PagerSplit(leading: fold.frame.minX, gap: fold.frame.width) -+ } -+ } -+ #endif ++ guard #available(iOS 27.1, *) else { return } ++ let folds = proxy.reservedRegions(kind: .division, options: .includeInactive) ++ .filter { $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < width } ++ guard let fold = folds.min(by: { abs($0.frame.midX - width / 2) < abs($1.frame.midX - width / 2) }) else { return } ++ self = fold.isActive ++ ? PagerSplit(leading: fold.frame.minX, gap: fold.frame.width) ++ : PagerSplit(leading: fold.frame.midX, gap: 0) + } +} - ++ /// A colour as PHP sent it, light and dark, nil where it sent none. struct PagerColor: Equatable { + let light: Int? +@@ -1032,11 +1102,22 @@ + scrollViews[page] = WeakScrollView(view: scrollView) + offsets[page] = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + +- if page != leading { ++ if page == leading { ++ follow(offsets[page] ?? 0) ++ } else { + align(page) + } + } + ++ /// Forget every list and how far the header had gone, so each list is ++ /// found again as it is. ++ func reset() { ++ collapse = 0 ++ stripMaxY = [:] ++ offsets = [:] ++ scrollViews = [:] ++ } ++ + /// A page's list scrolled to `y`, its distance from its own top. + func scrolled(page: Int, y: CGFloat) { + offsets[page] = y From 2b824aa4eac1b25473b4cd93311c29b409d735a3 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:00:08 -0800 Subject: [PATCH 31/42] Keep each profile list's place when the header moves beside it Moving the header beside the pages took the header out of each page's list and switched the list's modifiers to another branch, so SwiftUI made a new UICollectionView for every list on each fold and unfold, and each list opened at the top. The pages now get the header either way and only draw it while it is in the list: the header row and the tabs' section stay, empty while the header is beside the pages, so the list keeps its rows, its sections and its scroll view, as it does at base. After the header is back, a list found before the one the header follows waits for it, instead of being levelled against a header the reset has put back at the top. --- ...ivephp-mobile-ui-pager-header-beside.patch | 169 ++++++++++++++++-- 1 file changed, 156 insertions(+), 13 deletions(-) diff --git a/patches/nativephp-mobile-ui-pager-header-beside.patch b/patches/nativephp-mobile-ui-pager-header-beside.patch index 2f8f2b12..1ac0eb08 100644 --- a/patches/nativephp-mobile-ui-pager-header-beside.patch +++ b/patches/nativephp-mobile-ui-pager-header-beside.patch @@ -168,6 +168,8 @@ - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in - headerScroll.pageWidth = frame.width - headerScroll.pagerMinY = frame.minY +- } +- } else { + HStack(spacing: 0) { + if let besideHeader { + // The header scrolls by itself in the leading column, and @@ -175,8 +177,7 @@ + ScrollView(.vertical) { + PagerChildren(node: besideHeader) + .frame(maxWidth: .infinity, alignment: .leading) - } -- } else { ++ } + .frame(width: split.leading) + Color.clear + .frame(width: split.gap) @@ -209,10 +210,32 @@ } @ViewBuilder -@@ -881,6 +920,37 @@ +@@ -584,7 +623,7 @@ + Group { + if let child = pageByIndex[index] { + NodeView(node: child).equatable() +- .environment(\.nativeUIPagerHeader, header.map { pagerHeader(page: index, node: $0) }) ++ .environment(\.nativeUIPagerHeader, (header ?? besideHeader).map { pagerHeader(page: index, node: $0) }) + } else if index >= loaded { + ProgressView() + .controlSize(.large) +@@ -606,8 +645,10 @@ - // MARK: - Header + /// What page `index` gets of the header: the same object for the life + /// of the pager, so a render only redraws the parts of it that changed. ++ /// A page gets it beside the pages too, with nothing to draw, so its ++ /// list is the same list either way and keeps its place. + private func pagerHeader(page index: Int, node: NativeUINode) -> NativeUIPagerHeader { +- headerScroll.header(page: index, node: node, tabs: tabs, background: background, progress: progress, select: selectTab) ++ headerScroll.header(page: index, node: node, inList: header != nil, tabs: tabs, background: background, progress: progress, select: selectTab) + } + /// A tap on a tab: scroll there, and the page is reported when the +@@ -880,7 +921,38 @@ + } + + // MARK: - Header ++ +/// Where a pager splits into its header and its pages: at a foldable's fold, +/// for an even split around the hinge. Flat, the columns meet on it. Partly +/// folded, its division region is active and they stop at its margins, @@ -230,7 +253,7 @@ + self.leading = leading + self.gap = gap + } -+ + + init(in proxy: GeometryProxy) { + let width = proxy.size.width + self = .even(width) @@ -247,18 +270,63 @@ /// A colour as PHP sent it, light and dark, nil where it sent none. struct PagerColor: Equatable { let light: Int? -@@ -1032,11 +1102,22 @@ +@@ -911,15 +983,19 @@ + final class NativeUIPagerHeader { + let page: Int + var node: NativeUINode ++ /// Whether the page's list draws the header and the tabs. Beside the ++ /// pages, the pager draws them itself and the list only its own rows. ++ var inList: Bool + fileprivate var tabs: PagerTabs? + var background: PagerColor + fileprivate let progress: PagerProgress + @ObservationIgnored fileprivate var select: (Int) -> Void + let scroll: PagerHeaderScroll + +- fileprivate init(page: Int, node: NativeUINode, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void, scroll: PagerHeaderScroll) { ++ fileprivate init(page: Int, node: NativeUINode, inList: Bool, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void, scroll: PagerHeaderScroll) { + self.page = page + self.node = node ++ self.inList = inList + self.tabs = tabs + self.background = background + self.progress = progress +@@ -994,15 +1070,16 @@ + weak var view: UIScrollView? + } + +- fileprivate func header(page: Int, node: NativeUINode, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void) -> NativeUIPagerHeader { ++ fileprivate func header(page: Int, node: NativeUINode, inList: Bool, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void) -> NativeUIPagerHeader { + guard let header = headers[page] else { +- let header = NativeUIPagerHeader(page: page, node: node, tabs: tabs, background: background, progress: progress, select: select, scroll: self) ++ let header = NativeUIPagerHeader(page: page, node: node, inList: inList, tabs: tabs, background: background, progress: progress, select: select, scroll: self) + headers[page] = header + return header + } + + // Only what changed, so an unchanged header doesn't redraw. + if header.node !== node { header.node = node } ++ if header.inList != inList { header.inList = inList } + if header.tabs != tabs { header.tabs = tabs } + if header.background != background { header.background = background } + header.select = select +@@ -1032,9 +1109,25 @@ scrollViews[page] = WeakScrollView(view: scrollView) offsets[page] = scrollView.contentOffset.y + scrollView.adjustedContentInset.top - if page != leading { + if page == leading { -+ follow(offsets[page] ?? 0) -+ } else { ++ // The header follows this list from where it is, and any list ++ // found before it is brought level with it. ++ collapse = min(offsets[page] ?? 0, headerHeight) ++ for other in scrollViews.keys where other != page { ++ align(other) ++ } ++ } else if scrollViews[leading]?.view != nil { align(page) } - } - ++ } ++ + /// Forget every list and how far the header had gone, so each list is + /// found again as it is. + func reset() { @@ -266,8 +334,83 @@ + stripMaxY = [:] + offsets = [:] + scrollViews = [:] + } + + /// A page's list scrolled to `y`, its distance from its own top. +@@ -1073,17 +1166,27 @@ + } + + /// The header as a page's list draws it: its first row. ++/// ++/// With the header beside the pages the row stays, drawing nothing, as does ++/// the section the tabs head: taking either out reloads the whole list, ++/// which loses its place. + struct NativeUIPagerHeaderRow: View { + let header: NativeUIPagerHeader + + var body: some View { +- PagerChildren(node: header.node) +- .frame(maxWidth: .infinity, alignment: .leading) +- .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in +- if header.scroll.headerHeight != height { header.scroll.headerHeight = height } +- } +- .background(PagerHeaderScrollViewFinder { header.scroll.attach($0, page: header.page) }) +- .modifier(PagerStationary(header: header)) ++ if header.inList { ++ PagerChildren(node: header.node) ++ .frame(maxWidth: .infinity, alignment: .leading) ++ .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in ++ if header.scroll.headerHeight != height { header.scroll.headerHeight = height } ++ } ++ .background(PagerHeaderScrollViewFinder { header.scroll.attach($0, page: header.page) }) ++ .modifier(PagerStationary(header: header)) ++ } else { ++ Color.clear ++ .frame(height: 0) ++ .accessibilityHidden(true) ++ } + } + } + +@@ -1096,6 +1199,16 @@ + @Environment(\.colorScheme) private var colorScheme + + var body: some View { ++ if header.inList { ++ strip ++ } else { ++ Color.clear ++ .frame(height: 0) ++ .accessibilityHidden(true) ++ } + } + - /// A page's list scrolled to `y`, its distance from its own top. - func scrolled(page: Int, y: CGFloat) { - offsets[page] = y ++ private var strip: some View { + header.strip + .frame(maxWidth: .infinity) + .background(header.background.resolve(colorScheme, fallback: themeStore.resolve(for: colorScheme).surface)) +@@ -1129,10 +1242,13 @@ + func body(content: Content) -> some View { + if let header { + content +- // Section headers get a minimum height and space above. ++ // Section headers get a minimum height and space above, and ++ // rows a minimum height, which the header's row beside the ++ // pages must not have. + .environment(\.defaultMinListHeaderHeight, 0) ++ .environment(\.defaultMinListRowHeight, 0) + .listSectionSpacing(0) +- .contentMargins(.bottom, room, for: .scrollContent) ++ .contentMargins(.bottom, header.inList ? room : 0, for: .scrollContent) + .onScrollGeometryChange(for: PagerListGeometry.self) { geometry in + PagerListGeometry( + y: geometry.contentOffset.y + geometry.contentInsets.top, +@@ -1142,6 +1258,8 @@ + container: geometry.containerSize.height + ) + } action: { _, geometry in ++ guard header.inList else { return } ++ + header.scroll.scrolled(page: header.page, y: geometry.y) + + // The room this list needs to scroll the header away, past From f96c26c2805b6b88410075f421ff171129bcc5b0 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:36:55 -0800 Subject: [PATCH 32/42] Put scroll indicators at the screen edge beside the Duo's vertical bar Lists, scroll views and pagers stopped at the safe area, so beside the iPhone Duo's vertical bar their indicator sat just inside the bar, where Apple's Settings and News put it at the screen's edge. A vertical list or scroll view now runs under the horizontal safe area beside it and hands that area back to its content as safe area: rows, separators and controls stay where they were, while the indicator and full-bleed backgrounds reach the edge. A horizontal pager does the same for its pages, which span the bar's inset, so its lists can too; it clips its sides at those edges. The Duo workspace gives each column its measured insets as safe area instead of padding, so the same applies inside it. A column that stops at the fold has no inset there and keeps its indicator at its own edge. --- composer.json | 3 +- composer.lock | 2 +- .../resources/ios/AdaptiveFeedRenderer.swift | 7 +- ...php-mobile-ui-scroll-edge-under-bars.patch | 140 ++++++++++++++++++ 4 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 patches/nativephp-mobile-ui-scroll-edge-under-bars.patch diff --git a/composer.json b/composer.json index 731812ee..501e16ad 100644 --- a/composer.json +++ b/composer.json @@ -128,7 +128,8 @@ }, "nativephp/mobile-ui": { "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch", - "Pager: header-beside draws a horizontal pager's header in its own column beside the pages from a breakpoint up, split at the fold (iOS)": "patches/nativephp-mobile-ui-pager-header-beside.patch" + "Pager: header-beside draws a horizontal pager's header in its own column beside the pages from a breakpoint up, split at the fold (iOS)": "patches/nativephp-mobile-ui-pager-header-beside.patch", + "Lists, scroll views and pagers run under a vertical bar beside them (iPhone Duo) with their indicator at the screen edge, handing the bar's inset back to their content as safe area (iOS)": "patches/nativephp-mobile-ui-scroll-edge-under-bars.patch" } } }, diff --git a/composer.lock b/composer.lock index ee18745a..566f399b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7c5ede59979ffef2bd5c093b8b43903d", + "content-hash": "05e585c4b8f86c3d1afef26b77e23e52", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 259eb30b..5f46e711 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -79,11 +79,12 @@ private struct AdaptiveNodeRoot: View { .environment(\.nativeSafeAreaTop, geometry.safeAreaInsets.top) .environment(\.nativeSafeAreaBottom, geometry.safeAreaInsets.bottom) } - .padding(.leading, paneInsets.horizontal.leading) - .padding(.trailing, paneInsets.horizontal.trailing) + .safeAreaPadding(.leading, paneInsets.horizontal.leading) + .safeAreaPadding(.trailing, paneInsets.horizontal.trailing) // A navigation controller hands its pages the window's horizontal // insets even in a column that stops short of the vertical bar. The - // workspace measures what each column actually overlaps instead. + // workspace measures what each column actually overlaps instead, as + // safe area, so lists and backgrounds can still reach under the bar. .ignoresSafeArea(.container, edges: .horizontal) // UIHostingController is a new SwiftUI root, so it does not inherit the // representable's theme, window breakpoints, locale, or custom keys. diff --git a/patches/nativephp-mobile-ui-scroll-edge-under-bars.patch b/patches/nativephp-mobile-ui-scroll-edge-under-bars.patch new file mode 100644 index 00000000..9a11671a --- /dev/null +++ b/patches/nativephp-mobile-ui-scroll-edge-under-bars.patch @@ -0,0 +1,140 @@ +--- a/resources/ios/NativeUIListRenderer.swift ++++ b/resources/ios/NativeUIListRenderer.swift +@@ -200,6 +200,7 @@ + // Only this list draws the header, not one in its rows. + .environment(\.nativeUIPagerHeader, nil) + .modifier(ListAutoScrollModifier(request: autoScrollTo, targetID: autoScrollTarget, anchor: .top)) ++ .modifier(NativeUIScrollEdgeExtension()) + } + } + +--- a/resources/ios/NativeUIPagerRenderer.swift ++++ b/resources/ios/NativeUIPagerRenderer.swift +@@ -182,6 +182,7 @@ + PagerChildren(node: besideHeader) + .frame(maxWidth: .infinity, alignment: .leading) + } ++ .modifier(NativeUIScrollEdgeExtension()) + .frame(width: split.leading) + Color.clear + .frame(width: split.gap) +@@ -197,7 +198,12 @@ + pager + .overlay(alignment: .top) { overlayView } + .modifier(PagerA11yLabelModifier(label: a11yLabel)) +- .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in ++ .onGeometryChange(for: CGRect.self) { proxy in ++ // A page also spans the bar insets at its sides (see `pager`). ++ let sides = horizontal ? proxy.safeAreaInsets.leading + proxy.safeAreaInsets.trailing : 0 ++ let frame = proxy.frame(in: .global) ++ return CGRect(x: frame.minX, y: frame.minY, width: frame.width + sides, height: frame.height) ++ } action: { frame in + headerScroll.pageWidth = frame.width + headerScroll.pagerMinY = frame.minY + } +@@ -238,17 +244,25 @@ + // content as safe area, so a list in a page draws under the + // bar and still scrolls its last row clear of it. Paging is + // sideways, so the extra height can't make the pages drift. +- let bottomInset = horizontal ? geo.safeAreaInsets.bottom : 0 ++ // ++ // It runs under a bar at its side (an iPhone Duo's vertical bar) ++ // the same way, so a page's list can put its scroll indicator at ++ // the screen's edge while its rows stay clear of the bar. Each ++ // page spans the insets, so `.paging` still steps by one page. ++ let insets = horizontal ++ ? EdgeInsets(top: 0, leading: geo.safeAreaInsets.leading, bottom: geo.safeAreaInsets.bottom, trailing: geo.safeAreaInsets.trailing) ++ : EdgeInsets() + ScrollView(axis) { +- pages(size: CGSize(width: geo.size.width, height: geo.size.height + bottomInset), bottomInset: bottomInset) ++ pages(size: CGSize(width: geo.size.width + insets.leading + insets.trailing, height: geo.size.height + insets.bottom), insets: insets) + .scrollTargetLayout() + } +- .ignoresSafeArea(.container, edges: horizontal ? .bottom : []) +- // Beside a sideways bar (an iPhone Duo's rail) the ScrollView +- // also draws into the bar's inset, so the next page showed +- // under it. Clip the sides only: the pages still run under a +- // bar at the bottom. ++ // A ScrollView draws past its sides, so the next page showed ++ // beside the pager. Clip the sides only, at the edges the pages ++ // run to: inside the safe-area extension, which lays this out ++ // over the side insets. The pages still run under a bar at the ++ // bottom. + .clipShape(PagerSideClip(horizontal: horizontal)) ++ .ignoresSafeArea(.container, edges: horizontal ? [.bottom, .horizontal] : []) + .scrollTargetBehavior(.paging) + .scrollPosition(id: $position) + // On a non-lazy stack SwiftUI ignores the initial value of +@@ -610,15 +624,15 @@ + /// stack expensive, so they only exist within `placeholderRadius` of + /// the current page; further slots are clear until scrolled towards. + @ViewBuilder +- private func pages(size: CGSize, bottomInset: CGFloat) -> some View { ++ private func pages(size: CGSize, insets: EdgeInsets) -> some View { + if horizontal { +- HStack(spacing: 0) { pageViews(size: size, bottomInset: bottomInset) } ++ HStack(spacing: 0) { pageViews(size: size, insets: insets) } + } else { +- VStack(spacing: 0) { pageViews(size: size, bottomInset: bottomInset) } ++ VStack(spacing: 0) { pageViews(size: size, insets: insets) } + } + } + +- private func pageViews(size: CGSize, bottomInset: CGFloat) -> some View { ++ private func pageViews(size: CGSize, insets: EdgeInsets) -> some View { + ForEach(0.. some View { ++ content ++ .safeAreaPadding(.leading, insets.leading) ++ .safeAreaPadding(.trailing, insets.trailing) ++ .ignoresSafeArea(.container, edges: .horizontal) ++ .onGeometryChange(for: EdgeInsets.self) { proxy in ++ EdgeInsets(top: 0, leading: proxy.safeAreaInsets.leading, bottom: 0, trailing: proxy.safeAreaInsets.trailing) ++ } action: { insets = $0 } + } + } +--- a/resources/ios/NativeUIVirtualListRenderer.swift ++++ b/resources/ios/NativeUIVirtualListRenderer.swift +@@ -56,6 +56,7 @@ + // Environment-propagating, so it reaches the List inside the body + // without threading another property through it. + .scrollIndicators(node.props.getBool("shows_indicators", default: true) ? .automatic : .hidden) ++ .modifier(NativeUIScrollEdgeExtension()) + } + } + From aec97fd2fdb94c10f7299b7ac50c87cde2ed3522 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:53:57 -0800 Subject: [PATCH 33/42] Speak in git's voice in the empty detail pane --- resources/views/native/feed-workspace.blade.php | 6 ++---- resources/views/native/partials/empty.blade.php | 2 +- tests/Feature/Screens/FeedWorkspaceTest.php | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/resources/views/native/feed-workspace.blade.php b/resources/views/native/feed-workspace.blade.php index d1a796b5..d6710972 100644 --- a/resources/views/native/feed-workspace.blade.php +++ b/resources/views/native/feed-workspace.blade.php @@ -3,10 +3,8 @@ - - - No commit selected - Choose a commit in the log to read its conversation here. + + @include('native.partials.empty', ['ref' => 'no-conversation', 'command' => 'no commit selected', 'message' => 'Choose a commit in the log to read its conversation here.']) @foreach ($conversations as $conversation) diff --git a/resources/views/native/partials/empty.blade.php b/resources/views/native/partials/empty.blade.php index afa3478f..29b69915 100644 --- a/resources/views/native/partials/empty.blade.php +++ b/resources/views/native/partials/empty.blade.php @@ -1,5 +1,5 @@ {{-- Empty states speak in git's voice: a mono line, then a plain sentence or two, and maybe a link on its own line. --}} - + {{ $command }} {{ $message }} @isset($link) diff --git a/tests/Feature/Screens/FeedWorkspaceTest.php b/tests/Feature/Screens/FeedWorkspaceTest.php index 1d71f26c..80a40750 100644 --- a/tests/Feature/Screens/FeedWorkspaceTest.php +++ b/tests/Feature/Screens/FeedWorkspaceTest.php @@ -59,6 +59,7 @@ function serveWorkspace(): void it('keeps the feed live beside conversation history and ignores obsolete native back', function () { serveWorkspace(); $screen = Native::test(FeedWorkspace::class, platform: 'ios')->assertSee('Browsable feed'); + expect(treeShows(nodeByRef($screen, 'no-conversation'), 'no commit selected'))->toBeTrue(); $screen->call('openConversation', 'c2', ['replies' => true], true) ->assertSee('Conversation c2') From 90cac4336ba62cd559a9c12a9bdae40d75de131d Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:53:57 -0800 Subject: [PATCH 34/42] Name the New remote button as VoiceOver announces it in the empty Remotes tab --- resources/views/native/partials/remotes-page.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/native/partials/remotes-page.blade.php b/resources/views/native/partials/remotes-page.blade.php index 51b149f2..4a67b6a2 100644 --- a/resources/views/native/partials/remotes-page.blade.php +++ b/resources/views/native/partials/remotes-page.blade.php @@ -22,7 +22,7 @@ class="w-full flex-1 bg-theme-surface" @include('native.partials.empty', match ($page['tab']) { 'stashed' => ['command' => 'nothing stashed', 'message' => 'Stash a conversation from its menu. It pops back into Open when someone writes.'], 'fetched' => ['command' => 'no requests fetched', 'message' => 'First messages from people you don’t follow wait here until you merge or drop them.'], - default => ['command' => 'no conversations yet', 'message' => 'Start one with the pencil button, or from a profile with Remote.'], + default => ['command' => 'no conversations yet', 'message' => 'Start one with New remote, the pencil button, or from a profile with Remote.'], }) @endif From da59a700a583bd4c3778752bc306d7ca11e5813c Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:53:57 -0800 Subject: [PATCH 35/42] Derive expanded widths in tests from the breakpoint and the readable column --- tests/Feature/Screens/CommitDetailTest.php | 4 +++- tests/Feature/Screens/OnboardingTest.php | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 1d03c485..8705232f 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -1,11 +1,13 @@ not->toHaveKey('_variants') ->and($bar['layout'])->toMatchArray(['width' => 'fill', 'align_items' => AlignItems::Center->value]) - ->and($variant)->toMatchArray(['min' => 840, 'layout' => ['width' => 672]]) + ->and($variant)->toMatchArray(['min' => TailwindParser::breakpointMinWidth('expanded'), 'layout' => TailwindParser::parse(Display::ReadableColumn)['variants']['expanded']]) ->and(childRefs($screen, 'reply-bar'))->toBe([null, 'reply-content']) ->and(childRefs($screen, 'reply-content'))->toContain('reply-mention-slot', 'reply-row'); })->with(['ios', 'android']); diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index 85d1b571..8a6b7a31 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -17,6 +17,7 @@ use Native\Mobile\Edge\Enums\JustifyContent; use Native\Mobile\Edge\NativeRouter; use Native\Mobile\Edge\NavigationIntent; +use Native\Mobile\Edge\TailwindParser; use Native\Mobile\System; use Native\Mobile\Testing\FakeBridge; use Native\Mobile\Testing\Native; @@ -142,7 +143,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent } }); -it("keeps the lockup inside its half, with the buttons' margin, once the window is wide enough to put them side by side", function (int $window) { +it("keeps the lockup inside its half, with the buttons' margin, once the window is wide enough to put them side by side", function (float $window) { signInBridge(testSeed()); $screen = Native::visit('/onboarding', platform: 'ios'); @@ -151,11 +152,11 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $margin = $variant(nodeByRef($screen, 'logo-space'))['layout']['padding'][1]; $half = ($window - $variant($screen->tree())['layout']['gap']) / 2; - expect($logo['min'])->toBe(840) + expect($logo['min'])->toEqual(TailwindParser::breakpointMinWidth('expanded')) ->and($margin)->toEqual(nodeByRef($screen, 'actions')['layout']['padding'][1]) ->and(floatval($logo['layout']['width']) / 100 * $window)->toBeLessThanOrEqual($half - 2 * $margin); })->with([ - 'the narrowest expanded window' => 840, + 'the narrowest expanded window' => fn (): float => TailwindParser::breakpointMinWidth('expanded'), 'the inner display held sideways' => 951, ]); From 64e41ec0982bb6f4e371557d2e7b024385b83ed5 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:53:57 -0800 Subject: [PATCH 36/42] Say why, not the sums, in the lockup, selected row and sheet button comments --- resources/views/native/onboarding.blade.php | 5 ++--- resources/views/native/partials/commit-row.blade.php | 4 ++-- tests/Feature/Screens/ComposeSheetTest.php | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index 892a13a8..f88f8ebf 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -10,9 +10,8 @@ {{-- The lockup, two thirds wide at its own 15:4, centred in its space. Side by side it has to fit its half less the buttons' 24pt margin on each side. iOS resolves a percentage against the window, not the parent, and a - variant can't swap a width for `w-full`, so there it's 40% of the window: at most that room from 840pt up (336pt - of 340pt there, 380pt of 395pt on the inner display). Android resolves it against the parent, which is already - the half, so it keeps two thirds. + variant can't swap a width for `w-full`, so there it's 40% of the window, which fits that room at every expanded + width. Android resolves it against the parent, which is already the half, so it keeps two thirds. --}} diff --git a/resources/views/native/partials/commit-row.blade.php b/resources/views/native/partials/commit-row.blade.php index d8462e04..a5782409 100644 --- a/resources/views/native/partials/commit-row.blade.php +++ b/resources/views/native/partials/commit-row.blade.php @@ -3,8 +3,8 @@ The author's avatar is the node on the rail. A commit carrying on its author's run ($continuesRun) gets a lane dot instead. The segment above the node takes the previous author's lane, the one below takes this author's. $focused draws the commit a page is about: a larger body, its git date, and no link to itself. - In the iOS workspace the row of the conversation open from the log ($selectedCommit) takes the wash, as a thread - reply does. + In the iOS workspace the row of the conversation open from the log ($selectedCommit) takes the wash; a selected + thread reply's row does the same (partials/thread-reply). Rows in a list carry no press-* feedback. EDGE draws it with a zero-distance DragGesture (NodePressFeedbackModifier in nativephp/mobile), and on iOS that gesture keeps the list from scrolling when a drag starts on the row. diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 610123b1..7158888c 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -300,6 +300,7 @@ function sheetCommitDisabled(TestableComponent $screen): bool expect($cancel['type'])->toBe('pressable') ->and($cancel['props'])->toMatchArray(['a11y_label' => 'Cancel']) ->and($commit['props']['a11y_label'])->toBe('Commit, unavailable') + // On iOS every ref'd node becomes its own accessibility container (NodeIdentityModifier), so one ref is one announcement. ->and(findNodes($cancel, fn (array $node): bool => isset($node['ref'])))->toHaveCount(1) ->and(findNodes($commit, fn (array $node): bool => isset($node['ref'])))->toHaveCount(1); From 9c187bc9a6e5f3d8eb1bf74bd78974b41fb01bea Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:56:22 -0800 Subject: [PATCH 37/42] Drop the workspace background's redundant safe-area escape --- .../adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 5f46e711..2cd4ecc0 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -14,10 +14,7 @@ struct AdaptiveFeedRenderer: View { // move into the vertical bar only when their controller reaches // its edge; inset here, they stayed horizontal. .ignoresSafeArea(.container, edges: .all) - .background { - themeStore.resolve(for: colorScheme).surface - .ignoresSafeArea(.container, edges: .all) - } + .background(themeStore.resolve(for: colorScheme).surface) .environment(\.nativeUITheme, themeStore.resolve(for: colorScheme)) } } From a829290606c5fbf894d279fb6724d5a3ef72603e Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:56:22 -0800 Subject: [PATCH 38/42] Split evenly the same way in the workspace and the pager without a fold The workspace parted its columns at the unrounded middle of a display without a fold, while the pager rounded it. Both now round, and each copy of the fold rule names the other, since the app's plugin shouldn't depend on a symbol a vendor patch adds. --- .../resources/ios/AdaptiveFeedRenderer.swift | 4 +++- ...ivephp-mobile-ui-pager-header-beside.patch | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index 2cd4ecc0..b089c202 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -376,8 +376,10 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC /// Flat, they meet on it. Partly folded, its division region is active /// and they stop at its margins, keeping controls out of the crease. /// A display without a fold across the split gets an even split. + /// PagerSplit in the patched nativephp/mobile-ui NativeUIPagerRenderer.swift + /// applies the same rule; keep the two alike. private func foldGap(in bounds: CGRect) -> CGRect { - let middle = CGRect(x: bounds.midX, y: bounds.minY, width: 0, height: bounds.height) + let middle = CGRect(x: bounds.minX + (bounds.width / 2).rounded(), y: bounds.minY, width: 0, height: bounds.height) guard #available(iOS 27.1, *) else { return middle } let divisions = view.reservedRegions(kind: .division, options: .includeInactive) .filter { $0.frame.height > $0.frame.width && $0.frame.minX > bounds.minX && $0.frame.maxX < bounds.maxX } diff --git a/patches/nativephp-mobile-ui-pager-header-beside.patch b/patches/nativephp-mobile-ui-pager-header-beside.patch index 1ac0eb08..5db1826e 100644 --- a/patches/nativephp-mobile-ui-pager-header-beside.patch +++ b/patches/nativephp-mobile-ui-pager-header-beside.patch @@ -231,7 +231,7 @@ } /// A tap on a tab: scroll there, and the page is reported when the -@@ -880,7 +921,38 @@ +@@ -880,7 +921,39 @@ } // MARK: - Header @@ -240,7 +240,8 @@ +/// for an even split around the hinge. Flat, the columns meet on it. Partly +/// folded, its division region is active and they stop at its margins, +/// keeping controls out of the crease. Without a fold across the pager the -+/// columns share the width evenly. ++/// columns share the width evenly. foldGap(in:) in devnull/adaptive-layout's ++/// AdaptiveFeedRenderer.swift applies the same rule; keep the two alike. +private struct PagerSplit: Equatable { + var leading: CGFloat + var gap: CGFloat @@ -270,7 +271,7 @@ /// A colour as PHP sent it, light and dark, nil where it sent none. struct PagerColor: Equatable { let light: Int? -@@ -911,15 +983,19 @@ +@@ -911,15 +984,19 @@ final class NativeUIPagerHeader { let page: Int var node: NativeUINode @@ -291,7 +292,7 @@ self.tabs = tabs self.background = background self.progress = progress -@@ -994,15 +1070,16 @@ +@@ -994,15 +1071,16 @@ weak var view: UIScrollView? } @@ -310,7 +311,7 @@ if header.tabs != tabs { header.tabs = tabs } if header.background != background { header.background = background } header.select = select -@@ -1032,9 +1109,25 @@ +@@ -1032,9 +1110,25 @@ scrollViews[page] = WeakScrollView(view: scrollView) offsets[page] = scrollView.contentOffset.y + scrollView.adjustedContentInset.top @@ -337,7 +338,7 @@ } /// A page's list scrolled to `y`, its distance from its own top. -@@ -1073,17 +1166,27 @@ +@@ -1073,17 +1167,27 @@ } /// The header as a page's list draws it: its first row. @@ -372,7 +373,7 @@ } } -@@ -1096,6 +1199,16 @@ +@@ -1096,6 +1200,16 @@ @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -389,7 +390,7 @@ header.strip .frame(maxWidth: .infinity) .background(header.background.resolve(colorScheme, fallback: themeStore.resolve(for: colorScheme).surface)) -@@ -1129,10 +1242,13 @@ +@@ -1129,10 +1243,13 @@ func body(content: Content) -> some View { if let header { content @@ -405,7 +406,7 @@ .onScrollGeometryChange(for: PagerListGeometry.self) { geometry in PagerListGeometry( y: geometry.contentOffset.y + geometry.contentInsets.top, -@@ -1142,6 +1258,8 @@ +@@ -1142,6 +1259,8 @@ container: geometry.containerSize.height ) } action: { _, geometry in From 75e1769dc0a54a7e7741ca76f5fb5277ccd3611c Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:56:22 -0800 Subject: [PATCH 39/42] Re-check the fold gap on the run loop after each hinge update The division region can turn active just after a hinge update, which left the columns parted at the flat fold until something else laid them out. --- .../resources/ios/AdaptiveFeedRenderer.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index b089c202..ced1a7c3 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -265,11 +265,11 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC } if #available(iOS 27.1, *) { // Hinge updates say nothing about when the division region turns - // active, so re-read the gap and lay out only when it moves. + // active, and it can turn active just after one, so re-read the + // gap then and once more on the next run loop. view.addInteraction(UIHingeInteraction { [weak self] _, _ in - guard let self, self.wide == true, - self.foldGap(in: self.view.bounds) != self.appliedFoldGap else { return } - self.view.setNeedsLayout() + self?.layOutIfFoldGapMoved() + DispatchQueue.main.async { [weak self] in self?.layOutIfFoldGapMoved() } }) } } @@ -372,6 +372,11 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC divider.frame = CGRect(x: fold.midX - hairline / 2, y: bounds.minY, width: hairline, height: bounds.height) } + private func layOutIfFoldGapMoved() { + guard wide == true, foldGap(in: view.bounds) != appliedFoldGap else { return } + view.setNeedsLayout() + } + /// Where the columns part: the fold, for an even split around the hinge. /// Flat, they meet on it. Partly folded, its division region is active /// and they stop at its margins, keeping controls out of the crease. From a610a5d3f93ffd3b196881dc01d765979b576a0e Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 19:56:22 -0800 Subject: [PATCH 40/42] Describe the scroll-edge patch by the safe area it reacts to, and its order --- composer.json | 2 +- composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 501e16ad..7452c988 100644 --- a/composer.json +++ b/composer.json @@ -129,7 +129,7 @@ "nativephp/mobile-ui": { "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch", "Pager: header-beside draws a horizontal pager's header in its own column beside the pages from a breakpoint up, split at the fold (iOS)": "patches/nativephp-mobile-ui-pager-header-beside.patch", - "Lists, scroll views and pagers run under a vertical bar beside them (iPhone Duo) with their indicator at the screen edge, handing the bar's inset back to their content as safe area (iOS)": "patches/nativephp-mobile-ui-scroll-edge-under-bars.patch" + "Lists, scroll views and pagers run under the horizontal safe area beside them (the iPhone Duo's vertical bar, a notched iPhone held sideways) with their indicator at the screen edge, handing that inset back to their content as safe area (iOS); its pager hunks edit lines the header-beside patch adds, so it applies after that one": "patches/nativephp-mobile-ui-scroll-edge-under-bars.patch" } } }, diff --git a/composer.lock b/composer.lock index 566f399b..0500d86e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "05e585c4b8f86c3d1afef26b77e23e52", + "content-hash": "4f5c6c2ddc01188a55eeaaabfb661e0c", "packages": [ { "name": "bacon/bacon-qr-code", From 0d03a049fe778a2eb76c8a2a9ce6c411963ed5e6 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 20:50:53 -0800 Subject: [PATCH 41/42] Keep the Duo's inner display to one column in portrait Regular width alone split the unfolded display in portrait into two skinny columns. Like Apple's arrangements, weigh the window's aspect ratio as well: only a window wider than it is tall gets the split. --- .../resources/ios/AdaptiveFeedRenderer.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index ced1a7c3..b7900420 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -399,7 +399,12 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC primary.transitionCoordinator == nil, secondary.transitionCoordinator == nil, let latest, let inherited, view.bounds.width > 0, view.bounds.height > 0, let feed = latest.children.first(where: { $0.type != "adaptive_detail" && $0.type != "adaptive_placeholder" }) else { return } - let wantsWide = traitCollection.horizontalSizeClass == .regular + // Regular width alone would split the inner display in portrait into + // two skinny columns. Like Apple's arrangements, also weigh the aspect + // ratio: only a window wider than it is tall gets two columns. The + // window's shape, unlike the view's, survives keyboard resizing. + let window = view.window?.bounds ?? view.bounds + let wantsWide = traitCollection.horizontalSizeClass == .regular && window.width > window.height // Root-view assignment can schedule another UIKit layout. A bounds-only // layout in the same mode must not assign all hosting roots again. guard appliedRevision != revision || wide != wantsWide else { return } From f83ea45313aacb5cbb805b58d3c8d2435cf2ed69 Mon Sep 17 00:00:00 2001 From: Connor McManus Date: Sat, 7 Nov 2026 21:07:23 -0800 Subject: [PATCH 42/42] Measure each column's side insets from the live safe area, not its lagging guide --- .../adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift index b7900420..0f36a4ec 100644 --- a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -346,7 +346,9 @@ private final class AdaptiveWorkspaceController: UIViewController, UINavigationC } private func updateHorizontalInsets() { - let safe = view.safeAreaLayoutGuide.layoutFrame + // The safe-area guide is solved lazily, and these columns are laid out + // by frame, so after a resize it can keep an intermediate size's frame. + let safe = view.bounds.inset(by: view.safeAreaInsets) let isRightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft for navigation in [primary, secondary] where !navigation.view.isHidden { let column = navigation.view.frame