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..040502c34 --- /dev/null +++ b/app/Console/Commands/CaptureDocsScreenshots.php @@ -0,0 +1,278 @@ +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; + } + + $cropPercent = $this->resolveCropPercent(); + + if ($cropPercent === null) { + return self::FAILURE; + } + + $cropOffset = $this->resolveCropOffset($cropPercent); + + if ($cropOffset === null) { + return self::FAILURE; + } + + if ($this->option('dry-run')) { + $this->printDryRun($superNativePath, $keys, $platforms, $cropPercent, $cropOffset); + + return self::SUCCESS; + } + + $stagingPath = (string) config('docs.screenshots.staging_path'); + File::ensureDirectoryExists($stagingPath); + + $capturer = new ScreenshotCapturer($superNativePath, (int) config('docs.screenshots.process_timeout')); + $failures = []; + + foreach ($keys as $key) { + foreach ($platforms as $platform) { + if (! $this->captureScreen($capturer, $stagingPath, $key, $platform, $cropPercent, $cropOffset)) { + $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 resolveCropPercent(): ?float + { + $given = (string) $this->option('crop-percent'); + $percent = $given === '' ? (float) config('docs.screenshots.crop_percent') : (float) $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; + } + + return $percent; + } + + private function resolveCropOffset(float $cropPercent): ?float + { + $given = (string) $this->option('crop-offset'); + $offset = $given === '' ? (float) config('docs.screenshots.crop_offset') : (float) $given; + + if ($offset < 0 || $offset + $cropPercent >= self::CROP_PERCENT_CEILING) { + $this->error(sprintf( + '--crop-offset (%s) plus --crop-percent (%s) must be less than %s.', + $given === '' ? $offset : $given, + $cropPercent, + self::CROP_PERCENT_CEILING + )); + + return null; + } + + return $offset; + } + + /** + * @param list $keys + * @param list $platforms + */ + private function printDryRun(string $superNativePath, array $keys, array $platforms, float $cropPercent, float $cropOffset): void + { + $this->info(sprintf('Would use super-native checkout: %s', $superNativePath)); + + foreach ($keys as $key) { + $screen = DocsScreenshotManifest::get($key); + + foreach ($platforms as $platform) { + $crop = $this->option('full') || $screen['crop'] === DocsScreenshotCrop::Full + ? 'full' + : sprintf('%s %d%% (offset %d%%)', $screen['crop']->value, (int) round($cropPercent * 100), (int) round($cropOffset * 100)); + + $this->line(sprintf( + ' %s (%s) — route %s — %s%s', + $key, + $platform->value, + $screen['route'], + $crop, + $screen['requires_drawer_open'] ? ' — needs a manual drawer-open step' : '' + )); + } + } + } + + private function captureScreen( + ScreenshotCapturer $capturer, + string $stagingPath, + string $key, + DocsScreenshotPlatform $platform, + float $cropPercent, + float $cropOffset, + ): bool { + $screen = DocsScreenshotManifest::get($key); + $outputPath = sprintf('%s/%s', $stagingPath, $screen[$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, + cropOffset: $cropOffset, + isInteractive: $this->input->isInteractive(), + confirmDrawerOpen: fn (string $message) => $this->ask($message), + ); + + if ($failure !== null) { + $this->error($failure); + + return false; + } + + $this->info($crop === DocsScreenshotCrop::Full + ? sprintf('Captured %s (full, uncropped).', $outputPath) + : sprintf('Captured %s (cropped to %s).', $outputPath, $crop->value)); + + return true; + } + + /** + * @param list $keys + * @param list $platforms + */ + private function publish(string $stagingPath, array $keys, array $platforms): bool + { + $publishPath = (string) config('docs.screenshots.publish_path'); + + $filenames = []; + + foreach ($keys as $key) { + foreach ($platforms as $platform) { + $filenames[] = DocsScreenshotManifest::get($key)[$platform->value]; + } + } + + $failures = (new ScreenshotPublisher)->publish($stagingPath, $publishPath, $filenames); + + 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/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 @@ + + */ + 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/Services/DocsScreenshots/ScreenshotCapturer.php b/app/Services/DocsScreenshots/ScreenshotCapturer.php new file mode 100644 index 000000000..488d187c6 --- /dev/null +++ b/app/Services/DocsScreenshots/ScreenshotCapturer.php @@ -0,0 +1,123 @@ +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) * self::MICROSECONDS_PER_MILLISECOND); + + $screenshotCommand = $this->buildArgv([ + 'php', 'artisan', 'native:screenshot', $platform->value, $udid, + '--output='.$outputPath, + $crop === DocsScreenshotCrop::Full ? '' : '--crop='.$crop->value, + $crop === DocsScreenshotCrop::Full ? '' : '--crop-percent='.$cropPercent, + $crop === DocsScreenshotCrop::Full ? '' : '--crop-offset='.$cropOffset, + ]); + + 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 new file mode 100644 index 000000000..0ad0397ae --- /dev/null +++ b/app/Support/DocsScreenshotManifest.php @@ -0,0 +1,218 @@ + + */ + 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, + '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, + ], + 'bottom-sheet' => [ + 'route' => '/explore/sheets', + 'ios' => 'edge-bottom-sheet-ios.png', + 'android' => 'edge-bottom-sheet-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'modal' => [ + 'route' => '/explore/sheets', + 'ios' => 'edge-modal-ios.png', + 'android' => 'edge-modal-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'menus' => [ + 'route' => '/explore/menus', + 'ios' => 'edge-menus-ios.png', + 'android' => 'edge-menus-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'list' => [ + 'route' => '/mail-demo', + 'ios' => 'edge-list-ios.png', + 'android' => 'edge-list-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'refreshable' => [ + 'route' => '/refreshable-demo', + 'ios' => 'edge-refreshable-ios.png', + 'android' => 'edge-refreshable-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'virtual-list' => [ + 'route' => '/explore/icons', + 'ios' => 'edge-virtual-list-ios.png', + 'android' => 'edge-virtual-list-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'slider' => [ + 'route' => '/explore/forms', + 'ios' => 'edge-slider-ios.png', + 'android' => 'edge-slider-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'toggle' => [ + 'route' => '/explore/forms', + 'ios' => 'edge-toggle-ios.png', + 'android' => 'edge-toggle-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'checkbox' => [ + 'route' => '/explore/forms', + 'ios' => 'edge-checkbox-ios.png', + 'android' => 'edge-checkbox-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'select' => [ + 'route' => '/explore/forms', + 'ios' => 'edge-select-ios.png', + 'android' => 'edge-select-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'radio-group' => [ + 'route' => '/explore/forms', + 'ios' => 'edge-radio-group-ios.png', + 'android' => 'edge-radio-group-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'web-view' => [ + 'route' => '/webview-demo', + 'ios' => 'edge-web-view-ios.png', + 'android' => 'edge-web-view-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'gesture-area' => [ + 'route' => '/animate', + 'ios' => 'edge-gesture-area-ios.png', + 'android' => 'edge-gesture-area-android.png', + 'requires_drawer_open' => true, + 'crop' => DocsScreenshotCrop::Full, + ], + 'tab-row' => [ + 'route' => '/twitter/profile/0', + 'ios' => 'edge-tab-row-ios.png', + 'android' => 'edge-tab-row-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + 'text' => [ + 'route' => '/explore/typography', + 'ios' => 'edge-text-ios.png', + 'android' => 'edge-text-android.png', + 'requires_drawer_open' => false, + 'crop' => DocsScreenshotCrop::Full, + ], + ]; + + /** + * @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..ae6fd0b80 100644 --- a/config/docs.php +++ b/config/docs.php @@ -63,6 +63,45 @@ ], ], + /* + |-------------------------------------------------------------------------- + | 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. + | + | 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. + | + | crop_offset is how much of the image height to skip from the crop edge + | before measuring crop_percent, so the OS status bar (top crop) or OS + | nav bar/home indicator (bottom crop) is excluded from the captured + | strip. Like crop_percent, it's an approximation tuned against real + | device captures — review a staged screenshot before publishing. + | + */ + + 'screenshots' => [ + 'staging_path' => storage_path('docs-screenshots'), + 'publish_path' => public_path('img/docs'), + 'process_timeout' => 300, + 'crop_percent' => 0.15, + 'crop_offset' => 0.05, + ], + /* |-------------------------------------------------------------------------- | Jump diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 000000000..745301e3e --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,21 @@ +# Lightweight local MySQL for developers who aren't running Herd's own +# database service — matches .env.example's default DB_* values. +# Usage: docker compose -f docker-compose.local.yml up -d +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: "nativephp.com" + ports: + - "3306:3306" + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + mysql-data: diff --git a/public/img/docs/edge-bottom-nav-android.png b/public/img/docs/edge-bottom-nav-android.png index a0bf2273f..b7367cdf9 100644 Binary files a/public/img/docs/edge-bottom-nav-android.png and b/public/img/docs/edge-bottom-nav-android.png differ diff --git a/public/img/docs/edge-bottom-nav-ios.png b/public/img/docs/edge-bottom-nav-ios.png index 9a3784cc0..2076c5385 100644 Binary files a/public/img/docs/edge-bottom-nav-ios.png and b/public/img/docs/edge-bottom-nav-ios.png differ diff --git a/public/img/docs/edge-bottom-sheet-android.png b/public/img/docs/edge-bottom-sheet-android.png new file mode 100644 index 000000000..f0a228daa Binary files /dev/null and b/public/img/docs/edge-bottom-sheet-android.png differ diff --git a/public/img/docs/edge-bottom-sheet-ios.png b/public/img/docs/edge-bottom-sheet-ios.png new file mode 100644 index 000000000..a9c3c5f4e Binary files /dev/null and b/public/img/docs/edge-bottom-sheet-ios.png differ diff --git a/public/img/docs/edge-checkbox-android.png b/public/img/docs/edge-checkbox-android.png new file mode 100644 index 000000000..137551fc0 Binary files /dev/null and b/public/img/docs/edge-checkbox-android.png differ diff --git a/public/img/docs/edge-checkbox-ios.png b/public/img/docs/edge-checkbox-ios.png new file mode 100644 index 000000000..c953f361b Binary files /dev/null and b/public/img/docs/edge-checkbox-ios.png differ diff --git a/public/img/docs/edge-gesture-area-android.png b/public/img/docs/edge-gesture-area-android.png new file mode 100644 index 000000000..25bcf4a92 Binary files /dev/null and b/public/img/docs/edge-gesture-area-android.png differ diff --git a/public/img/docs/edge-gesture-area-ios.png b/public/img/docs/edge-gesture-area-ios.png new file mode 100644 index 000000000..31f2c5462 Binary files /dev/null and b/public/img/docs/edge-gesture-area-ios.png differ diff --git a/public/img/docs/edge-list-android.png b/public/img/docs/edge-list-android.png new file mode 100644 index 000000000..b093db89e Binary files /dev/null and b/public/img/docs/edge-list-android.png differ diff --git a/public/img/docs/edge-list-ios.png b/public/img/docs/edge-list-ios.png new file mode 100644 index 000000000..c5ab92beb Binary files /dev/null and b/public/img/docs/edge-list-ios.png differ diff --git a/public/img/docs/edge-menus-android.png b/public/img/docs/edge-menus-android.png new file mode 100644 index 000000000..be6afe046 Binary files /dev/null and b/public/img/docs/edge-menus-android.png differ diff --git a/public/img/docs/edge-menus-ios.png b/public/img/docs/edge-menus-ios.png new file mode 100644 index 000000000..76697b87b Binary files /dev/null and b/public/img/docs/edge-menus-ios.png differ diff --git a/public/img/docs/edge-modal-android.png b/public/img/docs/edge-modal-android.png new file mode 100644 index 000000000..0ebc53e0c Binary files /dev/null and b/public/img/docs/edge-modal-android.png differ diff --git a/public/img/docs/edge-modal-ios.png b/public/img/docs/edge-modal-ios.png new file mode 100644 index 000000000..3d06a48eb Binary files /dev/null and b/public/img/docs/edge-modal-ios.png differ diff --git a/public/img/docs/edge-radio-group-android.png b/public/img/docs/edge-radio-group-android.png new file mode 100644 index 000000000..137551fc0 Binary files /dev/null and b/public/img/docs/edge-radio-group-android.png differ diff --git a/public/img/docs/edge-radio-group-ios.png b/public/img/docs/edge-radio-group-ios.png new file mode 100644 index 000000000..00ac307f0 Binary files /dev/null and b/public/img/docs/edge-radio-group-ios.png differ diff --git a/public/img/docs/edge-refreshable-android.png b/public/img/docs/edge-refreshable-android.png new file mode 100644 index 000000000..da70eb985 Binary files /dev/null and b/public/img/docs/edge-refreshable-android.png differ diff --git a/public/img/docs/edge-refreshable-ios.png b/public/img/docs/edge-refreshable-ios.png new file mode 100644 index 000000000..901af46c2 Binary files /dev/null and b/public/img/docs/edge-refreshable-ios.png differ diff --git a/public/img/docs/edge-select-android.png b/public/img/docs/edge-select-android.png new file mode 100644 index 000000000..137551fc0 Binary files /dev/null and b/public/img/docs/edge-select-android.png differ diff --git a/public/img/docs/edge-select-ios.png b/public/img/docs/edge-select-ios.png new file mode 100644 index 000000000..e7605daba Binary files /dev/null and b/public/img/docs/edge-select-ios.png differ diff --git a/public/img/docs/edge-side-nav-android.png b/public/img/docs/edge-side-nav-android.png index 46f0fb7b6..efa863ce5 100644 Binary files a/public/img/docs/edge-side-nav-android.png and b/public/img/docs/edge-side-nav-android.png differ diff --git a/public/img/docs/edge-side-nav-ios.png b/public/img/docs/edge-side-nav-ios.png index 01abcd893..f3a6d2873 100644 Binary files a/public/img/docs/edge-side-nav-ios.png and b/public/img/docs/edge-side-nav-ios.png differ diff --git a/public/img/docs/edge-slider-android.png b/public/img/docs/edge-slider-android.png new file mode 100644 index 000000000..f5b6290c8 Binary files /dev/null and b/public/img/docs/edge-slider-android.png differ diff --git a/public/img/docs/edge-slider-ios.png b/public/img/docs/edge-slider-ios.png new file mode 100644 index 000000000..3a3caf84c Binary files /dev/null and b/public/img/docs/edge-slider-ios.png differ diff --git a/public/img/docs/edge-tab-row-android.png b/public/img/docs/edge-tab-row-android.png new file mode 100644 index 000000000..a07cf8991 Binary files /dev/null and b/public/img/docs/edge-tab-row-android.png differ diff --git a/public/img/docs/edge-tab-row-ios.png b/public/img/docs/edge-tab-row-ios.png new file mode 100644 index 000000000..ef37ad4c3 Binary files /dev/null and b/public/img/docs/edge-tab-row-ios.png differ diff --git a/public/img/docs/edge-text-android.png b/public/img/docs/edge-text-android.png new file mode 100644 index 000000000..f30753486 Binary files /dev/null and b/public/img/docs/edge-text-android.png differ diff --git a/public/img/docs/edge-text-ios.png b/public/img/docs/edge-text-ios.png new file mode 100644 index 000000000..b8bbc36af Binary files /dev/null and b/public/img/docs/edge-text-ios.png differ diff --git a/public/img/docs/edge-toggle-android.png b/public/img/docs/edge-toggle-android.png new file mode 100644 index 000000000..f5b6290c8 Binary files /dev/null and b/public/img/docs/edge-toggle-android.png differ diff --git a/public/img/docs/edge-toggle-ios.png b/public/img/docs/edge-toggle-ios.png new file mode 100644 index 000000000..3a3caf84c Binary files /dev/null and b/public/img/docs/edge-toggle-ios.png differ diff --git a/public/img/docs/edge-top-bar-android.png b/public/img/docs/edge-top-bar-android.png index f4b2ce56f..ee24cf151 100644 Binary files a/public/img/docs/edge-top-bar-android.png and b/public/img/docs/edge-top-bar-android.png differ diff --git a/public/img/docs/edge-top-bar-ios.png b/public/img/docs/edge-top-bar-ios.png index 1af165665..b99ef39bd 100644 Binary files a/public/img/docs/edge-top-bar-ios.png and b/public/img/docs/edge-top-bar-ios.png differ diff --git a/public/img/docs/edge-virtual-list-android.png b/public/img/docs/edge-virtual-list-android.png new file mode 100644 index 000000000..b85eade17 Binary files /dev/null and b/public/img/docs/edge-virtual-list-android.png differ diff --git a/public/img/docs/edge-virtual-list-ios.png b/public/img/docs/edge-virtual-list-ios.png new file mode 100644 index 000000000..486cb6f9b Binary files /dev/null and b/public/img/docs/edge-virtual-list-ios.png differ diff --git a/public/img/docs/edge-web-view-android.png b/public/img/docs/edge-web-view-android.png new file mode 100644 index 000000000..6142e34df Binary files /dev/null and b/public/img/docs/edge-web-view-android.png differ diff --git a/public/img/docs/edge-web-view-ios.png b/public/img/docs/edge-web-view-ios.png new file mode 100644 index 000000000..c9a6792f7 Binary files /dev/null and b/public/img/docs/edge-web-view-ios.png differ diff --git a/resources/views/components/docs/edge-preview.blade.php b/resources/views/components/docs/edge-preview.blade.php new file mode 100644 index 000000000..4476f0d73 --- /dev/null +++ b/resources/views/components/docs/edge-preview.blade.php @@ -0,0 +1,107 @@ +@props([ + 'ios' => null, + 'android' => null, + 'source' => null, + 'alt' => 'Screenshot', + 'edge' => null, + 'sidebarWidthIos' => null, + 'sidebarWidthAndroid' => null, +]) + +@php + $default = $ios ? 'ios' : 'android'; + $showTop = in_array($edge, ['top', 'both'], true); + $showBottom = in_array($edge, ['bottom', 'both'], true); + $storageKey = 'nativephp-docs-edge-preview-collapsed:'.($ios ?? $android ?? $alt); + [$iosRadius, $androidRadius] = match (true) { + $showTop && ! $showBottom => ['rounded-t-[2.25rem]', 'rounded-t-[1.75rem]'], + $showBottom && ! $showTop => ['rounded-b-[2.25rem]', 'rounded-b-[1.75rem]'], + default => ['rounded-[2.25rem]', 'rounded-[1.75rem]'], + }; + + // When a screenshot shows an open drawer/sheet that only covers part of + // the width, the real device's dimming scrim sits over everything below + // it — including the status bar and home-indicator zones. Splitting the + // fake chrome bars' background to match keeps that scrim visually + // continuous through the chrome instead of stopping dead at its edge. + $scrim = '#8c8d8f'; + $splitBackground = fn (?int $width, string $base): ?string => $width !== null + ? "background: linear-gradient(to right, {$base} 0%, {$base} {$width}%, {$scrim} {$width}%, {$scrim} 100%);" + : null; + $iosTopStyle = $splitBackground($sidebarWidthIos, '#ffffff'); + $iosBottomStyle = $splitBackground($sidebarWidthIos, '#f9fafb'); + $androidStyle = $splitBackground($sidebarWidthAndroid, '#f8fafc'); +@endphp + +
+
+ @if ($ios && $android) +
+ + +
+ @else + {{ $ios ? 'iOS' : 'Android' }} + @endif +
+ @if ($source) + + + Source + + @endif + +
+
+
+
+ @if ($ios) +
+ @if ($showTop) +
+ + +
+ + + +
+
+ @endif + {{ $alt }} (iOS) + @if ($showBottom) +
+ +
+ @endif +
+ @endif + @if ($android) +
+ @if ($showTop) +
+ + +
+ + + +
+
+ @endif + {{ $alt }} (Android) + @if ($showBottom) +
+ + + +
+ @endif +
+ @endif +
+
+
diff --git a/resources/views/docs/mobile/4/edge-components/bottom-nav.md b/resources/views/docs/mobile/4/edge-components/bottom-nav.md index cee4c63ee..bc6456b7e 100644 --- a/resources/views/docs/mobile/4/edge-components/bottom-nav.md +++ b/resources/views/docs/mobile/4/edge-components/bottom-nav.md @@ -5,13 +5,13 @@ order: 120 ## Overview -
- -![](/img/docs/edge-bottom-nav-ios.png) - -![](/img/docs/edge-bottom-nav-android.png) - -
+ A bottom navigation bar with up to 5 items — your app's primary navigation. Placing `` at the root of a screen's Blade **hoists it onto the real native chrome root** (a `TabView` on iOS, a `NavigationBar` in a diff --git a/resources/views/docs/mobile/4/edge-components/bottom-sheet.md b/resources/views/docs/mobile/4/edge-components/bottom-sheet.md index 4bbdd035f..342a35b7d 100644 --- a/resources/views/docs/mobile/4/edge-components/bottom-sheet.md +++ b/resources/views/docs/mobile/4/edge-components/bottom-sheet.md @@ -5,6 +5,14 @@ order: 130 ## Overview + + A modal panel that slides up from the bottom of the screen. Use it for contextual actions, forms, and detail views that overlay the main content. Renders as SwiftUI's `.sheet` with `presentationDetents` on iOS and a Material3 `ModalBottomSheet` on Android. diff --git a/resources/views/docs/mobile/4/edge-components/checkbox.md b/resources/views/docs/mobile/4/edge-components/checkbox.md index e1254dd2d..23cd070c4 100644 --- a/resources/views/docs/mobile/4/edge-components/checkbox.md +++ b/resources/views/docs/mobile/4/edge-components/checkbox.md @@ -5,6 +5,14 @@ order: 190 ## Overview + + A binary tick/untick control with an optional inline label. On iOS, renders as a tappable SF Symbol pair (`checkmark.square.fill` / `square`) — SwiftUI has no native checkbox primitive. On Android, renders as a Material3 `Checkbox`. 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).