From 1ed410a60650ae8c8499439b452c6468898dcad6 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 22 Jul 2026 15:32:27 +0100 Subject: [PATCH 1/4] Homepage: Mobile/Desktop path chooser, SuperNative-first messaging for v4 The hero CTAs become a platform toggle (persisted, Mobile by default) and the Under the hood, tools, Fast/Tiny apps and install-steps blocks adapt to the chosen track. Mobile leads with SuperNative: Blade rendering to real SwiftUI/Jetpack Compose views over shared memory, web view demoted to an optional component. Desktop keeps the accurate Electron story: static PHP binary, authenticated HTTP, Chromium window. Also: Bifrost diagram redrawn with a two-arm fan (Windows track removed) and a centred bubble, announcement cards scoped to the Mobile track, and the invalid flux-pro carousel stub class excluded from Tailwind's sources to silence a Lightning CSS warning on every build. Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/css/app.css | 9 + resources/js/app.js | 24 +- .../views/components/home/explainer.blade.php | 416 +++++++++++------- .../views/components/home/hero.blade.php | 367 +++++++-------- .../components/home/skill-pill.blade.php | 2 +- .../icons/home/arc-connector.blade.php | 31 -- .../components/icons/home/arc-fan.blade.php | 43 ++ .../illustrations/bifrost-diagram.blade.php | 51 +-- .../illustrations/desktop-stack.blade.php | 118 +++++ .../illustrations/mobile-stack.blade.php | 120 +++++ resources/views/welcome.blade.php | 13 +- tests/Feature/HomeCourseCardTest.php | 36 ++ tests/Feature/HomeExplainerNativeUiTest.php | 245 +++++++++++ 13 files changed, 1044 insertions(+), 431 deletions(-) delete mode 100644 resources/views/components/icons/home/arc-connector.blade.php create mode 100644 resources/views/components/icons/home/arc-fan.blade.php create mode 100644 resources/views/components/illustrations/desktop-stack.blade.php create mode 100644 resources/views/components/illustrations/mobile-stack.blade.php create mode 100644 tests/Feature/HomeExplainerNativeUiTest.php diff --git a/resources/css/app.css b/resources/css/app.css index 10bb7bda..1d207711 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,6 +8,15 @@ @source '../../storage/framework/views/*.php'; @source '../../vendor/filament/**/*.blade.php'; +/* + * flux.css pulls in the flux-pro stubs, and the carousel slide stub declares + * `[snap="mandatory"_&]:snap-always` — one bracket short, so it compiles to the + * invalid selector `snap="mandatory" &` and Lightning CSS warns on every build. + * We don't use , so skip that stub. Drop this once Flux fixes it + * upstream; remove it first if we ever adopt the carousel. + */ +@source not '../../vendor/livewire/flux-pro/stubs/resources/views/flux/carousel'; + /* Safelist classes used in dynamic content (blog posts from database) */ @source inline("grid-cols-1 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6"); diff --git a/resources/js/app.js b/resources/js/app.js index dfb5cda0..9fcdbffb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -113,10 +113,28 @@ Alpine.data('countdown', (iso) => ({ }, // tidy up })) +// Which platform path the homepage is showing: 'mobile' | 'desktop'. +// Shared across sections (hero, explainer) and remembered between visits. +// Registered on alpine:init because $persist only exists once Livewire has +// registered Alpine's plugins. +document.addEventListener('alpine:init', () => { + Alpine.store('platform', { + current: Alpine.$persist('mobile').as('nativephpPlatform'), + is(name) { + return this.current === name + }, + select(name) { + this.current = name + }, + }) +}) + Livewire.start() // Docsearch -const docsPathMatch = window.location.pathname.match(/^\/docs\/(desktop|mobile)\/(\d+)/) +const docsPathMatch = window.location.pathname.match( + /^\/docs\/(desktop|mobile)\/(\d+)/, +) const docsearchOptions = { appId: 'ZNII9QZ8WI', apiKey: '9be495a1aaf367b47c873d30a8e7ccf5', @@ -146,7 +164,9 @@ docsearch({ // pressing Cmd+K only registers one handler (avoiding duplicate modals). const mobileContainer = document.getElementById('docsearch-mobile') if (mobileContainer) { - const desktopButton = document.querySelector('#docsearch-desktop .DocSearch-Button') + const desktopButton = document.querySelector( + '#docsearch-desktop .DocSearch-Button', + ) if (desktopButton) { const mobileButton = desktopButton.cloneNode(true) mobileContainer.appendChild(mobileButton) diff --git a/resources/views/components/home/explainer.blade.php b/resources/views/components/home/explainer.blade.php index 77fffd51..32595862 100644 --- a/resources/views/components/home/explainer.blade.php +++ b/resources/views/components/home/explainer.blade.php @@ -43,32 +43,44 @@ class="text-2xl font-bold text-gray-800 lg:text-3xl dark:text-white" How does it work? - {{-- Description --}} + {{-- Description: Mobile --}}

NativePHP - bundles PHP with your app and lets it run inside a + bundles PHP with your app and runs it - Swift, + inside + the app process. No server. No network round-trip. +
+
+ Your Blade renders straight to real - Kotlin + SwiftUI - (mobile) or + and - Electron + Jetpack Compose - (desktop) shell. It uses special + views over - bridges + shared memory. - to talk directly to the device and show your app in a + No web view. No JSON bridge. + +
+
+ We call this - native web view. + SuperNative.
@@ -83,136 +95,94 @@ class="text-pretty text-gray-600 sm:max-w-75 dark:text-zinc-400" but it’s just PHP... on your user's device!

+ + {{-- + v4 is still a prerelease, so link the version explicitly: + the unversioned URL resolves to the latest stable docs. + --}} + + Learn more about SuperNative + + + + {{-- Description: Desktop --}} +

+ + NativePHP + + ships a + + statically-compiled PHP binary + + inside your app, alongside an + + Electron + + shell. Your users install nothing else. + +
+
+ The two talk to each other over + + authenticated HTTP + + on the device, so your Laravel routes drive a real + + Chromium window, + + rendering the same HTML, CSS and JavaScript you already + write. + +
+
+ You still write PHP like you’re used to—just with a few + extra tools for windows, menus, notifications and the file + system. +
+
+ That’s it. It feels like + + magic, + + but it’s just PHP... on your user's device! +

{{-- Right side --}}
-
- {{-- Phone wireframe --}} -
{{-- Grid illustration --}} @@ -254,7 +224,12 @@ class="h-px w-full [background-image:linear-gradient(to_right,currentColor_0_8px
{{-- Right side --}} -
+ {{-- + Grow to fill rather than sizing to content: the Mobile track's + pill list is short, so a max-content width leaves a gap on the + right instead of lining up with the cards above. + --}} +
{{-- Performance --}}
- Tiny apps + + Tiny apps + + + One file +

- Mobile apps under 50MB + + Mobile apps under 50MB + + + Ships as a single executable +

@@ -361,42 +352,131 @@ class="text-2xl font-bold text-gray-800 dark:text-white" > Bring your favorite tools -

- Use any Composer packages and front-end frameworks. +

+ Use (almost) any Composer package! +

+

+ Use any Composer package and front-end framework you + like.

+ @php + // Work the same whether your UI is native or a web view. + $phpSkills = [ + ['name' => 'Laravel', 'link' => 'https://laravel.com/', 'icon' => 'icons.skills.laravel'], + ['name' => 'Pest', 'link' => 'https://pestphp.com/', 'icon' => 'icons.skills.pest'], + ['name' => 'PHPUnit', 'link' => 'https://phpunit.de/', 'icon' => 'icons.skills.phpunit'], + ]; + // Render HTML, so they belong to the web view story. + $webSkills = [ + ['name' => 'Livewire', 'link' => 'https://livewire.laravel.com', 'icon' => 'icons.skills.livewire'], + ['name' => 'FilamentPHP', 'link' => 'https://filamentphp.com/', 'icon' => 'icons.skills.filamentphp'], + ['name' => 'TailwindCSS', 'link' => 'https://tailwindcss.com/', 'icon' => 'icons.skills.tailwind-css'], + ['name' => 'Alpine.js', 'link' => 'https://alpinejs.dev/', 'icon' => 'icons.skills.alpinejs'], + ['name' => 'Inertia.js', 'link' => 'https://inertiajs.com/', 'icon' => 'icons.skills.inertiajs'], + ['name' => 'React', 'link' => 'https://reactjs.org/', 'icon' => 'icons.skills.reactjs'], + ['name' => 'Vue.js', 'link' => 'https://vuejs.org/', 'icon' => 'icons.skills.vuejs'], + ['name' => 'Nuxt', 'link' => 'https://nuxtjs.org/', 'icon' => 'icons.skills.nuxtjs'], + ['name' => 'Next.js', 'link' => 'https://nextjs.org/', 'icon' => 'icons.skills.nextjs'], + ['name' => 'TypeScript', 'link' => 'https://www.typescriptlang.org/', 'icon' => 'icons.skills.typescript'], + ['name' => 'JavaScript', 'link' => 'https://www.javascript.com/', 'icon' => 'icons.skills.javascript'], + ]; + @endphp + + {{-- + One list: the web front-ends drop out on the mobile path. + Each icon must render exactly once — several skill SVGs use + document-wide ids (Pest's gradient is plain id="a"), so a + duplicated pill silently loses its fill. + --}}
- @php - $skills = [ - ['name' => 'Laravel', 'link' => 'https://laravel.com/', 'icon' => 'icons.skills.laravel'], - ['name' => 'React', 'link' => 'https://reactjs.org/', 'icon' => 'icons.skills.reactjs'], - ['name' => 'Vue.js', 'link' => 'https://vuejs.org/', 'icon' => 'icons.skills.vuejs'], - ['name' => 'Nuxt', 'link' => 'https://nuxtjs.org/', 'icon' => 'icons.skills.nuxtjs'], - ['name' => 'Next.js', 'link' => 'https://nextjs.org/', 'icon' => 'icons.skills.nextjs'], - ['name' => 'Livewire', 'link' => 'https://livewire.laravel.com', 'icon' => 'icons.skills.livewire'], - ['name' => 'FilamentPHP', 'link' => 'https://filamentphp.com/', 'icon' => 'icons.skills.filamentphp'], - ['name' => 'Alpine.js', 'link' => 'https://alpinejs.dev/', 'icon' => 'icons.skills.alpinejs'], - ['name' => 'Inertia.js', 'link' => 'https://inertiajs.com/', 'icon' => 'icons.skills.inertiajs'], - ['name' => 'TailwindCSS', 'link' => 'https://tailwindcss.com/', 'icon' => 'icons.skills.tailwind-css'], - ['name' => 'TypeScript', 'link' => 'https://www.typescriptlang.org/', 'icon' => 'icons.skills.typescript'], - ['name' => 'JavaScript', 'link' => 'https://www.javascript.com/', 'icon' => 'icons.skills.javascript'], - ['name' => 'Pest', 'link' => 'https://pestphp.com/', 'icon' => 'icons.skills.pest'], - ['name' => 'PHPUnit', 'link' => 'https://phpunit.de/', 'icon' => 'icons.skills.phpunit'], - ]; - @endphp + @foreach ($phpSkills as $skill) + + + + @endforeach - @foreach ($skills as $skill) + @foreach ($webSkills as $skill) @endforeach
+ + {{-- Render targets --}} +
+

+ Blade templates feel like HTML and render to real native + views. + + No web view required + +

+ +
+ @php + $renderTargets = [ + ['name' => 'SwiftUI', 'link' => 'https://developer.apple.com/xcode/swiftui/', 'icon' => 'icons.home.apple'], + ['name' => 'Jetpack Compose', 'link' => 'https://developer.android.com/compose', 'icon' => 'icons.home.android'], + ]; + @endphp + + @foreach ($renderTargets as $renderTarget) + + + + @endforeach +
+ +

+ Your front-end renders in a + + Chromium window. + +

+
@@ -474,7 +554,20 @@ class="size-5" 2. - Install the package. + Install + + + nativephp/mobile + + + nativephp/desktop + +
  • - {{-- Demo app --}} -
    -
    -

    - Try our - Mobile - app: -

    - - - -
    -
    - {{-- Mockups --}}
    @@ -554,141 +478,192 @@ class="inline-block font-medium text-gray-900 transition duration-200 will-chang ) } " - class="mt-4 flex w-full max-w-md flex-col items-center gap-3 sm:flex-row sm:justify-center" + class="mt-4 flex w-full max-w-md flex-col items-center gap-3" > - {{-- Mobile button --}} -
    - - {{-- Label --}} - {{-- Introduction video for mobile viewport --}} diff --git a/resources/views/components/home/skill-pill.blade.php b/resources/views/components/home/skill-pill.blade.php index 5a6fbb77..e5c286af 100644 --- a/resources/views/components/home/skill-pill.blade.php +++ b/resources/views/components/home/skill-pill.blade.php @@ -6,7 +6,7 @@ href="{{ $link }}" target="_blank" rel="noopener noreferrer" - class="dark:bg-cloud/30 dark:hover:bg-cloud/50 inline-flex items-center gap-2 rounded-full bg-white/50 py-2 pr-4 pl-3.5 text-sm text-slate-500 ring-1 ring-slate-200 transition duration-200 will-change-transform hover:scale-105 hover:bg-white/70 hover:text-black hover:ring-slate-200 2xl:py-2.5 2xl:pr-5 2xl:pl-4 2xl:text-base dark:text-gray-400 dark:ring-slate-700/80 dark:hover:text-white dark:hover:ring-slate-700" + {{ $attributes->merge(['class' => 'dark:bg-cloud/30 dark:hover:bg-cloud/50 inline-flex items-center gap-2 rounded-full bg-white/50 py-2 pr-4 pl-3.5 text-sm text-slate-500 ring-1 ring-slate-200 transition duration-200 will-change-transform hover:scale-105 hover:bg-white/70 hover:text-black hover:ring-slate-200 2xl:py-2.5 2xl:pr-5 2xl:pl-4 2xl:text-base dark:text-gray-400 dark:ring-slate-700/80 dark:hover:text-white dark:hover:ring-slate-700']) }} >
    - - - - - - - - diff --git a/resources/views/components/icons/home/arc-fan.blade.php b/resources/views/components/icons/home/arc-fan.blade.php new file mode 100644 index 00000000..020e3a41 --- /dev/null +++ b/resources/views/components/icons/home/arc-fan.blade.php @@ -0,0 +1,43 @@ +{{-- + Two arms fanning from a single point out to ±32px, matching the 48px + platform tiles stacked with a 16px gap (centres sit 64px apart). +--}} +merge(['class' => '[--grad-stop-1:--alpha(var(--color-gray-300)/100%)] [--grad-stop-2:--alpha(var(--color-blue-300)/100%)] dark:[--grad-stop-1:--alpha(var(--color-blue-300)/20%)] dark:[--grad-stop-2:--alpha(var(--color-blue-500)/80%)]']) }} + xmlns="http://www.w3.org/2000/svg" + width="67" + height="68" + viewBox="0 0 67 68" + fill="none" +> + {{-- Upper arm --}} + + {{-- Lower arm --}} + + + + + + + + diff --git a/resources/views/components/illustrations/bifrost-diagram.blade.php b/resources/views/components/illustrations/bifrost-diagram.blade.php index 1030adc3..9a6af24b 100644 --- a/resources/views/components/illustrations/bifrost-diagram.blade.php +++ b/resources/views/components/illustrations/bifrost-diagram.blade.php @@ -1,30 +1,26 @@ +{{-- + Three columns with equal 1fr flanks so the Bifrost bubble stays centred + even below 2xs, where the source column is hidden. +--}}
    {{-- Source (NativePHP app) --}} -
    diff --git a/resources/views/components/illustrations/desktop-stack.blade.php b/resources/views/components/illustrations/desktop-stack.blade.php new file mode 100644 index 00000000..04ba57e1 --- /dev/null +++ b/resources/views/components/illustrations/desktop-stack.blade.php @@ -0,0 +1,118 @@ +{{-- How a NativePHP for Desktop app is put together: a bundled PHP binary talking to an Electron shell over authenticated HTTP. --}} +
    + {{-- Title bar --}} + + {{-- Schema --}} +
    + {{-- Header --}} +
    +
    + Electron +
    +
    Shell app
    +
    + {{-- Php runtime --}} +
    +
    + PHP Binary +
    +
    +
    + Static PHP Runtime +
    +
    +
    + {{-- Core --}} +
    + {{-- Left --}} +
    +
    +
    Native
    +
    desktop
    +
    functions
    +
    + {{-- Bottom arrow --}} + +
    + {{-- Right --}} +
    +
    +
    Authenticated
    +
    HTTP
    +
    bridge
    +
    + {{-- Top arrow --}} + + {{-- Bottom arrow --}} + +
    +
    + {{-- Chromium window --}} +
    +
    + Chromium Window +
    +
    + HTML/CSS + JavaScript +
    +
    +
    +
    diff --git a/resources/views/components/illustrations/mobile-stack.blade.php b/resources/views/components/illustrations/mobile-stack.blade.php new file mode 100644 index 00000000..e579b9ab --- /dev/null +++ b/resources/views/components/illustrations/mobile-stack.blade.php @@ -0,0 +1,120 @@ +{{-- How a NativePHP for Mobile app is put together: PHP in-process, rendering real native views. --}} +
    + {{-- Phone wireframe --}} +
    diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index bd286457..4e434f5c 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -8,8 +8,17 @@ {{-- Explainer --}} - {{-- Announcements: Plugins, Bifrost, Mimi, Jump --}} - + {{-- + Announcements: Plugins, Masterclass, Jump, Bifrost. All mobile- + specific, so they drop out on the Desktop track. No x-cloak: mobile is + the default, so the first paint is already correct for most visitors. + --}} +
    + +
    {{-- Partners --}} diff --git a/tests/Feature/HomeCourseCardTest.php b/tests/Feature/HomeCourseCardTest.php index 5d933ad2..4bb6525b 100644 --- a/tests/Feature/HomeCourseCardTest.php +++ b/tests/Feature/HomeCourseCardTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature; +use DOMDocument; +use DOMXPath; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -26,4 +28,38 @@ public function the_homepage_shows_the_video_course_badge() ->assertSee('Video Course') ->assertDontSee('Early Bird'); } + + /** + * The announcement cards (Plugins, Masterclass, Jump, Bifrost) are all + * mobile-specific, so they show on the Mobile track and drop out on + * Desktop. Asserting on the wrapper rather than the page text, because the + * cards stay in the DOM either way — only their visibility changes. + */ + #[Test] + public function the_announcement_cards_are_scoped_to_the_mobile_track() + { + $dom = new DOMDocument; + libxml_use_internal_errors(true); + $dom->loadHTML(''.$this->get('/')->getContent()); + libxml_clear_errors(); + + $wrapper = (new DOMXPath($dom)) + ->query('//*[@data-platform-section="announcements"]') + ->item(0); + + $this->assertNotNull($wrapper, 'Announcements wrapper is missing.'); + + $this->assertSame( + "\$store.platform.is('mobile')", + $wrapper->getAttribute('x-show'), + 'Announcements should only show on the Mobile track.', + ); + + // Guards against the wrapper being correctly bound but empty. + $contents = $dom->saveHTML($wrapper); + + foreach (['Video Course', 'Cloud Platform', 'Jump'] as $card) { + $this->assertStringContainsString($card, $contents); + } + } } diff --git a/tests/Feature/HomeExplainerNativeUiTest.php b/tests/Feature/HomeExplainerNativeUiTest.php new file mode 100644 index 00000000..4596d4b6 --- /dev/null +++ b/tests/Feature/HomeExplainerNativeUiTest.php @@ -0,0 +1,245 @@ +blade('') + ->assertSee('SwiftUI') + ->assertSee('Jetpack Compose') + ->assertSee('shared memory.') + ->assertSee('No web view. No JSON bridge.') + ->assertDontSee('Native WebView') + ->assertDontSee('native web view'); + } + + #[Test] + public function the_mobile_diagram_shows_native_views_with_the_web_view_as_optional() + { + $this->blade('') + ->assertSee('SwiftUI & Compose', escape: false) + ->assertSee('Real native views') + ->assertSee('Web view (optional)') + ->assertDontSee('HTML/CSS + JavaScript'); + } + + #[Test] + public function the_desktop_diagram_shows_the_electron_and_chromium_stack() + { + $this->blade('') + ->assertSee('Electron') + ->assertSee('Static PHP Runtime') + ->assertSee('Chromium Window') + ->assertSee('HTML/CSS + JavaScript') + ->assertDontSee('SwiftUI'); + } + + #[Test] + public function the_explainer_renders_both_platform_paths_for_the_toggle() + { + $this->blade('') + // Mobile path + ->assertSee('Real native views') + ->assertSee('Tiny apps') + ->assertSee('nativephp/mobile') + // Desktop path + ->assertSee('Chromium Window') + ->assertSee('statically-compiled PHP binary') + ->assertSee('authenticated HTTP') + ->assertSee('One file') + ->assertSee('nativephp/desktop'); + } + + #[Test] + public function the_mobile_tools_card_reads_as_written() + { + $this->blade('') + ->assertSee('Use (almost) any Composer package!') + ->assertSeeInOrder([ + 'Blade templates feel like HTML and render to real native', + 'views.', + 'No web view required', + ]); + } + + #[Test] + public function the_explainer_blocks_are_bound_to_the_platform_store() + { + $this->blade('') + ->assertSee("\$store.platform.is('mobile')", escape: false) + ->assertSee("\$store.platform.is('desktop')", escape: false); + } + + /** + * The mobile path renders native UI, so anything that renders HTML would + * imply a web view. Those tools belong to the desktop path only. + */ + #[Test] + public function the_html_rendering_tools_are_scoped_to_the_desktop_path() + { + $scopes = $this->toolPillScopes(); + + $webOnly = [ + 'Livewire', 'FilamentPHP', 'TailwindCSS', 'Alpine.js', 'Inertia.js', + 'React', 'Vue.js', 'Nuxt', 'Next.js', 'TypeScript', 'JavaScript', + ]; + + foreach ($webOnly as $name) { + $this->assertSame('desktop', $scopes[$name] ?? null, "{$name} should be desktop-only."); + } + } + + #[Test] + public function the_platform_agnostic_php_tools_show_on_both_paths() + { + $scopes = $this->toolPillScopes(); + + foreach (['Laravel', 'Pest', 'PHPUnit'] as $name) { + $this->assertSame('all', $scopes[$name] ?? null, "{$name} should show on both paths."); + } + } + + /** + * Several skill SVGs use document-wide ids (Pest's gradient is a bare + * id="a"), so a pill rendered twice loses its fill in the second copy. + */ + #[Test] + public function each_tool_pill_is_rendered_exactly_once() + { + $names = array_map( + fn (DOMElement $pill) => $this->pillName($pill), + iterator_to_array($this->toolPills()), + ); + + $this->assertSame( + array_unique($names), + $names, + 'A tool pill is rendered more than once, which breaks SVGs with shared ids.', + ); + } + + /** + * @return array pill name => the path it appears on + */ + private function toolPillScopes(): array + { + $scopes = []; + + foreach ($this->toolPills() as $pill) { + $scopes[$this->pillName($pill)] = $pill->getAttribute('data-tools'); + } + + return $scopes; + } + + private function toolPills(): DOMNodeList + { + $dom = new DOMDocument; + libxml_use_internal_errors(true); + $dom->loadHTML(''.$this->blade('')); + libxml_clear_errors(); + + return (new DOMXPath($dom))->query('//a[@data-tools]'); + } + + private function pillName(DOMElement $pill): string + { + return trim(preg_replace('/\s+/', ' ', $pill->textContent)); + } + + #[Test] + public function the_hero_offers_a_platform_chooser_defaulting_to_mobile() + { + $this->blade('') + ->assertSee('I want to build a', escape: false) + ->assertSee('role="tablist"', escape: false) + ->assertSee("\$store.platform.select('mobile')", escape: false) + ->assertSee("\$store.platform.select('desktop')", escape: false) + ->assertSee('Mobile app') + ->assertSee('Desktop app') + // No-JS fallback points at the Mobile docs + ->assertSee('href="/docs/mobile/getting-started/introduction"', escape: false) + ->assertSee('Read the Mobile docs'); + } + + #[Test] + public function the_hero_no_longer_promotes_the_demo_app_downloads() + { + $this->blade('') + ->assertDontSee('Try our') + ->assertDontSee('TestFlight') + ->assertDontSee('Play Store') + ->assertDontSee('testflight.apple.com', escape: false); + } + + /** + * v4 is a prerelease, so the unversioned URL would resolve to the latest + * stable docs (v3), which has no architecture section. + */ + #[Test] + public function the_under_the_hood_section_links_to_the_supernative_intro() + { + $this->blade('') + ->assertSee('Learn more about SuperNative') + ->assertSee('/docs/mobile/4/architecture/super-native', escape: false); + } + + #[Test] + public function the_supernative_page_the_homepage_links_to_exists() + { + $this->get('/docs/mobile/4/architecture/super-native') + ->assertOk(); + } + + #[Test] + public function the_mobile_path_names_supernative() + { + $this->blade('') + ->assertSeeInOrder([ + 'No web view. No JSON bridge.', + 'We call this', + 'SuperNative.', + ]); + } + + #[Test] + public function the_desktop_render_target_note_drops_the_gui_framework_line() + { + $this->blade('') + ->assertSee('Chromium window.') + ->assertDontSee("isn't a GUI framework", escape: false); + } + + #[Test] + public function the_bifrost_diagram_no_longer_advertises_a_windows_track() + { + $this->blade('') + ->assertSee('Apple (macOS)') + ->assertSee('Android') + ->assertDontSee('Windows'); + } + + #[Test] + public function the_homepage_renders_both_paths() + { + $this->get('/') + ->assertOk() + ->assertSee('Real native views') + ->assertSee('Jetpack Compose') + ->assertSee('Chromium Window') + ->assertDontSee('Native WebView'); + } +} From 4888dd7cae109ad8b07f9720c84f0964f4745cf9 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 22 Jul 2026 15:56:13 +0100 Subject: [PATCH 2/4] Fix CI: fake Torchlight for the SuperNative docs-page test The test renders a docs page with fenced code blocks; Torchlight throws outside production when no token is configured (as in CI). Worse, the thrown request skipped the middleware's clearBlocks(), so the static block registry leaked into every later HTML response in the process, 500ing 12 unrelated tests. Fake the token and API (the DocumentationRenderingTest pattern) and clear the registry in tearDown so a failure here can never cascade. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Feature/HomeExplainerNativeUiTest.php | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Feature/HomeExplainerNativeUiTest.php b/tests/Feature/HomeExplainerNativeUiTest.php index 4596d4b6..f08fa521 100644 --- a/tests/Feature/HomeExplainerNativeUiTest.php +++ b/tests/Feature/HomeExplainerNativeUiTest.php @@ -7,13 +7,28 @@ use DOMNodeList; use DOMXPath; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; +use Torchlight\Blade\BladeManager; class HomeExplainerNativeUiTest extends TestCase { use RefreshDatabase; + /** + * Torchlight's block registry is a static array. If a request that + * registered blocks throws before the RenderTorchlight middleware clears + * them, they leak into every later HTML response in the same PHPUnit + * process — 500ing unrelated tests. Clear them unconditionally. + */ + protected function tearDown(): void + { + BladeManager::clearBlocks(); + + parent::tearDown(); + } + #[Test] public function the_mobile_path_leads_with_native_rendering_not_a_web_view() { @@ -200,6 +215,14 @@ public function the_under_the_hood_section_links_to_the_supernative_intro() #[Test] public function the_supernative_page_the_homepage_links_to_exists() { + // The page contains fenced code blocks. Torchlight throws outside + // production when no token is configured (as in CI), so give it a + // token and fake the API to force its offline fallback. + config(['torchlight.token' => 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + $this->get('/docs/mobile/4/architecture/super-native') ->assertOk(); } From b8898b11726f6d217ba4b60038e45dcc4e98463f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 23 Jul 2026 02:09:20 +0100 Subject: [PATCH 3/4] Mobile v4 changelog: pull release candidates from GitHub release notes releasesAfter('4.0.0') excluded every 4.0.0-rc.* tag because version_compare ranks prereleases below the release, so the automated feed rendered nothing while only RCs exist. Add releasesFrom(), which filters on a "-dev" floor (dev ranks below every prerelease marker), admitting alphas, betas, RCs and the release itself, and switch the v4 changelog to it. Covered by unit tests and a page test against a faked GitHub API. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Support/GitHub.php | 16 ++++ .../mobile/4/getting-started/changelog.md | 2 +- tests/Feature/MobileChangelogTest.php | 78 +++++++++++++++++++ tests/Feature/Support/GitHubTest.php | 37 +++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/MobileChangelogTest.php diff --git a/app/Support/GitHub.php b/app/Support/GitHub.php index 3daf6382..9786c9bc 100644 --- a/app/Support/GitHub.php +++ b/app/Support/GitHub.php @@ -79,6 +79,22 @@ public function releasesAfter(string $version): Collection )->values(); } + /** + * Releases for the given version and everything later, including that + * prefix version itself and its prereleases. releasesAfter("4.0.0") + * would exclude 4.0.0-rc.1 because version_compare ranks prereleases + * below the release; "dev" ranks below every other prerelease marker, + * so a "-dev" floor admits alphas, betas, RCs and the release itself. + */ + public function releasesFrom(string $version): Collection + { + $floor = ltrim($version, 'v').'-dev'; + + return $this->releases()->filter( + fn (Release $release) => version_compare(ltrim((string) $release->tag_name, 'v'), $floor, '>=') + )->values(); + } + private function fetchLatestVersion(): ?Release { // Make a request to GitHub diff --git a/resources/views/docs/mobile/4/getting-started/changelog.md b/resources/views/docs/mobile/4/getting-started/changelog.md index 5f3026d0..f156c415 100644 --- a/resources/views/docs/mobile/4/getting-started/changelog.md +++ b/resources/views/docs/mobile/4/getting-started/changelog.md @@ -5,7 +5,7 @@ order: 2 For changes prior to v4, see the [v3 documentation](/docs/mobile/3/getting-started/changelog). -@forelse (\App\Support\GitHub::mobileAir()->releasesAfter('4.0.0') as $release) +@forelse (\App\Support\GitHub::mobileAir()->releasesFrom('4.0.0') as $release) ## {{ $release->name ?: $release->tag_name }} **Released: {{ \Carbon\Carbon::parse($release->published_at)->format('F j, Y') }}** diff --git a/tests/Feature/MobileChangelogTest.php b/tests/Feature/MobileChangelogTest.php new file mode 100644 index 00000000..74a7b402 --- /dev/null +++ b/tests/Feature/MobileChangelogTest.php @@ -0,0 +1,78 @@ + Http::response([ + [ + 'id' => 1, + 'tag_name' => 'v4.0.0-rc.2', + 'name' => 'v4.0.0-rc.2', + 'body' => 'Fixed gesture handling in the SuperNative renderer by @simonhamp in https://github.com/NativePHP/mobile-air/pull/123', + 'published_at' => '2026-07-15T00:00:00Z', + ], + [ + 'id' => 2, + 'tag_name' => 'v4.0.0-rc.1', + 'name' => 'v4.0.0-rc.1', + 'body' => 'First release candidate', + 'published_at' => '2026-07-01T00:00:00Z', + ], + [ + 'id' => 3, + 'tag_name' => 'v3.2.0', + 'name' => 'v3.2.0', + 'body' => 'A v3 release that must not appear on the v4 page', + 'published_at' => '2026-05-01T00:00:00Z', + ], + ]), + // The page contains fenced code blocks; Torchlight throws outside + // production without a token (as in CI), so fake its API too. + '*' => Http::response(['blocks' => []], 200), + ]); + + config(['torchlight.token' => 'test-token']); + } + + public function test_the_v4_changelog_lists_release_candidates_from_github(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSeeInOrder(['v4.0.0-rc.2', 'v4.0.0-rc.1']) + ->assertSee('Released: July 15, 2026') + ->assertSee('First release candidate'); + } + + public function test_the_v4_changelog_excludes_v3_releases(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertDontSee('A v3 release that must not appear'); + } + + public function test_release_bodies_link_pull_requests_and_authors(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSee('https://github.com/NativePHP/mobile-air/pull/123', false) + ->assertSee('https://github.com/simonhamp', false); + } +} diff --git a/tests/Feature/Support/GitHubTest.php b/tests/Feature/Support/GitHubTest.php index de171aa5..05619710 100644 --- a/tests/Feature/Support/GitHubTest.php +++ b/tests/Feature/Support/GitHubTest.php @@ -98,6 +98,43 @@ public function test_releases_after_handles_tag_names_without_a_leading_v(): voi $this->assertEquals('3.2.0', $releases->first()->tag_name); } + public function test_releases_from_includes_prereleases_of_the_boundary_version(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/releases*' => Http::response([ + ['id' => 1, 'tag_name' => 'v4.0.1', 'name' => 'v4.0.1', 'body' => '', 'published_at' => '2026-08-01T00:00:00Z'], + ['id' => 2, 'tag_name' => 'v4.0.0', 'name' => 'v4.0.0', 'body' => '', 'published_at' => '2026-07-20T00:00:00Z'], + ['id' => 3, 'tag_name' => 'v4.0.0-rc.2', 'name' => 'v4.0.0-rc.2', 'body' => '', 'published_at' => '2026-07-10T00:00:00Z'], + ['id' => 4, 'tag_name' => 'v4.0.0-rc.1', 'name' => 'v4.0.0-rc.1', 'body' => '', 'published_at' => '2026-07-01T00:00:00Z'], + ['id' => 5, 'tag_name' => 'v4.0.0-beta.1', 'name' => 'v4.0.0-beta.1', 'body' => '', 'published_at' => '2026-06-15T00:00:00Z'], + ['id' => 6, 'tag_name' => 'v3.2.9', 'name' => 'v3.2.9', 'body' => '', 'published_at' => '2026-06-01T00:00:00Z'], + ['id' => 7, 'tag_name' => 'v3.2.0', 'name' => 'v3.2.0', 'body' => '', 'published_at' => '2026-05-01T00:00:00Z'], + ]), + ]); + + $releases = GitHub::mobileAir()->releasesFrom('4.0.0'); + + $this->assertEquals( + ['v4.0.1', 'v4.0.0', 'v4.0.0-rc.2', 'v4.0.0-rc.1', 'v4.0.0-beta.1'], + $releases->pluck('tag_name')->all() + ); + } + + public function test_releases_from_handles_tag_names_without_a_leading_v(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/releases*' => Http::response([ + ['id' => 1, 'tag_name' => '4.0.0-rc.1', 'name' => '4.0.0-rc.1', 'body' => '', 'published_at' => '2026-07-01T00:00:00Z'], + ['id' => 2, 'tag_name' => '3.2.0', 'name' => '3.2.0', 'body' => '', 'published_at' => '2026-05-01T00:00:00Z'], + ]), + ]); + + $releases = GitHub::mobileAir()->releasesFrom('v4.0.0'); + + $this->assertCount(1, $releases); + $this->assertEquals('4.0.0-rc.1', $releases->first()->tag_name); + } + public function test_releases_after_returns_empty_collection_when_request_fails(): void { Http::fake([ From 8bc7dc626dc7286ba0259b71ce8c2550629ff4d3 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 23 Jul 2026 04:35:06 +0100 Subject: [PATCH 4/4] Promote Mobile v4 docs to stable; SuperNative banner, changelog and homepage updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docs: v4 is now the latest version — beta notice retired (machinery still tested via pinned config), unversioned URLs and hardcoded v3 links resolve to the latest version, renamed pages 301 in one hop - Changelog: pull v4 release candidates from GitHub release notes, fix double-encoded links in hand-written release bodies, cap the v3 feed below 4.0.0, replace the static v4.0 section with the automated feed - New SuperNative header banner replacing The Vibes event banner - Homepage: announcement row moved above Plugins with Jump leading, Jump/Bifrost card copy and pricing ($10/mo), matched card padding, and hover transitions scoped to shadow/colors so the card fade-ins no longer fight gsap frame-by-frame --- .../PluginReviewChecksIncomplete.php | 2 +- app/Support/GitHub.php | 18 ++++- app/Support/GitHub/Release.php | 16 ++-- config/docs.php | 5 +- .../components/blog/ad-rotation.blade.php | 2 +- .../docs/platform-switcher.blade.php | 4 +- resources/views/components/footer.blade.php | 4 +- .../components/home/announcements.blade.php | 18 ++--- .../components/home/bifrost-card.blade.php | 17 ++-- .../components/home/course-card.blade.php | 6 +- .../views/components/home/explainer.blade.php | 5 +- .../views/components/home/jump-card.blade.php | 11 ++- resources/views/components/layout.blade.php | 2 +- .../navbar/device-dropdowns.blade.php | 4 +- .../components/supernative-banner.blade.php | 63 +++++++++++++++ .../mobile/3/getting-started/changelog.md | 2 +- .../mobile/4/getting-started/changelog.md | 23 +----- .../customer/developer/onboarding.blade.php | 2 +- resources/views/plugin-show.blade.php | 2 +- routes/web.php | 6 ++ tests/Feature/DocsPrereleaseVersionTest.php | 45 ++++++++++- tests/Feature/HomeCourseCardTest.php | 12 +++ tests/Feature/HomeExplainerNativeUiTest.php | 4 +- tests/Feature/MobileChangelogTest.php | 40 +++++++++- .../PluginShowInstallCredentialsTest.php | 2 +- tests/Feature/SupernativeBannerTest.php | 24 ++++++ tests/Feature/Support/GitHub/ReleaseTest.php | 78 +++++++++++++++++++ tests/Feature/Support/GitHubTest.php | 16 ++++ tests/Unit/DocsVersionServiceTest.php | 2 +- 29 files changed, 355 insertions(+), 80 deletions(-) create mode 100644 resources/views/components/supernative-banner.blade.php create mode 100644 tests/Feature/SupernativeBannerTest.php diff --git a/app/Notifications/PluginReviewChecksIncomplete.php b/app/Notifications/PluginReviewChecksIncomplete.php index fa95d078..1d7a6928 100644 --- a/app/Notifications/PluginReviewChecksIncomplete.php +++ b/app/Notifications/PluginReviewChecksIncomplete.php @@ -12,7 +12,7 @@ class PluginReviewChecksIncomplete extends Notification implements ShouldQueue { use Queueable; - private const DOCS_BASE = 'https://nativephp.com/docs/mobile/3/plugins'; + private const DOCS_BASE = 'https://nativephp.com/docs/mobile/plugins'; /** * @var array diff --git a/app/Support/GitHub.php b/app/Support/GitHub.php index 9786c9bc..639c107b 100644 --- a/app/Support/GitHub.php +++ b/app/Support/GitHub.php @@ -70,13 +70,23 @@ public function releases(): Collection ) ?? collect(); } - public function releasesAfter(string $version): Collection + /** + * Releases strictly after the given version, optionally capped below + * another version and its prereleases — without a cap, a versioned + * changelog would list the next major's releases too (4.0.0-rc.1 + * compares greater than any 3.x tag). + */ + public function releasesAfter(string $version, ?string $before = null): Collection { $version = ltrim($version, 'v'); + $ceiling = $before === null ? null : ltrim($before, 'v').'-dev'; - return $this->releases()->filter( - fn (Release $release) => version_compare(ltrim((string) $release->tag_name, 'v'), $version, '>') - )->values(); + return $this->releases()->filter(function (Release $release) use ($version, $ceiling) { + $tag = ltrim((string) $release->tag_name, 'v'); + + return version_compare($tag, $version, '>') + && ($ceiling === null || version_compare($tag, $ceiling, '<')); + })->values(); } /** diff --git a/app/Support/GitHub/Release.php b/app/Support/GitHub/Release.php index 11522465..82a22f2a 100644 --- a/app/Support/GitHub/Release.php +++ b/app/Support/GitHub/Release.php @@ -49,25 +49,31 @@ public function getBodyForMarkdown(): string { $body = $this->body; - // Convert any URLs to Markdown links + // Convert bare URLs to Markdown links. Release bodies can already + // contain Markdown links, so URLs preceded by "(" or "<" are left + // alone — wrapping those again nests link syntax and the page ends + // up rendering both the text and the URL. Closing ")" and "]" are + // excluded from the URL so a link's own delimiters are never + // swallowed into it. if ($this->convertLinks) { $body = preg_replace( - '/https?:\/\/[^\s]+\/pull\/(\d+)/', + '/(?withUserLinks) { $body = preg_replace( - '/@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)/', + '/(? [ 'desktop' => 2, - 'mobile' => 3, + 'mobile' => 4, ], /* @@ -32,7 +32,7 @@ 'prerelease_versions' => [ 'desktop' => [], - 'mobile' => [4], + 'mobile' => [], ], /* @@ -53,6 +53,7 @@ 4 => [ 'the-basics/native-components' => 'the-basics/native-ui', 'the-basics/dialog' => 'the-basics/dialogs', + 'the-basics/web-view' => 'edge-components/web-view', 'getting-started/deployment' => 'publishing/introduction', 'plugins/vibe' => 'digging-deeper/websockets', diff --git a/resources/views/components/blog/ad-rotation.blade.php b/resources/views/components/blog/ad-rotation.blade.php index 023572c9..375478c9 100644 --- a/resources/views/components/blog/ad-rotation.blade.php +++ b/resources/views/components/blog/ad-rotation.blade.php @@ -180,7 +180,7 @@ class="group relative z-0 grid place-items-center overflow-hidden rounded-2xl bg {{-- Price hint --}}
    - Plans from $19/mo + Plans from $10/mo
    {{-- Decorative stars --}} diff --git a/resources/views/components/docs/platform-switcher.blade.php b/resources/views/components/docs/platform-switcher.blade.php index 8e4f3d5d..7474154d 100644 --- a/resources/views/components/docs/platform-switcher.blade.php +++ b/resources/views/components/docs/platform-switcher.blade.php @@ -1,7 +1,7 @@ @php $isMobile = request()->is('docs/mobile/*'); - $mobileHref = '/docs/mobile/3'; - $desktopHref = '/docs/desktop/2'; + $mobileHref = '/docs/mobile/'.config('docs.latest_versions.mobile'); + $desktopHref = '/docs/desktop/'.config('docs.latest_versions.desktop'); @endphp
  • Documentation @@ -420,7 +420,7 @@ class="flex flex-col items-start text-sm text-gray-500 dark:text-gray-400" >
  • Documentation diff --git a/resources/views/components/home/announcements.blade.php b/resources/views/components/home/announcements.blade.php index 63cc4da6..1e531353 100644 --- a/resources/views/components/home/announcements.blade.php +++ b/resources/views/components/home/announcements.blade.php @@ -1,22 +1,22 @@ -{{-- Announcements Section: Plugins (full width) + Bifrost (50%) & Course/Jump (50% stacked) --}} +{{-- Announcements Section: Jump/Course (50% stacked) & Bifrost (50%) + Plugins (full width) --}}
    - {{-- Plugins Announcement (Full Width) --}} - - - {{-- Course & Jump + Bifrost Row --}} + {{-- Jump & Course + Bifrost Row --}}
    - {{-- Left Column - Course & Jump --}} + {{-- Left Column - Jump & Course --}}
    - {{-- Course Card --}} - - {{-- Jump Card --}} + + {{-- Course Card --}} +
    {{-- Bifrost Banner (Right - 50%) --}}
    + + {{-- Plugins Announcement (Full Width) --}} +
    \ No newline at end of file diff --git a/resources/views/components/home/bifrost-card.blade.php b/resources/views/components/home/bifrost-card.blade.php index 4c039ca1..2cac56e6 100644 --- a/resources/views/components/home/bifrost-card.blade.php +++ b/resources/views/components/home/bifrost-card.blade.php @@ -4,7 +4,7 @@ target="_blank" rel="noopener noreferrer" onclick="fathom.trackEvent('bifrost_card_click');" - class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br from-sky-500/20 via-indigo-500/10 to-purple-500/20 p-0.5 ring-1 ring-zinc-200/50 transition duration-300 hover:ring-sky-400/50 dark:ring-sky-500/30" + class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br from-sky-500/20 via-indigo-500/10 to-purple-500/20 p-0.5 ring-1 ring-zinc-200/50 transition-shadow duration-300 hover:ring-sky-400/50 dark:ring-sky-500/30" x-init=" () => { motion.inView($el, (element) => { @@ -15,7 +15,7 @@ class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br y: 0, autoAlpha: 1, duration: 0.6, - delay: 0.1, + delay: 0.3, ease: 'power2.out', }, ) @@ -23,7 +23,7 @@ class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br } " > -
  • diff --git a/resources/views/docs/mobile/3/getting-started/changelog.md b/resources/views/docs/mobile/3/getting-started/changelog.md index ec985749..ef690edf 100644 --- a/resources/views/docs/mobile/3/getting-started/changelog.md +++ b/resources/views/docs/mobile/3/getting-started/changelog.md @@ -5,7 +5,7 @@ order: 2 For changes prior to v3, see the [v2 documentation](/docs/mobile/2/getting-started/changelog). -@forelse (\App\Support\GitHub::mobileAir()->releasesAfter('3.1.0') as $release) +@forelse (\App\Support\GitHub::mobileAir()->releasesAfter('3.1.0', before: '4.0.0') as $release) ## {{ $release->name ?: $release->tag_name }} **Released: {{ \Carbon\Carbon::parse($release->published_at)->format('F j, Y') }}** diff --git a/resources/views/docs/mobile/4/getting-started/changelog.md b/resources/views/docs/mobile/4/getting-started/changelog.md index f156c415..b4637ba6 100644 --- a/resources/views/docs/mobile/4/getting-started/changelog.md +++ b/resources/views/docs/mobile/4/getting-started/changelog.md @@ -12,25 +12,6 @@ For changes prior to v4, see the [v3 documentation](/docs/mobile/3/getting-start {{ $release->getBodyForMarkdown() }} --- @empty +Release notes for v4 will appear here as releases are published. In the +meantime, start with the [SuperNative introduction](../architecture/super-native). @endforelse - -## v4.0 — SuperNative (beta) - -### New Features - -- **[SuperNative](../architecture/super-native)** — fully native UI powered by SwiftUI on iOS and Jetpack Compose on Android, and the new default for v4 apps -- **Livewire-like native components** — each screen is a PHP `NativeComponent` class holding its state and behavior, re-rendering the native UI as your properties change -- **Shared memory between PHP and the native layer** — no serialization overhead or web view bridge between your code and the UI -- **[Web view as a component](../edge-components/web-view)** — the classic web-view-first approach is now opt-out: render a single native route with a full-screen web view to keep building the v3 way -- **[Layouts](../the-basics/layouts)** — declare shared chrome (nav bars, tab bars) once in a `NativeLayout` class and attach it to a route or group of routes -- **[Native navigation stack](../the-basics/routing)** — register screens with `Route::native()`, then push, pop, and replace them with native transitions -- **[Component testing suite](../testing/introduction)** — mount a `NativeComponent`, drive interactions, and assert on state and output entirely in-process, with no device or simulator - -### Breaking Changes - -- **Device, Dialog, File and System are now core built-ins** — the `nativephp/mobile-device`, `nativephp/mobile-dialog`, `nativephp/mobile-file`, and `nativephp/mobile-system` plugins have moved into `nativephp/mobile`. **Remove the standalone plugins before upgrading** (see the [Upgrade Guide](upgrade-guide)). The facades and events are unchanged, so no application code changes are needed. -- **The Vite dev server is now opt-in** — `native:run` / `native:watch` no longer start Vite automatically. Pass `--vite` to enable JS/CSS HMR. The `--no-vite` flag still works but is now redundant. See [Hot Reloading](development#hot-reloading). - -### For Plugin Developers - -- **No breaking changes** to the plugin architecture — add the `^4.0` constraint to your plugin's `nativephp/mobile` dependency and you're done diff --git a/resources/views/livewire/customer/developer/onboarding.blade.php b/resources/views/livewire/customer/developer/onboarding.blade.php index 005c7183..ac86e285 100644 --- a/resources/views/livewire/customer/developer/onboarding.blade.php +++ b/resources/views/livewire/customer/developer/onboarding.blade.php @@ -211,7 +211,7 @@ class="mt-0.5 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-5
    What do I need to get started? - A new Stripe account will be created for you as part of the onboarding process. You'll also need a GitHub account with a private repository for your plugin. Check out the plugin documentation to start building. + A new Stripe account will be created for you as part of the onboarding process. You'll also need a GitHub account with a private repository for your plugin. Check out the plugin documentation to start building.
    diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index be18bc15..8ba6795c 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -212,7 +212,7 @@ class="size-4 shrink-0 text-indigo-500 transition-transform duration-200 dark:te

    - Full walkthrough in the Using Plugins guide → + Full walkthrough in the Using Plugins guide →

    diff --git a/routes/web.php b/routes/web.php index d2c51260..b4199b14 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,7 @@ use App\Models\Course; use App\Models\Product; use App\Services\CartService; +use App\Services\DocsVersionService; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\UrlGenerationException; use Illuminate\Support\Facades\Route; @@ -272,6 +273,11 @@ ->last() ?? '1'; + // Map renamed pages to their slug in the latest version up front, so a + // stale unversioned link 301s straight to its new home instead of + // chaining through a second redirect. + $page = app(DocsVersionService::class)->resolvePageForVersion($platform, $latestVersion, $page); + return redirect("/docs/{$platform}/{$latestVersion}/{$page}", 301); }) ->where('platform', 'desktop|mobile') diff --git a/tests/Feature/DocsPrereleaseVersionTest.php b/tests/Feature/DocsPrereleaseVersionTest.php index de211842..4b108e08 100644 --- a/tests/Feature/DocsPrereleaseVersionTest.php +++ b/tests/Feature/DocsPrereleaseVersionTest.php @@ -25,8 +25,23 @@ protected function setUp(): void ]); } + /** + * v4 is promoted to stable, so these tests pin the config back to the + * pre-promotion state to keep the prerelease machinery covered for the + * next major. + */ + private function pretendVersionFourIsStillPrerelease(): void + { + config([ + 'docs.latest_versions.mobile' => 3, + 'docs.prerelease_versions.mobile' => [4], + ]); + } + public function test_prerelease_pages_show_the_beta_notice(): void { + $this->pretendVersionFourIsStillPrerelease(); + $response = $this->get('/docs/mobile/4/edge-components/button'); $response->assertOk(); @@ -36,6 +51,8 @@ public function test_prerelease_pages_show_the_beta_notice(): void public function test_beta_notice_links_to_the_same_page_when_it_exists_on_stable(): void { + $this->pretendVersionFourIsStillPrerelease(); + // top-bar exists in v3 edge-components, so the notice should deep-link to it. $this->get('/docs/mobile/4/edge-components/top-bar') ->assertOk() @@ -49,22 +66,36 @@ public function test_beta_notice_links_to_the_same_page_when_it_exists_on_stable public function test_stable_pages_do_not_show_the_beta_notice(): void { + $this->get('/docs/mobile/4/getting-started/introduction') + ->assertOk() + ->assertDontSee('pre-release documentation'); + $this->get('/docs/mobile/3/getting-started/introduction') ->assertOk() ->assertDontSee('pre-release documentation'); } + public function test_old_versions_point_to_the_promoted_version(): void + { + $this->get('/docs/mobile/3/getting-started/introduction') + ->assertOk() + ->assertSee('/docs/mobile/4/getting-started/introduction'); + } + public function test_unversioned_docs_urls_redirect_to_the_stable_version(): void { $this->get('/docs/mobile') - ->assertRedirect('/docs/mobile/3/getting-started/introduction'); + ->assertRedirect('/docs/mobile/4/getting-started/introduction'); + // the-basics/web-view became the web-view EDGE component in v4. $this->get('/docs/mobile/the-basics/web-view') - ->assertRedirect('/docs/mobile/3/the-basics/web-view'); + ->assertRedirect('/docs/mobile/4/edge-components/web-view'); } public function test_version_switcher_lists_newest_first_and_labels_beta(): void { + $this->pretendVersionFourIsStillPrerelease(); + $response = $this->get('/docs/mobile/4/getting-started/introduction'); $content = $response->getContent(); @@ -78,6 +109,14 @@ public function test_version_switcher_lists_newest_first_and_labels_beta(): void ); } + public function test_version_switcher_no_longer_labels_the_promoted_version_beta(): void + { + $this->get('/docs/mobile/4/getting-started/introduction') + ->assertOk() + ->assertDontSee('Version 4.x (beta)') + ->assertSee('Version 4.x'); + } + public function test_supernative_shield_badges_are_removed_from_navigation(): void { $this->get('/docs/mobile/4/edge-components/button') @@ -99,6 +138,8 @@ public function test_renamed_pages_redirect_to_their_equivalent_within_a_version public function test_beta_notice_maps_renamed_pages_to_their_stable_equivalent(): void { + $this->pretendVersionFourIsStillPrerelease(); + $this->get('/docs/mobile/4/the-basics/native-ui') ->assertOk() ->assertSee('/docs/mobile/3/the-basics/native-components'); diff --git a/tests/Feature/HomeCourseCardTest.php b/tests/Feature/HomeCourseCardTest.php index 4bb6525b..ed008242 100644 --- a/tests/Feature/HomeCourseCardTest.php +++ b/tests/Feature/HomeCourseCardTest.php @@ -62,4 +62,16 @@ public function the_announcement_cards_are_scoped_to_the_mobile_track() $this->assertStringContainsString($card, $contents); } } + + #[Test] + public function the_announcement_cards_lead_with_jump_then_masterclass_then_plugins(): void + { + $this->get('/') + ->assertOk() + ->assertSeeInOrder([ + 'Code here. Jump there.', + 'The Masterclass', + 'Plugins', + ]); + } } diff --git a/tests/Feature/HomeExplainerNativeUiTest.php b/tests/Feature/HomeExplainerNativeUiTest.php index f08fa521..8e024a0f 100644 --- a/tests/Feature/HomeExplainerNativeUiTest.php +++ b/tests/Feature/HomeExplainerNativeUiTest.php @@ -201,8 +201,8 @@ public function the_hero_no_longer_promotes_the_demo_app_downloads() } /** - * v4 is a prerelease, so the unversioned URL would resolve to the latest - * stable docs (v3), which has no architecture section. + * The version is pinned deliberately — SuperNative is a v4 story, so the + * link should keep working even after a future major becomes the latest. */ #[Test] public function the_under_the_hood_section_links_to_the_supernative_intro() diff --git a/tests/Feature/MobileChangelogTest.php b/tests/Feature/MobileChangelogTest.php index 74a7b402..6f597f48 100644 --- a/tests/Feature/MobileChangelogTest.php +++ b/tests/Feature/MobileChangelogTest.php @@ -33,7 +33,7 @@ protected function setUp(): void 'id' => 2, 'tag_name' => 'v4.0.0-rc.1', 'name' => 'v4.0.0-rc.1', - 'body' => 'First release candidate', + 'body' => 'First release candidate. See [SuperNative](https://nativephp.com/docs/mobile/4/architecture/super-native) for the full story.', 'published_at' => '2026-07-01T00:00:00Z', ], [ @@ -58,7 +58,21 @@ public function test_the_v4_changelog_lists_release_candidates_from_github(): vo ->assertOk() ->assertSeeInOrder(['v4.0.0-rc.2', 'v4.0.0-rc.1']) ->assertSee('Released: July 15, 2026') - ->assertSee('First release candidate'); + ->assertSee('First release candidate') + // The hand-written v4.0 section was replaced by the automated feed. + ->assertDontSee('v4.0 — SuperNative (beta)'); + } + + public function test_the_v4_changelog_shows_a_fallback_when_no_releases_exist(): void + { + // Pre-seed the releases cache so the page renders the @empty branch + // without fighting the Http fakes registered in setUp. + Cache::put('nativephp/mobile-air-releases', collect()); + + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSee('Release notes for v4 will appear here') + ->assertDontSee('v4.0.0-rc.2'); } public function test_the_v4_changelog_excludes_v3_releases(): void @@ -75,4 +89,26 @@ public function test_release_bodies_link_pull_requests_and_authors(): void ->assertSee('https://github.com/NativePHP/mobile-air/pull/123', false) ->assertSee('https://github.com/simonhamp', false); } + + /** + * Hand-written release notes already contain Markdown links; re-wrapping + * their URLs nests the link syntax and the page renders both the text + * and the URL. + */ + public function test_markdown_links_in_release_bodies_render_as_single_links(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSee('href="https://nativephp.com/docs/mobile/4/architecture/super-native"', false) + ->assertDontSee('[SuperNative]', false) + ->assertDontSee('](https://nativephp.com', false); + } + + public function test_the_v3_changelog_does_not_list_v4_prereleases(): void + { + $this->get('/docs/mobile/3/getting-started/changelog') + ->assertOk() + ->assertSee('v3.2.0') + ->assertDontSee('4.0.0-rc'); + } } diff --git a/tests/Feature/PluginShowInstallCredentialsTest.php b/tests/Feature/PluginShowInstallCredentialsTest.php index ec68b7c5..61569a82 100644 --- a/tests/Feature/PluginShowInstallCredentialsTest.php +++ b/tests/Feature/PluginShowInstallCredentialsTest.php @@ -84,7 +84,7 @@ public function test_install_box_links_to_the_using_plugins_guide(): void $this->get(route('plugins.show', $plugin->routeParams())) ->assertStatus(200) - ->assertSee(url('docs/mobile/3/plugins/using-plugins'), false) + ->assertSee(url('docs/mobile/plugins/using-plugins'), false) ->assertSee('Using Plugins guide'); } } diff --git a/tests/Feature/SupernativeBannerTest.php b/tests/Feature/SupernativeBannerTest.php new file mode 100644 index 00000000..13ac023e --- /dev/null +++ b/tests/Feature/SupernativeBannerTest.php @@ -0,0 +1,24 @@ +get('/') + ->assertOk() + ->assertSee('NativePHP for Mobile v4') + ->assertSee('/docs/mobile/4/architecture/super-native', escape: false) + ->assertSee('supernative_banner_click', escape: false) + // The Vibes event banner is replaced. + ->assertDontSee('Get your ticket'); + } +} diff --git a/tests/Feature/Support/GitHub/ReleaseTest.php b/tests/Feature/Support/GitHub/ReleaseTest.php index 593931f1..b516c2ab 100644 --- a/tests/Feature/Support/GitHub/ReleaseTest.php +++ b/tests/Feature/Support/GitHub/ReleaseTest.php @@ -39,4 +39,82 @@ public function test_it_does_not_match_trailing_hyphens_in_at_mentions(): void $this->assertStringContainsString('[@foo](https://github.com/foo)- bar', $release->getBodyForMarkdown()); } + + public function test_it_leaves_existing_markdown_links_untouched(): void + { + $release = new Release([ + 'body' => '* [SuperNative](https://nativephp.com/docs/mobile/4/architecture/super-native) — fully native UI', + ]); + + $output = $release->getBodyForMarkdown(); + + $this->assertStringContainsString( + '[SuperNative](https://nativephp.com/docs/mobile/4/architecture/super-native)', + $output, + ); + $this->assertStringNotContainsString('[[', $output); + $this->assertStringNotContainsString('([', $output); + $this->assertStringNotContainsString(')]', $output); + } + + public function test_it_leaves_pull_request_urls_inside_markdown_links_untouched(): void + { + $release = new Release([ + 'body' => 'See [the PR](https://github.com/NativePHP/mobile-air/pull/158) for details.', + ]); + + $this->assertStringContainsString( + '[the PR](https://github.com/NativePHP/mobile-air/pull/158)', + $release->getBodyForMarkdown(), + ); + } + + public function test_it_converts_bare_pull_request_urls(): void + { + $release = new Release([ + 'body' => '* Fix: Remove stray tag by @martin-ro in https://github.com/NativePHP/mobile-air/pull/158', + ]); + + $output = $release->getBodyForMarkdown(); + + $this->assertStringContainsString('[#158](https://github.com/NativePHP/mobile-air/pull/158)', $output); + $this->assertStringContainsString('[@martin-ro](https://github.com/martin-ro)', $output); + } + + public function test_it_converts_other_bare_urls(): void + { + $release = new Release([ + 'body' => '**Full Changelog**: https://github.com/NativePHP/mobile-air/compare/3.3.6...4.0.0-rc.1', + ]); + + $this->assertStringContainsString( + '[https://github.com/NativePHP/mobile-air/compare/3.3.6...4.0.0-rc.1](https://github.com/NativePHP/mobile-air/compare/3.3.6...4.0.0-rc.1)', + $release->getBodyForMarkdown(), + ); + } + + public function test_it_does_not_relink_usernames_that_are_already_link_text(): void + { + $release = new Release([ + 'body' => 'Thanks to [@simonhamp](https://github.com/simonhamp) for shipping this.', + ]); + + $output = $release->getBodyForMarkdown(); + + $this->assertStringContainsString('[@simonhamp](https://github.com/simonhamp)', $output); + $this->assertStringNotContainsString('[[', $output); + } + + public function test_it_does_not_convert_email_addresses_to_user_links(): void + { + $release = new Release([ + 'body' => 'Questions? Email support@nativephp.com any time.', + ]); + + $this->assertStringContainsString( + 'support@nativephp.com', + $release->getBodyForMarkdown(), + ); + $this->assertStringNotContainsString('[@nativephp]', $release->getBodyForMarkdown()); + } } diff --git a/tests/Feature/Support/GitHubTest.php b/tests/Feature/Support/GitHubTest.php index 05619710..5b9b0f0e 100644 --- a/tests/Feature/Support/GitHubTest.php +++ b/tests/Feature/Support/GitHubTest.php @@ -98,6 +98,22 @@ public function test_releases_after_handles_tag_names_without_a_leading_v(): voi $this->assertEquals('3.2.0', $releases->first()->tag_name); } + public function test_releases_after_caps_below_the_next_major_including_its_prereleases(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/releases*' => Http::response([ + ['id' => 1, 'tag_name' => 'v4.0.0', 'name' => 'v4.0.0', 'body' => '', 'published_at' => '2026-08-01T00:00:00Z'], + ['id' => 2, 'tag_name' => 'v4.0.0-rc.1', 'name' => 'v4.0.0-rc.1', 'body' => '', 'published_at' => '2026-07-01T00:00:00Z'], + ['id' => 3, 'tag_name' => 'v3.2.0', 'name' => 'v3.2.0', 'body' => '', 'published_at' => '2026-05-01T00:00:00Z'], + ['id' => 4, 'tag_name' => 'v3.1.0', 'name' => 'v3.1.0', 'body' => '', 'published_at' => '2026-04-01T00:00:00Z'], + ]), + ]); + + $releases = GitHub::mobileAir()->releasesAfter('3.1.0', before: '4.0.0'); + + $this->assertEquals(['v3.2.0'], $releases->pluck('tag_name')->all()); + } + public function test_releases_from_includes_prereleases_of_the_boundary_version(): void { Http::fake([ diff --git a/tests/Unit/DocsVersionServiceTest.php b/tests/Unit/DocsVersionServiceTest.php index 8bbd279e..669e39c8 100644 --- a/tests/Unit/DocsVersionServiceTest.php +++ b/tests/Unit/DocsVersionServiceTest.php @@ -78,7 +78,7 @@ public function it_points_to_the_latest_version_of_getting_started_introduction_ $expected = route('docs.show', [ 'platform' => 'mobile', - 'version' => '3', + 'version' => config('docs.latest_versions.mobile'), 'page' => 'getting-started/introduction', ]);