From 3073fa7868920ac5b060a07358b5405641bd2c82 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Thu, 10 Sep 2026 22:12:00 +0200 Subject: [PATCH 1/9] feat(docs): add docs:capture-screenshots to regenerate mobile Edge Component screenshots Adds an Artisan command that drives a local super-native checkout's native:run/native:screenshot commands per screen and platform, stages results under storage/docs-screenshots, and (with --publish) copies them into public/img/docs. The 9 captured screens live in App\Support\DocsScreenshotManifest, one entry per route in super-native's Edge Component showcase. Guards against the ways this can go wrong when run unattended: an ambiguous device without --udid can make the underlying native:run prompt interactively with no attached terminal, so that timeout is now caught instead of crashing the command; a screen whose drawer needs a manual open is skipped with a clear message rather than silently publishing a closed-drawer screenshot when run non-interactively; and --publish checks each source file exists and each copy actually succeeds before reporting success. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + .../Commands/CaptureDocsScreenshots.php | 242 ++++++++++++++++ app/Enums/DocsScreenshotPlatform.php | 27 ++ app/Support/DocsScreenshotManifest.php | 103 +++++++ config/docs.php | 24 ++ .../CaptureDocsScreenshotsCommandTest.php | 269 ++++++++++++++++++ 6 files changed, 666 insertions(+) create mode 100644 app/Console/Commands/CaptureDocsScreenshots.php create mode 100644 app/Enums/DocsScreenshotPlatform.php create mode 100644 app/Support/DocsScreenshotManifest.php create mode 100644 tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php diff --git a/.gitignore b/.gitignore index 9713846c4..e6c33865c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ auth.json # storage /storage/*.key +/storage/docs-screenshots # vendor /vendor diff --git a/app/Console/Commands/CaptureDocsScreenshots.php b/app/Console/Commands/CaptureDocsScreenshots.php new file mode 100644 index 000000000..fda180dd0 --- /dev/null +++ b/app/Console/Commands/CaptureDocsScreenshots.php @@ -0,0 +1,242 @@ +resolveSuperNativePath(); + + if ($superNativePath === null) { + return self::FAILURE; + } + + $platforms = DocsScreenshotPlatform::fromOption((string) $this->option('platform')); + + if ($platforms === []) { + $this->error(sprintf("Invalid --platform '%s'. Use 'ios', 'android', or 'both'.", $this->option('platform'))); + + return self::FAILURE; + } + + $keys = $this->resolveScreenKeys(); + + if ($keys === null) { + return self::FAILURE; + } + + $stagingPath = (string) config('docs.screenshots.staging_path'); + File::ensureDirectoryExists($stagingPath); + + $timeout = (int) config('docs.screenshots.process_timeout'); + $failures = []; + + foreach ($keys as $key) { + foreach ($platforms as $platform) { + if (! $this->captureScreen($superNativePath, $stagingPath, $key, $platform, $timeout)) { + $failures[] = sprintf('%s (%s)', $key, $platform->value); + } + } + } + + if ($failures !== []) { + $this->error(sprintf('Failed to capture: %s', implode(', ', $failures))); + + return self::FAILURE; + } + + $this->info(sprintf('Captured %d screen(s) into %s', count($keys), $stagingPath)); + + if ($this->option('publish') && ! $this->publish($stagingPath, $keys, $platforms)) { + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function resolveSuperNativePath(): ?string + { + $path = rtrim((string) $this->option('super-native-path'), '/'); + + if ($path === '') { + $this->error('--super-native-path is required — pass the local checkout of NativePHP/super-native, e.g. a sibling directory of this repo.'); + + return null; + } + + if (! is_dir($path) || ! is_file($path.'/artisan') || ! is_file($path.'/routes/mobile.php')) { + $this->error(sprintf("'%s' doesn't look like a super-native checkout (missing artisan or routes/mobile.php).", $path)); + + return null; + } + + return $path; + } + + /** + * @return list|null + */ + private function resolveScreenKeys(): ?array + { + $only = (string) $this->option('only'); + + if ($only === '') { + return DocsScreenshotManifest::keys(); + } + + $keys = array_filter(array_map('trim', explode(',', $only))); + $unknown = array_filter($keys, fn (string $key): bool => ! DocsScreenshotManifest::has($key)); + + if ($unknown !== []) { + $this->error(sprintf('Unknown screen key(s): %s', implode(', ', $unknown))); + + return null; + } + + return array_values($keys); + } + + private function captureScreen(string $superNativePath, string $stagingPath, string $key, DocsScreenshotPlatform $platform, int $timeout): bool + { + $screen = DocsScreenshotManifest::get($key); + $udid = (string) $this->option('udid'); + + $runCommand = $this->buildArgv([ + 'php', 'artisan', 'native:run', $platform->value, $udid, + '--build=debug', + '--start-url='.$screen['route'], + '--no-tty', + ]); + + $this->info(sprintf('Launching %s on %s...', $key, $platform->value)); + + if (! $this->runProcess($superNativePath, $runCommand, $timeout)) { + $this->error(sprintf( + 'native:run failed for %s (%s). If it has more than one device to pick from, pass --udid explicitly.', + $key, + $platform->value + )); + + return false; + } + + if ($screen['requires_drawer_open']) { + if (! $this->input->isInteractive()) { + $this->error(sprintf( + 'Skipping %s (%s): opening its drawer needs a manual step, so it can only be captured interactively.', + $key, + $platform->value + )); + + return false; + } + + $this->ask(sprintf( + 'Manually open the side drawer for "%s" in the %s simulator/emulator now, then press Enter to continue', + $key, + $platform->value + )); + } + + usleep(max(0, (int) $this->option('settle-ms')) * 1000); + + $outputPath = sprintf('%s/%s', $stagingPath, $screen[$platform->value]); + + $screenshotCommand = $this->buildArgv([ + 'php', 'artisan', 'native:screenshot', $platform->value, $udid, + '--output='.$outputPath, + ]); + + if (! $this->runProcess($superNativePath, $screenshotCommand, $timeout)) { + $this->error(sprintf('native:screenshot failed for %s (%s).', $key, $platform->value)); + + return false; + } + + $this->info(sprintf('Captured %s.', $outputPath)); + + return true; + } + + /** + * Runs an artisan command in the super-native checkout. `native:run` + * prompts interactively when `--udid` is ambiguous and no real terminal + * is attached to this subprocess, which can time out rather than fail + * fast — caught here and reported as an ordinary failure. + * + * @param list $command + */ + private function runProcess(string $superNativePath, array $command, int $timeout): bool + { + try { + return Process::path($superNativePath)->timeout($timeout)->run($command)->successful(); + } catch (ProcessTimedOutException) { + return false; + } + } + + /** + * Drop empty arguments (an omitted `--udid`) so the target command sees + * a clean argv instead of a blank positional argument. + * + * @param list $argv + * @return list + */ + private function buildArgv(array $argv): array + { + return array_values(array_filter($argv, fn (string $argument): bool => $argument !== '')); + } + + /** + * @param list $keys + * @param list $platforms + */ + private function publish(string $stagingPath, array $keys, array $platforms): bool + { + $publishPath = (string) config('docs.screenshots.publish_path'); + File::ensureDirectoryExists($publishPath); + + $failures = []; + + foreach ($keys as $key) { + foreach ($platforms as $platform) { + $filename = DocsScreenshotManifest::get($key)[$platform->value]; + $source = sprintf('%s/%s', $stagingPath, $filename); + + if (! File::exists($source) || ! File::copy($source, sprintf('%s/%s', $publishPath, $filename))) { + $failures[] = $filename; + } + } + } + + if ($failures !== []) { + $this->error(sprintf('Failed to publish: %s', implode(', ', $failures))); + + return false; + } + + $this->info(sprintf('Published %s.', $publishPath)); + + return true; + } +} diff --git a/app/Enums/DocsScreenshotPlatform.php b/app/Enums/DocsScreenshotPlatform.php new file mode 100644 index 000000000..d2e438d03 --- /dev/null +++ b/app/Enums/DocsScreenshotPlatform.php @@ -0,0 +1,27 @@ + + */ + public static function fromOption(string $value): array + { + return match ($value) { + 'ios' => [self::Ios], + 'android' => [self::Android], + 'both' => [self::Ios, self::Android], + default => [], + }; + } +} diff --git a/app/Support/DocsScreenshotManifest.php b/app/Support/DocsScreenshotManifest.php new file mode 100644 index 000000000..26ff42e7e --- /dev/null +++ b/app/Support/DocsScreenshotManifest.php @@ -0,0 +1,103 @@ + + */ + private const SCREENS = [ + 'top-bar' => [ + 'route' => '/edge-components/top-bar', + 'ios' => 'edge-top-bar-ios.png', + 'android' => 'edge-top-bar-android.png', + 'requires_drawer_open' => false, + ], + 'top-bar-large-title' => [ + 'route' => '/edge-components/top-bar-large-title', + 'ios' => 'edge-top-bar-large-title-ios.png', + 'android' => 'edge-top-bar-large-title-android.png', + 'requires_drawer_open' => false, + ], + 'top-bar-search' => [ + 'route' => '/edge-components/top-bar-search', + 'ios' => 'edge-top-bar-search-ios.png', + 'android' => 'edge-top-bar-search-android.png', + 'requires_drawer_open' => false, + ], + 'top-bar-destructive-action' => [ + 'route' => '/edge-components/top-bar-destructive-action', + 'ios' => 'edge-top-bar-destructive-action-ios.png', + 'android' => 'edge-top-bar-destructive-action-android.png', + 'requires_drawer_open' => false, + ], + 'top-bar-logo' => [ + 'route' => '/edge-components/top-bar-logo', + 'ios' => 'edge-top-bar-logo-ios.png', + 'android' => 'edge-top-bar-logo-android.png', + 'requires_drawer_open' => false, + ], + 'bottom-nav' => [ + 'route' => '/edge-components/bottom-nav', + 'ios' => 'edge-bottom-nav-ios.png', + 'android' => 'edge-bottom-nav-android.png', + 'requires_drawer_open' => false, + ], + 'bottom-nav-search-item' => [ + 'route' => '/edge-components/bottom-nav-search-item', + 'ios' => 'edge-bottom-nav-search-item-ios.png', + 'android' => 'edge-bottom-nav-search-item-android.png', + 'requires_drawer_open' => false, + ], + 'side-nav' => [ + 'route' => '/edge-components/side-nav', + 'ios' => 'edge-side-nav-ios.png', + 'android' => 'edge-side-nav-android.png', + 'requires_drawer_open' => true, + ], + 'side-nav-header-image' => [ + 'route' => '/edge-components/side-nav-header-image', + 'ios' => 'edge-side-nav-header-image-ios.png', + 'android' => 'edge-side-nav-header-image-android.png', + 'requires_drawer_open' => true, + ], + ]; + + /** + * @return list + */ + public static function keys(): array + { + return array_keys(self::SCREENS); + } + + public static function has(string $key): bool + { + return array_key_exists($key, self::SCREENS); + } + + /** + * @return Screen + */ + public static function get(string $key): array + { + if (! self::has($key)) { + throw new InvalidArgumentException("Unknown docs screenshot screen: {$key}"); + } + + return self::SCREENS[$key]; + } +} diff --git a/config/docs.php b/config/docs.php index 92c545a15..5fae871c6 100644 --- a/config/docs.php +++ b/config/docs.php @@ -63,6 +63,30 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Screenshot Capture + |-------------------------------------------------------------------------- + | + | Where `docs:capture-screenshots` writes captured screenshots. Every run + | writes to staging_path; `--publish` additionally copies them into + | publish_path, overwriting the tracked images the docs pages reference. + | + | staging_path deliberately sits outside storage/app (the `local` + | filesystem disk's root) — these are scratch files from a local dev + | tool, not app-managed disk content. + | + | process_timeout bounds each `native:run` / `native:screenshot` call + | (seconds) — a real simulator/emulator boot and build can take a while. + | + */ + + 'screenshots' => [ + 'staging_path' => storage_path('docs-screenshots'), + 'publish_path' => public_path('img/docs'), + 'process_timeout' => 300, + ], + /* |-------------------------------------------------------------------------- | Jump diff --git a/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php b/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php new file mode 100644 index 000000000..3e56cc35a --- /dev/null +++ b/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php @@ -0,0 +1,269 @@ +superNativePath = sys_get_temp_dir().'/super-native-fake-'.uniqid(); + File::ensureDirectoryExists($this->superNativePath.'/routes'); + File::put($this->superNativePath.'/artisan', ''); + File::put($this->superNativePath.'/routes/mobile.php', ''); + + $this->stagingPath = sys_get_temp_dir().'/docs-screenshots-staging-'.uniqid(); + $this->publishPath = sys_get_temp_dir().'/docs-screenshots-publish-'.uniqid(); + + config([ + 'docs.screenshots.staging_path' => $this->stagingPath, + 'docs.screenshots.publish_path' => $this->publishPath, + ]); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->superNativePath); + File::deleteDirectory($this->stagingPath); + File::deleteDirectory($this->publishPath); + + parent::tearDown(); + } + + #[Test] + public function it_fails_without_a_super_native_path_before_running_any_process(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots')->assertFailed(); + + Process::assertNothingRan(); + } + + #[Test] + public function it_fails_when_the_path_does_not_look_like_a_super_native_checkout(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => sys_get_temp_dir(), + ])->assertFailed(); + + Process::assertNothingRan(); + } + + #[Test] + public function it_fails_for_an_invalid_platform(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'blackberry', + ])->assertFailed(); + + Process::assertNothingRan(); + } + + #[Test] + public function it_fails_for_an_unknown_only_key(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--only' => 'not-a-real-screen', + ])->assertFailed(); + + Process::assertNothingRan(); + } + + #[Test] + public function the_manifest_has_both_filenames_for_every_screen_with_no_collisions(): void + { + $seen = []; + + foreach (DocsScreenshotManifest::keys() as $key) { + $screen = DocsScreenshotManifest::get($key); + + $this->assertNotSame('', $screen['ios']); + $this->assertNotSame('', $screen['android']); + + foreach ([$screen['ios'], $screen['android']] as $filename) { + $this->assertArrayNotHasKey($filename, $seen, "Filename [{$filename}] is used by more than one screen."); + $seen[$filename] = $key; + } + } + } + + #[Test] + public function it_captures_a_single_screen_to_staging_without_publishing(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + ])->assertSuccessful(); + + $outputPath = $this->stagingPath.'/edge-top-bar-ios.png'; + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:run', 'ios', '--build=debug', '--start-url=/edge-components/top-bar', '--no-tty', + ]); + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:screenshot', 'ios', '--output='.$outputPath, + ]); + + $this->assertFileDoesNotExist($this->publishPath.'/edge-top-bar-ios.png'); + } + + #[Test] + public function it_passes_the_udid_through_to_both_commands(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'android', + '--only' => 'bottom-nav', + '--udid' => 'emulator-5554', + '--settle-ms' => 0, + ])->assertSuccessful(); + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:run', 'android', 'emulator-5554', '--build=debug', '--start-url=/edge-components/bottom-nav', '--no-tty', + ]); + } + + #[Test] + public function it_fails_the_whole_run_when_one_capture_fails(): void + { + Process::fake([ + '*native:run*' => Process::result(exitCode: 1), + ]); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + ])->assertFailed(); + } + + #[Test] + public function it_publishes_staged_screenshots_when_requested(): void + { + Process::fake(); + + File::ensureDirectoryExists($this->stagingPath); + File::put($this->stagingPath.'/edge-top-bar-ios.png', 'fake-png-bytes'); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + '--publish' => true, + ])->assertSuccessful(); + + $this->assertFileExists($this->stagingPath.'/edge-top-bar-ios.png'); + $this->assertFileExists($this->publishPath.'/edge-top-bar-ios.png'); + $this->assertSame('fake-png-bytes', File::get($this->publishPath.'/edge-top-bar-ios.png')); + } + + #[Test] + public function it_asks_to_manually_open_the_drawer_for_a_drawer_screen(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'side-nav', + '--settle-ms' => 0, + ]) + ->expectsQuestion( + 'Manually open the side drawer for "side-nav" in the ios simulator/emulator now, then press Enter to continue', + '' + ) + ->assertSuccessful(); + } + + #[Test] + public function it_skips_a_drawer_screen_and_fails_when_run_non_interactively(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'side-nav', + '--settle-ms' => 0, + '--no-interaction' => true, + ])->assertFailed(); + + Process::assertNotRan(fn ($process): bool => ($process->command[2] ?? '') === 'native:screenshot'); + } + + #[Test] + public function it_clamps_a_negative_settle_ms_instead_of_crashing(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => -500, + ])->assertSuccessful(); + } + + #[Test] + public function it_fails_to_publish_a_screen_never_staged(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + '--publish' => true, + ])->assertFailed(); + } + + #[Test] + public function it_fails_cleanly_instead_of_crashing_when_a_capture_step_times_out(): void + { + // No Process::fake() here — this exercises a real subprocess that + // outlives the configured timeout, to prove the timeout is caught + // rather than left to crash the command as an uncaught exception. + File::put($this->superNativePath.'/artisan', " 1]); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + ])->assertFailed(); + } +} From 5ff832c8053850702ad7c4a1fc07cdbff2f76bd2 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Thu, 10 Sep 2026 22:43:45 +0200 Subject: [PATCH 2/9] refactor(docs): split capture command into services, add crop/dry-run options Splits CaptureDocsScreenshots into three focused, independently-testable services (ScreenshotCapturer, ScreenshotPublisher), and the manifest now records each screen's crop direction. Cropping itself is delegated to mobile-air's own native:screenshot --crop/--crop-percent flags rather than duplicated here, so every NativePHP developer gets it, not just this pipeline. --full skips cropping, --crop-percent overrides the configured default, and --dry-run prints the plan without running anything. Co-Authored-By: Claude Sonnet 5 --- .../Commands/CaptureDocsScreenshots.php | 167 +++++++++--------- app/Enums/DocsScreenshotCrop.php | 19 ++ .../DocsScreenshots/ScreenshotCapturer.php | 119 +++++++++++++ .../DocsScreenshots/ScreenshotPublisher.php | 37 ++++ app/Support/DocsScreenshotManifest.php | 12 +- config/docs.php | 8 + .../CaptureDocsScreenshotsCommandTest.php | 129 ++++++++++++++ .../ScreenshotPublisherTest.php | 71 ++++++++ 8 files changed, 478 insertions(+), 84 deletions(-) create mode 100644 app/Enums/DocsScreenshotCrop.php create mode 100644 app/Services/DocsScreenshots/ScreenshotCapturer.php create mode 100644 app/Services/DocsScreenshots/ScreenshotPublisher.php create mode 100644 tests/Unit/Services/DocsScreenshots/ScreenshotPublisherTest.php diff --git a/app/Console/Commands/CaptureDocsScreenshots.php b/app/Console/Commands/CaptureDocsScreenshots.php index fda180dd0..08b9539bd 100644 --- a/app/Console/Commands/CaptureDocsScreenshots.php +++ b/app/Console/Commands/CaptureDocsScreenshots.php @@ -4,12 +4,13 @@ namespace App\Console\Commands; +use App\Enums\DocsScreenshotCrop; use App\Enums\DocsScreenshotPlatform; +use App\Services\DocsScreenshots\ScreenshotCapturer; +use App\Services\DocsScreenshots\ScreenshotPublisher; use App\Support\DocsScreenshotManifest; use Illuminate\Console\Command; -use Illuminate\Process\Exceptions\ProcessTimedOutException; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Process; final class CaptureDocsScreenshots extends Command { @@ -19,6 +20,9 @@ final class CaptureDocsScreenshots extends Command {--udid= : Specific simulator/emulator UDID — required whenever native:run would otherwise have more than one device to pick from} {--only= : Comma-separated screen keys to limit to, e.g. top-bar,bottom-nav} {--settle-ms=2000 : Milliseconds to wait after launch before capturing} + {--full : Keep the full, uncropped screenshot instead of the tight top/bottom crop most screens use} + {--crop-percent= : Override the configured crop_percent for this run (0-1)} + {--dry-run : Print what would be captured without running anything} {--publish : Copy every staged screenshot into public/img/docs}'; protected $description = "Regenerate the mobile docs' Edge Component screenshots from a running super-native checkout"; @@ -45,15 +49,27 @@ public function handle(): int return self::FAILURE; } + $cropPercent = $this->resolveCropPercent(); + + if ($cropPercent === null) { + return self::FAILURE; + } + + if ($this->option('dry-run')) { + $this->printDryRun($superNativePath, $keys, $platforms, $cropPercent); + + return self::SUCCESS; + } + $stagingPath = (string) config('docs.screenshots.staging_path'); File::ensureDirectoryExists($stagingPath); - $timeout = (int) config('docs.screenshots.process_timeout'); + $capturer = new ScreenshotCapturer($superNativePath, (int) config('docs.screenshots.process_timeout')); $failures = []; foreach ($keys as $key) { foreach ($platforms as $platform) { - if (! $this->captureScreen($superNativePath, $stagingPath, $key, $platform, $timeout)) { + if (! $this->captureScreen($capturer, $stagingPath, $key, $platform, $cropPercent)) { $failures[] = sprintf('%s (%s)', $key, $platform->value); } } @@ -116,97 +132,86 @@ private function resolveScreenKeys(): ?array return array_values($keys); } - private function captureScreen(string $superNativePath, string $stagingPath, string $key, DocsScreenshotPlatform $platform, int $timeout): bool + private function resolveCropPercent(): ?float { - $screen = DocsScreenshotManifest::get($key); - $udid = (string) $this->option('udid'); + $given = (string) $this->option('crop-percent'); + $percent = $given === '' ? (float) config('docs.screenshots.crop_percent') : (float) $given; - $runCommand = $this->buildArgv([ - 'php', 'artisan', 'native:run', $platform->value, $udid, - '--build=debug', - '--start-url='.$screen['route'], - '--no-tty', - ]); + if ($percent <= 0 || $percent >= 1) { + $this->error(sprintf('--crop-percent must be between 0 and 1 (exclusive), got %s.', $given)); - $this->info(sprintf('Launching %s on %s...', $key, $platform->value)); + return null; + } - if (! $this->runProcess($superNativePath, $runCommand, $timeout)) { - $this->error(sprintf( - 'native:run failed for %s (%s). If it has more than one device to pick from, pass --udid explicitly.', - $key, - $platform->value - )); + return $percent; + } - return false; - } + /** + * @param list $keys + * @param list $platforms + */ + private function printDryRun(string $superNativePath, array $keys, array $platforms, float $cropPercent): void + { + $this->info(sprintf('Would use super-native checkout: %s', $superNativePath)); - if ($screen['requires_drawer_open']) { - if (! $this->input->isInteractive()) { - $this->error(sprintf( - 'Skipping %s (%s): opening its drawer needs a manual step, so it can only be captured interactively.', + foreach ($keys as $key) { + $screen = DocsScreenshotManifest::get($key); + + foreach ($platforms as $platform) { + $crop = $this->option('full') || $screen['crop'] === DocsScreenshotCrop::Full + ? 'full' + : sprintf('%s %d%%', $screen['crop']->value, (int) round($cropPercent * 100)); + + $this->line(sprintf( + ' %s (%s) — route %s — %s%s', $key, - $platform->value + $platform->value, + $screen['route'], + $crop, + $screen['requires_drawer_open'] ? ' — needs a manual drawer-open step' : '' )); - - return false; } - - $this->ask(sprintf( - 'Manually open the side drawer for "%s" in the %s simulator/emulator now, then press Enter to continue', - $key, - $platform->value - )); } + } - usleep(max(0, (int) $this->option('settle-ms')) * 1000); - + private function captureScreen( + ScreenshotCapturer $capturer, + string $stagingPath, + string $key, + DocsScreenshotPlatform $platform, + float $cropPercent, + ): bool { + $screen = DocsScreenshotManifest::get($key); $outputPath = sprintf('%s/%s', $stagingPath, $screen[$platform->value]); - - $screenshotCommand = $this->buildArgv([ - 'php', 'artisan', 'native:screenshot', $platform->value, $udid, - '--output='.$outputPath, - ]); - - if (! $this->runProcess($superNativePath, $screenshotCommand, $timeout)) { - $this->error(sprintf('native:screenshot failed for %s (%s).', $key, $platform->value)); + $crop = $this->option('full') ? DocsScreenshotCrop::Full : $screen['crop']; + + $failure = $capturer->capture( + key: $key, + platform: $platform, + route: $screen['route'], + requiresDrawerOpen: $screen['requires_drawer_open'], + udid: (string) $this->option('udid'), + settleMs: (int) $this->option('settle-ms'), + outputPath: $outputPath, + crop: $crop, + cropPercent: $cropPercent, + isInteractive: $this->input->isInteractive(), + confirmDrawerOpen: fn (string $message) => $this->ask($message), + ); + + if ($failure !== null) { + $this->error($failure); return false; } - $this->info(sprintf('Captured %s.', $outputPath)); + $this->info($crop === DocsScreenshotCrop::Full + ? sprintf('Captured %s (full, uncropped).', $outputPath) + : sprintf('Captured %s (cropped to %s).', $outputPath, $crop->value)); return true; } - /** - * Runs an artisan command in the super-native checkout. `native:run` - * prompts interactively when `--udid` is ambiguous and no real terminal - * is attached to this subprocess, which can time out rather than fail - * fast — caught here and reported as an ordinary failure. - * - * @param list $command - */ - private function runProcess(string $superNativePath, array $command, int $timeout): bool - { - try { - return Process::path($superNativePath)->timeout($timeout)->run($command)->successful(); - } catch (ProcessTimedOutException) { - return false; - } - } - - /** - * Drop empty arguments (an omitted `--udid`) so the target command sees - * a clean argv instead of a blank positional argument. - * - * @param list $argv - * @return list - */ - private function buildArgv(array $argv): array - { - return array_values(array_filter($argv, fn (string $argument): bool => $argument !== '')); - } - /** * @param list $keys * @param list $platforms @@ -214,21 +219,17 @@ private function buildArgv(array $argv): array private function publish(string $stagingPath, array $keys, array $platforms): bool { $publishPath = (string) config('docs.screenshots.publish_path'); - File::ensureDirectoryExists($publishPath); - $failures = []; + $filenames = []; foreach ($keys as $key) { foreach ($platforms as $platform) { - $filename = DocsScreenshotManifest::get($key)[$platform->value]; - $source = sprintf('%s/%s', $stagingPath, $filename); - - if (! File::exists($source) || ! File::copy($source, sprintf('%s/%s', $publishPath, $filename))) { - $failures[] = $filename; - } + $filenames[] = DocsScreenshotManifest::get($key)[$platform->value]; } } + $failures = (new ScreenshotPublisher)->publish($stagingPath, $publishPath, $filenames); + if ($failures !== []) { $this->error(sprintf('Failed to publish: %s', implode(', ', $failures))); diff --git a/app/Enums/DocsScreenshotCrop.php b/app/Enums/DocsScreenshotCrop.php new file mode 100644 index 000000000..4a910fc5e --- /dev/null +++ b/app/Enums/DocsScreenshotCrop.php @@ -0,0 +1,19 @@ +buildArgv([ + 'php', 'artisan', 'native:run', $platform->value, $udid, + '--build=debug', + '--start-url='.$route, + '--no-tty', + ]); + + if (! $this->runProcess($runCommand)) { + return sprintf( + 'native:run failed for %s (%s). If it has more than one device to pick from, pass --udid explicitly.', + $key, + $platform->value + ); + } + + if ($requiresDrawerOpen) { + if (! $isInteractive) { + return sprintf( + 'Skipping %s (%s): opening its drawer needs a manual step, so it can only be captured interactively.', + $key, + $platform->value + ); + } + + $confirmDrawerOpen(sprintf( + 'Manually open the side drawer for "%s" in the %s simulator/emulator now, then press Enter to continue', + $key, + $platform->value + )); + } + + usleep(max(0, $settleMs) * 1000); + + $screenshotCommand = $this->buildArgv([ + 'php', 'artisan', 'native:screenshot', $platform->value, $udid, + '--output='.$outputPath, + $crop === DocsScreenshotCrop::Full ? '' : '--crop='.$crop->value, + $crop === DocsScreenshotCrop::Full ? '' : '--crop-percent='.$cropPercent, + ]); + + if (! $this->runProcess($screenshotCommand)) { + return sprintf('native:screenshot failed for %s (%s).', $key, $platform->value); + } + + return null; + } + + /** + * `native:run` prompts interactively when `--udid` is ambiguous and no + * real terminal is attached to this subprocess, which can time out + * rather than fail fast — caught here and reported as an ordinary + * failure instead of crashing the caller. + * + * @param list $command + */ + private function runProcess(array $command): bool + { + try { + return Process::path($this->superNativePath)->timeout($this->timeout)->run($command)->successful(); + } catch (ProcessTimedOutException) { + return false; + } + } + + /** + * Drop empty arguments (an omitted `--udid`) so the target command sees + * a clean argv instead of a blank positional argument. + * + * @param list $argv + * @return list + */ + private function buildArgv(array $argv): array + { + return array_values(array_filter($argv, fn (string $argument): bool => $argument !== '')); + } +} diff --git a/app/Services/DocsScreenshots/ScreenshotPublisher.php b/app/Services/DocsScreenshots/ScreenshotPublisher.php new file mode 100644 index 000000000..af1fe4837 --- /dev/null +++ b/app/Services/DocsScreenshots/ScreenshotPublisher.php @@ -0,0 +1,37 @@ + $filenames + * @return list the filenames that failed to publish (empty on full success) + */ + public function publish(string $stagingPath, string $publishPath, array $filenames): array + { + File::ensureDirectoryExists($publishPath); + + $failures = []; + + foreach ($filenames as $filename) { + $source = sprintf('%s/%s', $stagingPath, $filename); + $destination = sprintf('%s/%s', $publishPath, $filename); + + if (! File::exists($source) || ! File::copy($source, $destination)) { + $failures[] = $filename; + } + } + + return $failures; + } +} diff --git a/app/Support/DocsScreenshotManifest.php b/app/Support/DocsScreenshotManifest.php index 26ff42e7e..7e9bf1f33 100644 --- a/app/Support/DocsScreenshotManifest.php +++ b/app/Support/DocsScreenshotManifest.php @@ -4,6 +4,7 @@ namespace App\Support; +use App\Enums\DocsScreenshotCrop; use InvalidArgumentException; /** @@ -12,7 +13,7 @@ * route group. Adding a new prop-variant screen there means adding its * entry here too. * - * @phpstan-type Screen array{route: string, ios: string, android: string, requires_drawer_open: bool} + * @phpstan-type Screen array{route: string, ios: string, android: string, requires_drawer_open: bool, crop: DocsScreenshotCrop} */ final class DocsScreenshotManifest { @@ -25,54 +26,63 @@ final class DocsScreenshotManifest 'ios' => 'edge-top-bar-ios.png', 'android' => 'edge-top-bar-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Top, ], 'top-bar-large-title' => [ 'route' => '/edge-components/top-bar-large-title', 'ios' => 'edge-top-bar-large-title-ios.png', 'android' => 'edge-top-bar-large-title-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Top, ], 'top-bar-search' => [ 'route' => '/edge-components/top-bar-search', 'ios' => 'edge-top-bar-search-ios.png', 'android' => 'edge-top-bar-search-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Top, ], 'top-bar-destructive-action' => [ 'route' => '/edge-components/top-bar-destructive-action', 'ios' => 'edge-top-bar-destructive-action-ios.png', 'android' => 'edge-top-bar-destructive-action-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Top, ], 'top-bar-logo' => [ 'route' => '/edge-components/top-bar-logo', 'ios' => 'edge-top-bar-logo-ios.png', 'android' => 'edge-top-bar-logo-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Top, ], 'bottom-nav' => [ 'route' => '/edge-components/bottom-nav', 'ios' => 'edge-bottom-nav-ios.png', 'android' => 'edge-bottom-nav-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Bottom, ], 'bottom-nav-search-item' => [ 'route' => '/edge-components/bottom-nav-search-item', 'ios' => 'edge-bottom-nav-search-item-ios.png', 'android' => 'edge-bottom-nav-search-item-android.png', 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Bottom, ], 'side-nav' => [ 'route' => '/edge-components/side-nav', 'ios' => 'edge-side-nav-ios.png', 'android' => 'edge-side-nav-android.png', 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, ], 'side-nav-header-image' => [ 'route' => '/edge-components/side-nav-header-image', 'ios' => 'edge-side-nav-header-image-ios.png', 'android' => 'edge-side-nav-header-image-android.png', 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, ], ]; diff --git a/config/docs.php b/config/docs.php index 5fae871c6..e683383be 100644 --- a/config/docs.php +++ b/config/docs.php @@ -79,12 +79,20 @@ | process_timeout bounds each `native:run` / `native:screenshot` call | (seconds) — a real simulator/emulator boot and build can take a while. | + | crop_percent is how much of the image height a top/bottom-cropped + | screenshot keeps (matches every existing top-bar/bottom-nav image in + | public/img/docs, which are cropped tight rather than full-screen). + | It's an approximation, not a per-device measurement — review a staged + | screenshot before publishing and adjust here if a component's bar is + | taller or shorter than this assumes. --full skips cropping entirely. + | */ 'screenshots' => [ 'staging_path' => storage_path('docs-screenshots'), 'publish_path' => public_path('img/docs'), 'process_timeout' => 300, + 'crop_percent' => 0.25, ], /* diff --git a/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php b/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php index 3e56cc35a..e73092e7f 100644 --- a/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php +++ b/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php @@ -119,6 +119,7 @@ public function it_captures_a_single_screen_to_staging_without_publishing(): voi '--platform' => 'ios', '--only' => 'top-bar', '--settle-ms' => 0, + '--full' => true, ])->assertSuccessful(); $outputPath = $this->stagingPath.'/edge-top-bar-ios.png'; @@ -145,6 +146,7 @@ public function it_passes_the_udid_through_to_both_commands(): void '--only' => 'bottom-nav', '--udid' => 'emulator-5554', '--settle-ms' => 0, + '--full' => true, ])->assertSuccessful(); Process::assertRan(fn ($process): bool => $process->command === [ @@ -180,6 +182,7 @@ public function it_publishes_staged_screenshots_when_requested(): void '--platform' => 'ios', '--only' => 'top-bar', '--settle-ms' => 0, + '--full' => true, '--publish' => true, ])->assertSuccessful(); @@ -198,6 +201,7 @@ public function it_asks_to_manually_open_the_drawer_for_a_drawer_screen(): void '--platform' => 'ios', '--only' => 'side-nav', '--settle-ms' => 0, + '--full' => true, ]) ->expectsQuestion( 'Manually open the side drawer for "side-nav" in the ios simulator/emulator now, then press Enter to continue', @@ -232,6 +236,7 @@ public function it_clamps_a_negative_settle_ms_instead_of_crashing(): void '--platform' => 'ios', '--only' => 'top-bar', '--settle-ms' => -500, + '--full' => true, ])->assertSuccessful(); } @@ -245,6 +250,7 @@ public function it_fails_to_publish_a_screen_never_staged(): void '--platform' => 'ios', '--only' => 'top-bar', '--settle-ms' => 0, + '--full' => true, '--publish' => true, ])->assertFailed(); } @@ -266,4 +272,127 @@ public function it_fails_cleanly_instead_of_crashing_when_a_capture_step_times_o '--settle-ms' => 0, ])->assertFailed(); } + + #[Test] + public function it_dry_runs_without_running_any_process(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar,side-nav', + '--dry-run' => true, + ])->assertSuccessful(); + + Process::assertNothingRan(); + } + + #[Test] + public function it_fails_for_an_invalid_crop_percent(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--crop-percent' => '1.5', + ])->assertFailed(); + + Process::assertNothingRan(); + } + + #[Test] + public function it_passes_the_screen_crop_direction_and_percent_to_native_screenshot(): void + { + // Cropping itself is native:screenshot's job (mobile-air) — this + // only verifies the right flags reach it for a top-bar screen. + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + '--crop-percent' => '0.3', + ])->assertSuccessful(); + + $outputPath = $this->stagingPath.'/edge-top-bar-ios.png'; + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:screenshot', 'ios', + '--output='.$outputPath, + '--crop=top', + '--crop-percent=0.3', + ]); + } + + #[Test] + public function it_passes_the_bottom_crop_direction_for_a_bottom_nav_screen(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'android', + '--only' => 'bottom-nav', + '--settle-ms' => 0, + ])->assertSuccessful(); + + $outputPath = $this->stagingPath.'/edge-bottom-nav-android.png'; + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:screenshot', 'android', + '--output='.$outputPath, + '--crop=bottom', + '--crop-percent=0.25', + ]); + } + + #[Test] + public function it_passes_no_crop_flags_for_a_side_nav_screen(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'side-nav', + '--settle-ms' => 0, + ]) + ->expectsQuestion( + 'Manually open the side drawer for "side-nav" in the ios simulator/emulator now, then press Enter to continue', + '' + ) + ->assertSuccessful(); + + $outputPath = $this->stagingPath.'/edge-side-nav-ios.png'; + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:screenshot', 'ios', + '--output='.$outputPath, + ]); + } + + #[Test] + public function it_passes_no_crop_flags_when_full_is_requested(): void + { + Process::fake(); + + $this->artisan('docs:capture-screenshots', [ + '--super-native-path' => $this->superNativePath, + '--platform' => 'ios', + '--only' => 'top-bar', + '--settle-ms' => 0, + '--full' => true, + ])->assertSuccessful(); + + $outputPath = $this->stagingPath.'/edge-top-bar-ios.png'; + + Process::assertRan(fn ($process): bool => $process->command === [ + 'php', 'artisan', 'native:screenshot', 'ios', + '--output='.$outputPath, + ]); + } } diff --git a/tests/Unit/Services/DocsScreenshots/ScreenshotPublisherTest.php b/tests/Unit/Services/DocsScreenshots/ScreenshotPublisherTest.php new file mode 100644 index 000000000..57db4306e --- /dev/null +++ b/tests/Unit/Services/DocsScreenshots/ScreenshotPublisherTest.php @@ -0,0 +1,71 @@ +stagingPath = sys_get_temp_dir().'/screenshot-publisher-staging-'.uniqid(); + $this->publishPath = sys_get_temp_dir().'/screenshot-publisher-publish-'.uniqid(); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->stagingPath); + File::deleteDirectory($this->publishPath); + + parent::tearDown(); + } + + #[Test] + public function it_copies_every_staged_file_to_the_publish_path(): void + { + File::ensureDirectoryExists($this->stagingPath); + File::put($this->stagingPath.'/a.png', 'a-bytes'); + File::put($this->stagingPath.'/b.png', 'b-bytes'); + + $failures = (new ScreenshotPublisher)->publish($this->stagingPath, $this->publishPath, ['a.png', 'b.png']); + + $this->assertSame([], $failures); + $this->assertSame('a-bytes', File::get($this->publishPath.'/a.png')); + $this->assertSame('b-bytes', File::get($this->publishPath.'/b.png')); + } + + #[Test] + public function it_reports_a_filename_that_was_never_staged(): void + { + File::ensureDirectoryExists($this->stagingPath); + File::put($this->stagingPath.'/a.png', 'a-bytes'); + + $failures = (new ScreenshotPublisher)->publish($this->stagingPath, $this->publishPath, ['a.png', 'missing.png']); + + $this->assertSame(['missing.png'], $failures); + $this->assertFileExists($this->publishPath.'/a.png'); + $this->assertFileDoesNotExist($this->publishPath.'/missing.png'); + } + + #[Test] + public function it_creates_the_publish_directory_when_missing(): void + { + File::ensureDirectoryExists($this->stagingPath); + File::put($this->stagingPath.'/a.png', 'a-bytes'); + + $this->assertDirectoryDoesNotExist($this->publishPath); + + (new ScreenshotPublisher)->publish($this->stagingPath, $this->publishPath, ['a.png']); + + $this->assertDirectoryExists($this->publishPath); + } +} From 954f6bea3a38a3e2878ec9e841d0f9102975dd72 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Thu, 10 Sep 2026 23:53:46 +0200 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20extract=20crop-percent=20ceilin?= =?UTF-8?q?g=20and=20ms-to-=C2=B5s=20magic=20numbers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- app/Console/Commands/CaptureDocsScreenshots.php | 11 +++++++++-- app/Services/DocsScreenshots/ScreenshotCapturer.php | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/Console/Commands/CaptureDocsScreenshots.php b/app/Console/Commands/CaptureDocsScreenshots.php index 08b9539bd..a07d9869c 100644 --- a/app/Console/Commands/CaptureDocsScreenshots.php +++ b/app/Console/Commands/CaptureDocsScreenshots.php @@ -14,6 +14,9 @@ final class CaptureDocsScreenshots extends Command { + /** Exclusive ceiling for `--crop-percent` — matches `native:screenshot`'s own single-edge ceiling. */ + private const CROP_PERCENT_CEILING = 1.0; + protected $signature = 'docs:capture-screenshots {--platform=both : ios, android, or both} {--super-native-path= : Local checkout of NativePHP/super-native} @@ -137,8 +140,12 @@ private function resolveCropPercent(): ?float $given = (string) $this->option('crop-percent'); $percent = $given === '' ? (float) config('docs.screenshots.crop_percent') : (float) $given; - if ($percent <= 0 || $percent >= 1) { - $this->error(sprintf('--crop-percent must be between 0 and 1 (exclusive), got %s.', $given)); + if ($percent <= 0 || $percent >= self::CROP_PERCENT_CEILING) { + $this->error(sprintf( + '--crop-percent must be between 0 and %s (exclusive), got %s.', + self::CROP_PERCENT_CEILING, + $given + )); return null; } diff --git a/app/Services/DocsScreenshots/ScreenshotCapturer.php b/app/Services/DocsScreenshots/ScreenshotCapturer.php index 3fdfd623f..02955cf58 100644 --- a/app/Services/DocsScreenshots/ScreenshotCapturer.php +++ b/app/Services/DocsScreenshots/ScreenshotCapturer.php @@ -18,6 +18,8 @@ */ final class ScreenshotCapturer { + private const MICROSECONDS_PER_MILLISECOND = 1_000; + public function __construct( private readonly string $superNativePath, private readonly int $timeout, @@ -72,7 +74,7 @@ public function capture( )); } - usleep(max(0, $settleMs) * 1000); + usleep(max(0, $settleMs) * self::MICROSECONDS_PER_MILLISECOND); $screenshotCommand = $this->buildArgv([ 'php', 'artisan', 'native:screenshot', $platform->value, $udid, From a6d2bd276839100d0ede464e5db256c3ea4eddec Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Thu, 10 Sep 2026 23:53:53 +0200 Subject: [PATCH 4/9] fix: tighten the default screenshot crop so it excludes screen chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.25 pulled in whatever sat just below/above the top/bottom bar on a real device capture (verified against a booted iOS simulator) — 0.15 is the tightest value that still keeps the full bar on both the top-bar and bottom-nav reference screens. Co-Authored-By: Claude Sonnet 5 --- config/docs.php | 2 +- tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/docs.php b/config/docs.php index e683383be..fc326605c 100644 --- a/config/docs.php +++ b/config/docs.php @@ -92,7 +92,7 @@ 'staging_path' => storage_path('docs-screenshots'), 'publish_path' => public_path('img/docs'), 'process_timeout' => 300, - 'crop_percent' => 0.25, + 'crop_percent' => 0.15, ], /* diff --git a/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php b/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php index e73092e7f..67e058bd9 100644 --- a/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php +++ b/tests/Feature/Console/CaptureDocsScreenshotsCommandTest.php @@ -346,7 +346,7 @@ public function it_passes_the_bottom_crop_direction_for_a_bottom_nav_screen(): v 'php', 'artisan', 'native:screenshot', 'android', '--output='.$outputPath, '--crop=bottom', - '--crop-percent=0.25', + '--crop-percent=0.15', ]); } From 9d8371826cc4cd96ac66c604a98436f721058ade Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Thu, 10 Sep 2026 23:53:59 +0200 Subject: [PATCH 5/9] fix(docs): replace the dead side-nav example with the working Drawer approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline element the old example and props table documented renders nothing on either platform (its own admonition said so) — there was no working example to preserve parity with, so this replaces it with the Drawer-builder approach that actually works, matching the reference screen in NativePHP/super-native. Also fixes , a stale tag name with no real element behind it — is the real element. Co-Authored-By: Claude Sonnet 5 --- .../docs/mobile/4/edge-components/divider.md | 3 +- .../docs/mobile/4/edge-components/side-nav.md | 116 +++++++++--------- 2 files changed, 57 insertions(+), 62 deletions(-) diff --git a/resources/views/docs/mobile/4/edge-components/divider.md b/resources/views/docs/mobile/4/edge-components/divider.md index bbe359f4a..9e0f7d0a5 100644 --- a/resources/views/docs/mobile/4/edge-components/divider.md +++ b/resources/views/docs/mobile/4/edge-components/divider.md @@ -14,8 +14,7 @@ the platform separator color (`UIColor.separator` on iOS, Material `outlineVaria ``` @endverbatim -`` is an equivalent divider component exposed for use inside [side navigation](side-nav). It -emits its own `horizontal_divider` element but renders the same visual rule as ``. +`` also works as a separator between items inside [side navigation](side-nav).