From 0a468c96daccaf0f40ebe70cd15427af70f8ad0e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 14:36:21 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(screenshots):=20the=20two=20missing=20?= =?UTF-8?q?marketing=20frames=20=E2=80=94=20the=20library=20shelf=20and=20?= =?UTF-8?q?pads=20that=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store plan's PICK & PLAY and FEEL THE GAME shots had no scene on any platform: the library screen's state comes off the network, and the controllers screens enumerate InputDevices, of which Robolectric has none (the old shot honestly said 'no controller detected' — a palette proof that sells nothing). - Android library: Coverflow goes internal and LibraryScene rebuilds the real shell around it (aurora, header, hint bar) with a mock shelf. Cover art is answered synchronously by coil-test's FakeImageLoaderEngine with generated gradient posters, so the frozen animation clock never races an async load. Shot at phone portrait+landscape and TV geometry. - Android controllers: PadRow renders a PadInfo model instead of a raw InputDevice (padInfoOf maps real devices; both screens take a padsOverride). The scenes inject the two pads the listing names — DualSense (player 1) and Xbox (player 2), real VID:PIDs. - Apple library: ShotLibrary composes the real LibraryCoverflowView with a JSON-decoded mock shelf (GameEntry's memberwise init is internal to PunktfunkKit; Codable is the public construction surface), registered as cross-platform scene 11-library and added to the store set + the tvOS CI scene list. Artless entries settle to their deterministic fallback posters, which is also what keeps the shot offline. Apple controllers stays a follow-up: ControllerTestView binds to live GCController hardware and has no injection surface yet. Verified: all 31 Roborazzi scenes render; the new tv-library, phone-library and controllers shots reviewed by eye. --- .gitea/workflows/apple.yml | 2 +- clients/android/app/build.gradle.kts | 4 + .../io/unom/punktfunk/ControllersScreen.kt | 65 ++++++-- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 4 +- .../punktfunk/screenshots/ScreenshotTest.kt | 15 ++ .../unom/punktfunk/screenshots/ShotScenes.kt | 151 +++++++++++++++++- .../punktfunk/screenshots/TvScreenshotTest.kt | 4 + .../Screenshots/ScreenshotScenes.swift | 36 +++++ clients/apple/tools/screenshots.sh | 2 +- 9 files changed, 262 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 79eefe09..e429689d 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -755,7 +755,7 @@ jobs: bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped" # tvOS shoots only the scenes that exist there — the 06–09 gamepad-console scenes are # compiled out on tvOS (native focus engine), and an unknown name = a normal app launch. - SCENES="01-stream 02-hosts 05-settings 03-pair" \ + SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \ bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped" echo "Produced:"; ls -la screenshots || true diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 171a617d..747d7a14 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -142,6 +142,10 @@ dependencies { // job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). --- testImplementation(composeBom) testImplementation("androidx.compose.ui:ui-test-junit4") + // Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's + // AsyncImage synchronously with generated posters — no network, no async race under the frozen + // animation clock. + testImplementation("io.coil-kt:coil-test:2.7.0") debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host testImplementation("junit:junit:4.13.2") // Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index df960c1c..fff2514e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -69,7 +69,7 @@ import kotlinx.coroutines.delay * to be the same one whichever interface asked. */ @Composable -fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { +internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List? = null) { BackHandler(onBack = onBack) var testing by remember { mutableStateOf(false) } ControllersBody( @@ -77,6 +77,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { scroll = rememberScrollState(), testing = testing, onTestingChange = { testing = it }, + padsOverride = padsOverride, // The touch screen holds the probes for its whole life: events are OBSERVED (not consumed) // while the test is off, which is what keeps the "Last input" line live while browsing. // Nothing else here wants the pad, so there is no one to hand them to. @@ -99,7 +100,12 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { * drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends. */ @Composable -fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) { +internal fun ConsoleControllersScreen( + gamepadSetting: Int, + onBack: () -> Unit, + navActive: Boolean = true, + padsOverride: List? = null, +) { BackHandler(onBack = onBack) val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val hazeState = remember { HazeState() } @@ -139,6 +145,7 @@ fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: scroll = scroll, testing = testing, onTestingChange = { testing = it }, + padsOverride = padsOverride, // Only while testing: the rest of the time the screen's own nav holds the // probes, so the "Last input" line is a test-time readout here rather than // an always-on one. A pad that reaches this screen at all has already @@ -200,14 +207,17 @@ private fun ControllersBody( onTestingChange: (Boolean) -> Unit, observeInput: Boolean, contentPadding: PaddingValues, + padsOverride: List? = null, heading: @Composable () -> Unit, ) { val context = LocalContext.current val activity = context as? MainActivity - // Device list, re-read on every hot-plug event. + // Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the + // screenshot harness runs where no InputDevice can exist, and the connected-pad card is the + // point of that shot. var generation by remember { mutableIntStateOf(0) } - val pads = remember(generation) { Gamepad.pads() } + val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf) val others = remember(generation) { InputDevice.getDeviceIds() .toList() @@ -392,8 +402,8 @@ private fun ControllersBody( // Every real controller is forwarded now (Automatic forwards them all, each on its own // wire pad index) — not just the first. A joystick-only device Android doesn't classify as // a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it. - pads.forEach { dev -> - PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting) + pads.forEach { info -> + PadRow(info, gamepadSetting = gamepadSetting) } } @@ -675,19 +685,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) { /** One detected gamepad: identity, what it streams as, and a rumble test. */ @Composable -private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { +private fun PadRow(info: PadInfo, gamepadSetting: Int) { OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - if (forwarded) { + Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (info.forwarded) { // Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad // user can tell which physical pad is which. The stream's wire pad index is // assigned separately (lowest-free per device) once streaming starts. - val number = dev.controllerNumber + val number = info.controllerNumber Text( if (number > 0) "forwarded · player $number" else "forwarded to host", style = MaterialTheme.typography.labelSmall, @@ -696,11 +706,11 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { } } Text( - deviceDetail(dev), + info.detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - val resolved = Gamepad.prefFor(dev) + val resolved = info.resolvedPref Text( if (gamepadSetting == Gamepad.PREF_AUTO) { "Streams as: ${prefLabel(resolved)} (automatic)" @@ -711,9 +721,8 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - val canRumble = deviceHasVibrator(dev) - if (canRumble) { - OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") } + if (info.canRumble) { + OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") } } else { Text( "No rumble motors reported — host rumble will be silent", @@ -794,6 +803,32 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) { private fun isForwarded(dev: InputDevice): Boolean = !dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD +/** + * Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose + * the connected-pad card at all — Robolectric enumerates no input devices, and a marketing shot of + * "no controller detected" sells nothing. Production always maps a real device via [padInfoOf]; + * [dev] powers the rumble test and is absent only in the harness (the button then no-ops). + */ +internal data class PadInfo( + val name: String, + val detail: String, + val forwarded: Boolean, + val controllerNumber: Int, + val resolvedPref: Int, + val canRumble: Boolean, + val dev: InputDevice? = null, +) + +internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo( + name = dev.name, + detail = deviceDetail(dev), + forwarded = isForwarded(dev), + controllerNumber = dev.controllerNumber, + resolvedPref = Gamepad.prefFor(dev), + canRumble = deviceHasVibrator(dev), + dev = dev, +) + /** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */ private fun deviceHasVibrator(dev: InputDevice): Boolean = if (Build.VERSION.SDK_INT >= 31) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index d2e287ff..85faf9ac 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -254,8 +254,10 @@ private fun MessageState(text: String) { ) } +// Internal (not private): the screenshot harness composes the real coverflow with mock games — +// the library screen itself can't be shot, its state comes off the network. @Composable -private fun Coverflow( +internal fun Coverflow( games: List, loader: ImageLoader, navActive: Boolean, diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 142e6c15..9f966565 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -173,6 +173,21 @@ class ScreenshotTest { @Test fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + /** The touch presentation, pads connected — the store's FEEL THE GAME frame. */ + @Test + fun controllers() = shootRoot("controllers") { ControllersScene() } + + /** + * The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the + * orientation the coverflow actually runs in, and the only one wide enough for neighbours. + */ + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun library() = shootRoot("library") { LibraryScene() } + + @Test + fun libraryPortrait() = shootRoot("library-portrait") { LibraryScene() } + @Test fun consoleControllersLight() = shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index c6cf105f..6717852d 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -1,5 +1,16 @@ package io.unom.punktfunk.screenshots +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.Typeface +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,8 +46,26 @@ import androidx.compose.runtime.CompositionLocalProvider import io.unom.punktfunk.GamepadHome import io.unom.punktfunk.GamepadInk import io.unom.punktfunk.GamepadPalette +import coil.ImageLoader +import coil.test.FakeImageLoaderEngine +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.ConsoleControllersScreen +import io.unom.punktfunk.ConsoleHeader +import io.unom.punktfunk.ConsoleLegendInset import io.unom.punktfunk.ConsoleLicensesScreen +import io.unom.punktfunk.ControllersScreen +import io.unom.punktfunk.Coverflow +import io.unom.punktfunk.GamepadAuroraBackground +import io.unom.punktfunk.GamepadHintBar +import io.unom.punktfunk.PadGlyph +import io.unom.punktfunk.PadInfo +import io.unom.punktfunk.consoleLegendInsets +import io.unom.punktfunk.consoleSafeArea +import io.unom.punktfunk.kit.Gamepad +import io.unom.punktfunk.kit.library.Artwork +import io.unom.punktfunk.kit.library.GameEntry +import androidx.compose.ui.platform.LocalConfiguration import io.unom.punktfunk.GamepadSettingsScreen import io.unom.punktfunk.HomeTile import io.unom.punktfunk.LocalGamepadInk @@ -510,8 +539,8 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") { * whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing * before this could catch light-grey body text stranded on a pastel field. * - * Robolectric enumerates no input devices, so the controllers scene renders its deterministic - * "nothing connected" state. + * Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the + * deterministic connected-pads state the store listing needs. */ @Composable internal fun ConsoleLicensesScene(paletteId: String = "violet") = @@ -520,14 +549,130 @@ internal fun ConsoleLicensesScene(paletteId: String = "violet") = @Composable internal fun ConsoleControllersScene(paletteId: String = "violet") = ConsolePalette(paletteId) { - ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false) + // Robolectric enumerates no input devices, so the shot injects the two pads the store + // listing talks about — the empty "no controller detected" state proves the palette but + // sells nothing. + ConsoleControllersScreen( + gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(), + ) } +/** + * The touch presentation of the same screen, with the same injected pads. Wrapped in a background + * [Surface]: the activity provides the dark ground in the app, and without one here the content + * color falls back to black-on-white while the cards stay dark. + */ +@Composable +internal fun ControllersScene() = + Surface(color = MaterialTheme.colorScheme.background) { + ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads()) + } + +/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */ +internal fun shotPads() = listOf( + PadInfo( + name = "DualSense Wireless Controller", + detail = "054C:0CE6 · gamepad · joystick", + forwarded = true, controllerNumber = 1, + resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true, + ), + PadInfo( + name = "Xbox Wireless Controller", + detail = "045E:0B13 · gamepad · joystick", + forwarded = true, controllerNumber = 2, + resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true, + ), +) + /** * Publish the palette locals `App` would normally provide. A scene that calls a console screen * directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently * prove nothing at all. */ +/** + * The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf. + * The library screen itself can't be shot — its state comes off the network — so the scene rebuilds + * the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint + * bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated + * posters, so the frozen animation clock never races an async load. + */ +@Composable +internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) { + val context = LocalContext.current + val loader = remember { shotLibraryLoader(context) } + val games = remember { shotGames() } + val hazeState = remember { HazeState() } + val landscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + GamepadAuroraBackground(Modifier.fillMaxSize()) + Column(Modifier.fillMaxSize().consoleSafeArea()) { + ConsoleHeader("Living Room PC — Library") + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Coverflow(games, loader, navActive = false, onLaunch = {}) + } + } + } + Box( + Modifier.align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")), + hazeState = hazeState, + ) + } + } +} + +/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */ +private fun shotGames() = listOf( + GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)), + GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)), + GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)), + GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)), + GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"), +) + +private fun shotLibraryLoader(context: Context): ImageLoader { + val engine = FakeImageLoaderEngine.Builder() + .intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A")) + .intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S")) + .intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N")) + .intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E")) + .default(ColorDrawable(0xFF221E44.toInt())) + .build() + return ImageLoader.Builder(context).components { add(engine) }.build() +} + +/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */ +private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable { + val w = 600 + val h = 900 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + canvas.drawRect( + 0f, 0f, w.toFloat(), h.toFloat(), + Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP, + ) + }, + ) + canvas.drawText( + mark, w / 2f, h / 2f + 110f, + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xD9FFFFFF.toInt() + textSize = 320f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + textAlign = Paint.Align.CENTER + }, + ) + return BitmapDrawable(context.resources, bmp) +} + @Composable private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) { val palette = GamepadPalette.named(paletteId) diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt index fef490a5..09dd2f9b 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt @@ -50,6 +50,10 @@ class TvScreenshotTest { @Test fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + /** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */ + @Test + fun library() = shootRoot("library") { LibraryScene() } + @Test fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index fd2f3b5a..190187ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -35,6 +35,11 @@ enum ShotScenes { ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) { AnyView(ShotSettings()) }, + // 06–10 are the iOS/macOS console-shell block below; the library is cross-platform + // (tvOS renders the same coverflow), hence the number above that range. + ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) { + AnyView(ShotLibrary()) + }, ] #if os(iOS) || os(macOS) // The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots @@ -193,6 +198,24 @@ enum ShotMock { #endif } + /// A believable shelf for the library coverflow. Decoded rather than constructed: + /// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public + /// construction surface. No art URLs — the posters render their deterministic fallback + /// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline. + static let games: [GameEntry] = { + let json = """ + [ + {"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}}, + {"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}}, + {"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}}, + {"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}}, + {"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {}, + "role": "launcher", "icon": "steam"} + ] + """ + return (try? JSONDecoder().decode([GameEntry].self, from: Data(json.utf8))) ?? [] + }() + /// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs. static let fingerprint = hostFingerprint(0) @@ -230,6 +253,19 @@ private struct ShotHome: View { } } +// MARK: - Library + +/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real +/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback +/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures. +private struct ShotLibrary: View { + var body: some View { + LibraryCoverflowView( + games: ShotMock.games, artLoader: nil, + onLaunch: { _ in }, onDismiss: {}, controllerActive: false) + } +} + // MARK: - Gamepad-mode console screens (dev-only glass preview) #if os(iOS) || os(macOS) diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 3eb88a9c..7a6cf789 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk" # The App Store set, in listing order — the first three are what most people ever see, so they are # the stream itself, the machines it found, and the couch/controller mode. Everything else in # ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`. -SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) +SCENES=(${SCENES:-01-stream 02-hosts 11-library 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing mkdir -p "$OUT" -- 2.54.0 From 5a4dd7423e494a107dfc373bfb509bd3c90a83e6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 15:01:20 +0200 Subject: [PATCH 2/7] feat(screenshots): the Apple controller panel renders a value model, so the harness can inject pads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ControllerTestView drew straight from GCController/GCExtendedGamepad, and a GCController cannot be constructed — the store plan's FEEL THE GAME frame had no Apple scene. Every card now renders plain values (ShotPad, InputSnapshot): the live path flattens the active DiscoveredController and samples the pad into a snapshot on each 30 Hz tick, the screenshot harness hands the panel pads that were never connected via a default-nil shotPads parameter (the seam Android's ControllersScreen grew in 0a468c96). Live behavior is unchanged — same cards, same order, same live feeds. The 12-controllers scene injects the two pads the listing names — the DualSense leading with the feedback surface (adaptive-trigger effects, rumble backend, lightbar + player LEDs), the Xbox pad carrying the input readout frozen mid-game; transport/battery/player ride in the header's detail line because the panel has no dedicated battery row. Registered in the iOS/macOS block and the store set only: ControllerTestView does not build on tvOS, so the tvOS CI scene list is untouched. --- .../Screenshots/ScreenshotScenes.swift | 57 ++++ .../Settings/ControllerTestView.swift | 251 +++++++++++++----- clients/apple/tools/screenshots.sh | 2 +- 3 files changed, 243 insertions(+), 67 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 190187ef..b4e5a56a 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -73,6 +73,12 @@ enum ShotScenes { ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) { AnyView(ShotConnect(kind: .timedOut, gamepadUI: false)) }, + // FEEL THE GAME — the controller test panel with injected pads. Gated with the + // console block because ControllerTestView doesn't build on tvOS, not because it + // is a console screen. + ShotScene(name: "12-controllers", orientation: .natural, colorScheme: .dark) { + AnyView(ShotControllers()) + }, ] #endif scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) { @@ -347,6 +353,57 @@ private struct ShotConnect: View { } } } + +// MARK: - Controllers (the pads the store listing names) + +/// The FEEL THE GAME frame: the controller test panel rendering the two pads the listing talks +/// about. A GCController cannot be constructed, so the panel draws injected `ShotPad`s — the +/// DualSense leads with the feedback surface (adaptive-trigger effects, rumble backend, lightbar +/// + player LEDs), the Xbox pad carries the input readout, frozen mid-game. +private struct ShotControllers: View { + var body: some View { + #if os(macOS) + // The panel is a window-modal sheet in the app — float it at sheet width over the + // dimmed host grid, the way the other mac sheet shots read. + ZStack { + ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45)) + ControllerTestView(shotPads: Self.pads) + .frame(width: 500, height: 840) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 40, y: 16) + } + #else + ControllerTestView(shotPads: Self.pads) + #endif + } + + /// Transport/battery/player ride in `detail` — the panel has no dedicated battery row. + /// Each pad shows a different half of the panel so both fit one portrait canvas: the + /// DualSense skips the input card (the effect grid is the marketing point), the Xbox pad + /// skips rumble and shows the readout instead. + static let pads: [ControllerTestView.ShotPad] = [ + .init( + name: "DualSense Wireless Controller", + detail: "Bluetooth · 85% · Player 1", + isDualSense: true, hasAdaptiveTriggers: true, hasLight: true, + rumbleBackend: "DualSense HID · Bluetooth"), + .init( + name: "Xbox Wireless Controller", + detail: "Bluetooth · 60% · Player 2", + isDualSense: false, hasAdaptiveTriggers: false, hasLight: false, + input: .init( + leftStick: .init(x: -0.31, y: 0.54), + rightStick: .init(x: 0.72, y: -0.16), + leftTrigger: 0.08, rightTrigger: 0.62, + buttons: [ + ("A", true), ("B", false), ("X", false), ("Y", false), + ("LB", false), ("RB", true), ("L3", false), ("R3", false), + ("Menu", false), ("Opts", false), + ("↑", false), ("↓", false), ("←", false), ("→", false), + ])), + ] +} #endif // MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift b/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift index 6165e782..3f062bcb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift @@ -4,6 +4,11 @@ // physical pad (no host needed), so the rendering paths a session uses can be confirmed // on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers. // +// Every card renders a plain value model (`ShotPad` / `InputSnapshot`) that the live path samples +// out of the real pad each timeline tick. A GCController cannot be constructed, and the App Store +// screenshot harness needs this panel with pads the capture machine doesn't have — ShotScenes +// injects them via `shotPads` (the same seam Android's ControllersScreen grew for its capture). +// // tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style // layout); macOS + iOS/iPadOS cover the validation need. @@ -14,10 +19,63 @@ import SwiftUI @MainActor struct ControllerTestView: View { + /// What one panel section says about a pad, as plain values. The live path flattens the + /// active `DiscoveredController` into one; the screenshot harness hands the panel pads that + /// were never connected. `input`/`rumbleBackend` are the harness's section knobs (nil hides + /// that card) — the live path always shows both, fed from the live pad and tester. + struct ShotPad: Identifiable { + let name: String + /// The header's second line. Production shows the GC product category; a shot packs + /// transport/battery/player facts into it (the panel has no dedicated battery row). + let detail: String + let isDualSense: Bool + let hasAdaptiveTriggers: Bool + let hasLight: Bool + var input: InputSnapshot? = nil + var rumbleBackend: String? = nil + var id: String { name } + } + + /// One frame of the input readout. The live path samples the real `GCExtendedGamepad` into + /// one of these on every 30 Hz tick; the harness writes a mid-game frame by hand. + struct InputSnapshot { + struct Stick { + var x: Float + var y: Float + var pressed = false + } + struct Touch { + /// Finger position in GC's -1...1 axes; nil = lifted. (GC snaps a lifted finger to + /// exactly (0, 0), so a real (0, 0) contact is indistinguishable anyway.) + var primary: CGPoint? + var secondary: CGPoint? + var clicked = false + } + struct Motion { + var gyro: SIMD3 + var accel: SIMD3 + } + var leftStick: Stick + var rightStick: Stick + var leftTrigger: Float = 0 + var rightTrigger: Float = 0 + /// Grid order; label → pressed. + var buttons: [(String, Bool)] + var touchpad: Touch? + var motion: Motion? + } + @Environment(\.dismiss) private var dismiss @ObservedObject private var gamepads = GamepadManager.shared @StateObject private var tester = ControllerTester() + /// Screenshot-harness injection — nil (the app) renders the live active pad. + private let shotPads: [ShotPad]? + + init(shotPads: [ShotPad]? = nil) { + self.shotPads = shotPads + } + @State private var heavyOn = false @State private var lightOn = false @State private var intensity = 0.75 @@ -62,12 +120,12 @@ struct ControllerTestView: View { Divider() ScrollView { VStack(alignment: .leading, spacing: 16) { - if let active = gamepads.active { - header(active) - inputCard - rumbleCard() - triggerCard(active) - extrasCard(active) + if let shotPads { + ForEach(shotPads) { pad in + shotPanel(pad) + } + } else if let active = gamepads.active { + livePanel(active) } else { ContentUnavailableView( "No controller", @@ -81,9 +139,10 @@ struct ControllerTestView: View { } } .frame(minWidth: 420, minHeight: 540) - .onAppear { tester.target(gamepads.active?.controller) } - .onDisappear { tester.stop() } + .onAppear { if shotPads == nil { tester.target(gamepads.active?.controller) } } + .onDisappear { if shotPads == nil { tester.stop() } } .onChange(of: gamepads.active?.id) { _, _ in + guard shotPads == nil else { return } heavyOn = false lightOn = false playerLED = -1 @@ -91,16 +150,53 @@ struct ControllerTestView: View { } } + // MARK: Panels + + @ViewBuilder + private func livePanel(_ active: GamepadManager.DiscoveredController) -> some View { + let pad = Self.describe(active) + header(pad) + liveInputCard + rumbleCard(backend: tester.rumbleBackend, health: tester.rumbleHealth) + triggerCard(pad) + extrasCard(pad) + } + + /// An injected pad's cards, in the live panel's order. The adaptive-trigger card is skipped + /// outright for a pad without them — the live path's "needs a DualSense" hint is a diagnosis, + /// and a capture has nothing to diagnose. + @ViewBuilder + private func shotPanel(_ pad: ShotPad) -> some View { + header(pad) + if let input = pad.input { + card("Input") { inputReadout(input) } + } + if let backend = pad.rumbleBackend { + rumbleCard(backend: backend, health: nil) + } + if pad.hasAdaptiveTriggers { + triggerCard(pad) + } + extrasCard(pad) + } + + /// The live pad, flattened to what the panel renders about it. + private static func describe(_ c: GamepadManager.DiscoveredController) -> ShotPad { + ShotPad( + name: c.name, detail: c.productCategory, isDualSense: c.isDualSense, + hasAdaptiveTriggers: c.hasAdaptiveTriggers, hasLight: c.hasLight) + } + // MARK: Header - private func header(_ c: GamepadManager.DiscoveredController) -> some View { + private func header(_ pad: ShotPad) -> some View { HStack(spacing: 10) { - Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill") + Image(systemName: pad.isDualSense ? "playstation.logo" : "gamecontroller.fill") .font(.title2) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { - Text(c.name).font(.geist(17, .semibold, relativeTo: .headline)) - Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) + Text(pad.name).font(.geist(17, .semibold, relativeTo: .headline)) + Text(pad.detail).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) } Spacer() } @@ -108,13 +204,13 @@ struct ControllerTestView: View { // MARK: Input - private var inputCard: some View { + private var liveInputCard: some View { card("Input") { // Poll the live controller at 30 Hz — no handlers installed, so nothing else's // capture is disturbed. TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in if let gp = gamepads.active?.controller.extendedGamepad { - inputReadout(gp, controller: gamepads.active?.controller) + inputReadout(Self.snapshot(gp, controller: gamepads.active?.controller)) } else { Text("Not an extended gamepad").foregroundStyle(.secondary) } @@ -122,40 +218,82 @@ struct ControllerTestView: View { } } + /// One readout frame off the live pad. + private static func snapshot( + _ g: GCExtendedGamepad, controller: GCController? + ) -> InputSnapshot { + var buttons: [(String, Bool)] = [ + ("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed), + ("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed), + ("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed), + ("L3", g.leftThumbstickButton?.isPressed ?? false), + ("R3", g.rightThumbstickButton?.isPressed ?? false), + ("Menu", g.buttonMenu.isPressed), + ("Opts", g.buttonOptions?.isPressed ?? false), + ("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed), + ("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed), + ] + let tp = touchpad(g) + if let tp { buttons.append(("Pad", tp.button.isPressed)) } + return InputSnapshot( + leftStick: .init( + x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value, + pressed: g.leftThumbstickButton?.isPressed ?? false), + rightStick: .init( + x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value, + pressed: g.rightThumbstickButton?.isPressed ?? false), + leftTrigger: g.leftTrigger.value, rightTrigger: g.rightTrigger.value, + buttons: buttons, + touchpad: tp.map { + .init(primary: finger($0.primary), secondary: finger($0.secondary), + clicked: $0.button.isPressed) + }, + motion: controller?.motion.map { m -> InputSnapshot.Motion in + let a = totalAccel(m) + return .init( + gyro: .init(m.rotationRate.x, m.rotationRate.y, m.rotationRate.z), + accel: .init(a.0, a.1, a.2)) + }) + } + + private static func finger(_ pad: GCControllerDirectionPad) -> CGPoint? { + let x = pad.xAxis.value, y = pad.yAxis.value + // GC snaps a lifted finger to exactly (0, 0). + return (x == 0 && y == 0) ? nil : CGPoint(x: CGFloat(x), y: CGFloat(y)) + } + @ViewBuilder - private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View { + private func inputReadout(_ s: InputSnapshot) -> some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 20) { - stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value, - pressed: g.leftThumbstickButton?.isPressed ?? false) - stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value, - pressed: g.rightThumbstickButton?.isPressed ?? false) + stick("L", s.leftStick) + stick("R", s.rightStick) VStack(spacing: 8) { - triggerBar("L2", value: g.leftTrigger.value) - triggerBar("R2", value: g.rightTrigger.value) + triggerBar("L2", value: s.leftTrigger) + triggerBar("R2", value: s.rightTrigger) } } - buttonGrid(g) - if let tp = Self.touchpad(g) { + buttonGrid(s.buttons) + if let tp = s.touchpad { touchpadView(tp) } - if let m = controller?.motion { + if let m = s.motion { motionReadout(m) } } } - private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View { + private func stick(_ label: String, _ s: InputSnapshot.Stick) -> some View { VStack(spacing: 4) { ZStack { Circle().stroke(Color.secondary.opacity(0.3)) Circle() - .fill(pressed ? Color.accentColor : Color.secondary) + .fill(s.pressed ? Color.accentColor : Color.secondary) .frame(width: 12, height: 12) - .offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up + .offset(x: CGFloat(s.x) * 22, y: CGFloat(-s.y) * 22) // GC y is +up } .frame(width: 56, height: 56) - Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary) + Text("\(label) \(sgn(s.x)),\(sgn(s.y))").font(.caption2.monospaced()).foregroundStyle(.secondary) } } @@ -175,20 +313,8 @@ struct ControllerTestView: View { .frame(width: 150) } - private func buttonGrid(_ g: GCExtendedGamepad) -> some View { - var items: [(String, Bool)] = [ - ("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed), - ("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed), - ("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed), - ("L3", g.leftThumbstickButton?.isPressed ?? false), - ("R3", g.rightThumbstickButton?.isPressed ?? false), - ("Menu", g.buttonMenu.isPressed), - ("Opts", g.buttonOptions?.isPressed ?? false), - ("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed), - ("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed), - ] - if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) } - return LazyVGrid( + private func buttonGrid(_ items: [(String, Bool)]) -> some View { + LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6 ) { ForEach(items.indices, id: \.self) { i in @@ -203,12 +329,9 @@ struct ControllerTestView: View { } } - private func touchpadView( - _ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad, - button: GCControllerButtonInput) - ) -> some View { + private func touchpadView(_ tp: InputSnapshot.Touch) -> some View { VStack(alignment: .leading, spacing: 4) { - Text("Touchpad\(tp.button.isPressed ? " — click" : "")") + Text("Touchpad\(tp.clicked ? " — click" : "")") .font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary) ZStack { RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3)) @@ -219,29 +342,25 @@ struct ControllerTestView: View { } } - private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View { - let x = pad.xAxis.value, y = pad.yAxis.value - let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0) - return Circle().fill(color).frame(width: 10, height: 10) - .offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33) - .opacity(active ? 1 : 0) + private func fingerDot(_ p: CGPoint?, color: Color) -> some View { + Circle().fill(color).frame(width: 10, height: 10) + .offset(x: (p?.x ?? 0) * 71, y: -(p?.y ?? 0) * 33) + .opacity(p == nil ? 0 : 1) } - private func motionReadout(_ m: GCMotion) -> some View { - let a = Self.totalAccel(m) - return VStack(alignment: .leading, spacing: 2) { + private func motionReadout(_ m: InputSnapshot.Motion) -> some View { + VStack(alignment: .leading, spacing: 2) { Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary) - Text(String(format: "gyro %+.2f %+.2f %+.2f", - m.rotationRate.x, m.rotationRate.y, m.rotationRate.z)) + Text(String(format: "gyro %+.2f %+.2f %+.2f", m.gyro.x, m.gyro.y, m.gyro.z)) .font(.caption2.monospaced()) - Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2)) + Text(String(format: "accel %+.2f %+.2f %+.2f", m.accel.x, m.accel.y, m.accel.z)) .font(.caption2.monospaced()) } } // MARK: Rumble - private func rumbleCard() -> some View { + private func rumbleCard(backend: String, health: String?) -> some View { card("Rumble") { VStack(alignment: .leading, spacing: 12) { Picker("Strength", selection: $intensity) { @@ -253,9 +372,9 @@ struct ControllerTestView: View { .pickerStyle(.segmented) Toggle("Heavy motor (left)", isOn: $heavyOn) Toggle("Light motor (right)", isOn: $lightOn) - Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform") + Label("Backend: \(backend)", systemImage: "waveform") .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) - if let problem = tester.rumbleHealth { + if let problem = health { Label(problem, systemImage: "exclamationmark.triangle.fill") .font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange) } @@ -276,9 +395,9 @@ struct ControllerTestView: View { // MARK: Adaptive triggers - private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View { + private func triggerCard(_ pad: ShotPad) -> some View { card("Adaptive triggers") { - if c.hasAdaptiveTriggers { + if pad.hasAdaptiveTriggers { VStack(alignment: .leading, spacing: 12) { Picker("Apply to", selection: $triggerTarget) { ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) } @@ -315,8 +434,8 @@ struct ControllerTestView: View { // MARK: Lightbar + player LED @ViewBuilder - private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View { - if c.hasLight { + private func extrasCard(_ pad: ShotPad) -> some View { + if pad.hasLight { card("Lightbar & player LED") { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 7a6cf789..c6602e97 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -45,7 +45,7 @@ BUNDLE_ID="io.unom.punktfunk" # The App Store set, in listing order — the first three are what most people ever see, so they are # the stream itself, the machines it found, and the couch/controller mode. Everything else in # ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`. -SCENES=(${SCENES:-01-stream 02-hosts 11-library 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) +SCENES=(${SCENES:-01-stream 02-hosts 11-library 12-controllers 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing mkdir -p "$OUT" -- 2.54.0 From e3443da1081e2734deb3de6f439c2eca4da2b1fc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 15:07:13 +0200 Subject: [PATCH 3/7] =?UTF-8?q?fix(screenshots):=20the=20store=20frames=20?= =?UTF-8?q?go=20landscape=20=E2=80=94=20the=20app=20is=20built=20for=20hor?= =?UTF-8?q?izontal=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portrait captures show a layout nobody streams in. The touch controllers frame and the library shot now render at landscape phone geometry (the portrait library variant is gone), a console-controllers-landscape frame joins the set, and the Apple 12-controllers scene rotates: on the landscape canvas the two pads sit as side-by-side columns — one ControllerTestView per pad — so neither story is cut by the short height. Known wart, deliberate: the console landscape frame's floating legend overlaps the second pad card mid-scroll; the styled composite crops above it, and the touch variant carries the uncropped two-card view. --- .../punktfunk/screenshots/ScreenshotTest.kt | 15 +++++++++++---- .../Screenshots/ScreenshotScenes.swift | 17 +++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 9f966565..09110040 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -173,10 +173,20 @@ class ScreenshotTest { @Test fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } - /** The touch presentation, pads connected — the store's FEEL THE GAME frame. */ + /** + * The touch presentation, pads connected — landscape, like every store frame: the app is + * built for horizontal use, and a portrait capture shows a layout nobody streams in. + */ @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun controllers() = shootRoot("controllers") { ControllersScene() } + /** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */ + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun consoleControllersLandscape() = + shootRoot("console-controllers-landscape") { ConsoleControllersScene() } + /** * The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the * orientation the coverflow actually runs in, and the only one wide enough for neighbours. @@ -185,9 +195,6 @@ class ScreenshotTest { @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun library() = shootRoot("library") { LibraryScene() } - @Test - fun libraryPortrait() = shootRoot("library-portrait") { LibraryScene() } - @Test fun consoleControllersLight() = shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index b4e5a56a..080e2427 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -75,8 +75,9 @@ enum ShotScenes { }, // FEEL THE GAME — the controller test panel with injected pads. Gated with the // console block because ControllerTestView doesn't build on tvOS, not because it - // is a console screen. - ShotScene(name: "12-controllers", orientation: .natural, colorScheme: .dark) { + // is a console screen. Landscape like the rest of the store set: the app is built + // for horizontal use, so the two pads sit as side-by-side columns (see the scene). + ShotScene(name: "12-controllers", orientation: .landscape, colorScheme: .dark) { AnyView(ShotControllers()) }, ] @@ -374,14 +375,18 @@ private struct ShotControllers: View { .shadow(radius: 40, y: 16) } #else - ControllerTestView(shotPads: Self.pads) + // Landscape canvas: one column per pad, so neither story is cut by the short height — + // the DualSense feedback surface left, the Xbox live-input readout right. + HStack(spacing: 0) { + ControllerTestView(shotPads: [Self.pads[0]]) + ControllerTestView(shotPads: [Self.pads[1]]) + } #endif } /// Transport/battery/player ride in `detail` — the panel has no dedicated battery row. - /// Each pad shows a different half of the panel so both fit one portrait canvas: the - /// DualSense skips the input card (the effect grid is the marketing point), the Xbox pad - /// skips rumble and shows the readout instead. + /// Each pad shows a different half of the panel: the DualSense skips the input card (the + /// effect grid is the marketing point), the Xbox pad skips rumble and shows the readout. static let pads: [ControllerTestView.ShotPad] = [ .init( name: "DualSense Wireless Controller", -- 2.54.0 From f033d3f5df80073fa1e80ad2092ecff0b0cb07e6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 17:11:35 +0200 Subject: [PATCH 4/7] =?UTF-8?q?fix(screenshots):=20pin=20the=20shot=20pale?= =?UTF-8?q?tte=20=E2=80=94=20a=20reused=20device's=20saved=20choice=20ship?= =?UTF-8?q?ped=20a=20sunset=20Apple=20TV=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aurora screens read the LIVE uiPalette default, and shot mode never forced one: the Apple TV Simulator had a sunset palette persisted from manual use, so every tvOS capture came out pink-on-pale while the iPhone set stayed violet. ScreenshotHostView now pins the palette (violet, or PUNKTFUNK_SHOT_PALETTE) before the scene mounts. Also documents the local tvOS-SIMULATOR wall in screenshots.sh: Xcode 26.6 and the 27 beta plan the macro targets swiftui-navigation-transitions pulls in for the tvOS triple and never schedule their swift-syntax deps ('unable to resolve module dependency') — prebuilts on or off. Only the tvOS target links that package, which is why iOS and device builds never hit it. Local workaround, since HomeView's use is canImport-guarded: temporarily unlink the product from the tvOS target, capture, restore. --- .../PunktfunkClient/Screenshots/ScreenshotHost.swift | 12 ++++++++++++ clients/apple/tools/screenshots.sh | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift index 92819bfb..3297ed0c 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift @@ -16,6 +16,7 @@ // can wait for layout instead of guessing with a fixed sleep. #if DEBUG +import PunktfunkKit import SwiftUI #if os(macOS) import AppKit @@ -43,6 +44,17 @@ enum ScreenshotMode { /// readiness ping for the capture script. struct ScreenshotHostView: View { let scene: ShotScene + + init(scene: ShotScene) { + self.scene = scene + // Pin the palette for the capture. The aurora screens read the LIVE `uiPalette` default, + // and a reused Simulator (or a dev Mac) carries whatever was last picked there — the + // Apple TV set once shipped out on a sunset palette that a test device had persisted. + // Idempotent, and only ever runs in shot mode (this view exists behind that gate). + UserDefaults.standard.set( + ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_PALETTE"] ?? "violet", + forKey: DefaultsKey.uiPalette) + } #if os(iOS) @Environment(\.horizontalSizeClass) private var hSizeClass @Environment(\.verticalSizeClass) private var vSizeClass diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index c6602e97..abecf2e6 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -142,6 +142,14 @@ shoot_sim() { # incremental build instead of cold-building into a throwaway tmpdir — CI pins this # (apple.yml); local runs keep the self-cleaning mktemp default. local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd" + # tvOS-SIMULATOR trap (Xcode 26.6 and the 27 beta, local only so far): the build planner + # schedules the SwiftPM MACRO plugin targets that swiftui-navigation-transitions pulls in + # (OnceMacro/SwizzlingMacro/AssociationMacro) for the *tvOS* triple and never plans their + # swift-syntax dependencies at all — "unable to resolve module dependency: 'SwiftSyntax'". + # Device archives and iOS builds don't hit it (only the tvOS target links that package), and + # prebuilt-vs-source swift-syntax makes no difference. Until Xcode fixes the planner, the + # workaround is temporarily unlinking SwiftUINavigationTransitions from the tvOS target + # (HomeView's use is canImport-guarded — the push transition degrades to the crossfade). xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \ -sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \ CODE_SIGNING_ALLOWED=NO build >/dev/null \ -- 2.54.0 From b66bcef5287e2aeb76a15e21ae9fbeb15670d910 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 18:23:38 +0200 Subject: [PATCH 5/7] fix(screenshots): the macOS leg built the harness out of existence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole shot harness is #if DEBUG, and shoot_macos built -c release — so the binary launched as the NORMAL app, never printed PF_SHOT_WINDOW, and every scene 'never reported a window' while the script SIGKILLed a perfectly healthy app. Build debug: SwiftUI has no release-only visuals, and the harness actually exists there. All eight mac scenes capture now. --- clients/apple/tools/screenshots.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index abecf2e6..07ffca17 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -63,9 +63,13 @@ require_xcode() { # ---------------------------------------------------------------------------- macOS shoot_macos() { - log "macOS — building (swift build -c release)…" - swift build -c release >/dev/null - local bin=".build/release/PunktfunkClient" + # DEBUG build, deliberately: the whole shot harness lives behind `#if DEBUG` + # (ScreenshotHost/ScreenshotScenes), so a release binary launches as the NORMAL app, never + # prints PF_SHOT_WINDOW, and every scene "never reported a window". Debug renders the same + # pixels — SwiftUI has no release-only visuals. + log "macOS — building (swift build)…" + swift build >/dev/null + local bin=".build/debug/PunktfunkClient" [ -x "$bin" ] || die "build produced no $bin" for scene in "${SCENES[@]}"; do -- 2.54.0 From cc70c6479736ce4276e06454ded07a6a62689f3e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 19:15:37 +0200 Subject: [PATCH 6/7] =?UTF-8?q?feat(screenshots):=20the=20add-host=20sheet?= =?UTF-8?q?=20becomes=20a=20scene=20=E2=80=94=20the=20blends'=20phone=20sc?= =?UTF-8?q?reens=20were=20three=20designs=20old?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Blender store scenes render whatever screens/ holds, and theirs were June captures of the pre-console UI. Fresh captures existed for hosts and pair but the add-host sheet had no scene: AddHostSheet's state is hoisted (ConnectScreen keeps half-typed values across dismissal), so the scene passes a filled form straight in. Two capture-truth fixes with it: dialog scenes advance the frozen clock 1.6 s (a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s), and the add-host shot uses Pixel-like geometry (411×915dp @ 420 dpi — same 1080×2400 px, but the dp headroom is what lets the Connect button, the row carrying the resolution promise, fit in frame). --- .../punktfunk/screenshots/ScreenshotTest.kt | 13 ++++++++++++- .../unom/punktfunk/screenshots/ShotScenes.kt | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 09110040..aff9190f 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -46,7 +46,9 @@ class ScreenshotTest { private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) { compose.mainClock.autoAdvance = false compose.setContent { ShotTheme(content) } - compose.mainClock.advanceTimeBy(800) + // 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the + // add-host sheet's Connect button was captured half below the frame. + compose.mainClock.advanceTimeBy(1600) captureScreenRoboImage("$out/phone-$name.png") } @@ -219,4 +221,13 @@ class ScreenshotTest { HostsScene() PairDialog() } + + /** + * The add-host sheet (separate window → whole-screen capture). Pixel-like geometry, not the + * default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the + * sheet's Connect button — the row that carries the resolution promise — fit in frame. + */ + @Test + @Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi") + fun addHost() = shootScreen("add-host") { AddHostScene() } } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 6717852d..a0b243ef 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -50,6 +50,7 @@ import coil.ImageLoader import coil.test.FakeImageLoaderEngine import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource +import io.unom.punktfunk.AddHostSheet import io.unom.punktfunk.ConsoleControllersScreen import io.unom.punktfunk.ConsoleHeader import io.unom.punktfunk.ConsoleLegendInset @@ -568,6 +569,23 @@ internal fun ControllersScene() = ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads()) } +/** + * The "Add a host" bottom sheet over the host grid — the store's onboarding frame. State is + * hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the + * mode label mirrors what a paired 120 Hz phone shows on the connect button. + */ +@Composable +internal fun AddHostScene() { + HostsScene() + AddHostSheet( + hostName = "Living Room PC", onHostNameChange = {}, + host = "192.168.1.42", onHostChange = {}, + port = "9777", onPortChange = {}, + connecting = false, modeLabel = "2992×1344@120", + onDismiss = {}, onConnect = { _, _, _ -> }, + ) +} + /** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */ internal fun shotPads() = listOf( PadInfo( -- 2.54.0 From 026dbe6153140e446d544e33e9c98c7b4c7acacc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 19:53:09 +0200 Subject: [PATCH 7/7] fix(screenshots): captures grow the status bar Robolectric never had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robolectric renders no system UI and zero insets, so every phone capture was missing the status bar and its content sat where the bar belongs — on the Pixel store render the app title collided with the camera punch-hole. ShotStatusFrame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole) and pushes the scene below it, the same geometry real insets produce; height mirrors a Pixel's tall bar measured off a real capture. On for the touch screens, off for the immersive surfaces (stream, console shell, TV) that hide the real bar too. --- .../punktfunk/screenshots/ScreenshotTest.kt | 61 +++++++++++-------- .../unom/punktfunk/screenshots/ShotScenes.kt | 52 ++++++++++++++++ 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index aff9190f..7bc47ccf 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -34,18 +34,31 @@ class ScreenshotTest { // cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so // setContent's wait-for-idle never returns. Frozen, the capture is also deterministic. - /** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */ - private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) { + /** + * Full-screen content scenes: the compose root fills the device, so a root capture is the + * shot. [statusBar] draws the fake system bar and pushes content below it (see + * [ShotStatusFrame]) — off for the immersive surfaces (stream, console shell), which hide + * the real bar too. + */ + private fun shootRoot( + name: String, + statusBar: Boolean = true, + content: @androidx.compose.runtime.Composable () -> Unit, + ) { compose.mainClock.autoAdvance = false - compose.setContent { ShotTheme(content) } + compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } } compose.mainClock.advanceTimeBy(800) compose.onRoot().captureRoboImage("$out/phone-$name.png") } /** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */ - private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) { + private fun shootScreen( + name: String, + statusBar: Boolean = true, + content: @androidx.compose.runtime.Composable () -> Unit, + ) { compose.mainClock.autoAdvance = false - compose.setContent { ShotTheme(content) } + compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } } // 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the // add-host sheet's Connect button was captured half below the frame. compose.mainClock.advanceTimeBy(1600) @@ -75,25 +88,25 @@ class ScreenshotTest { @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive - fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) } + fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) } @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") - fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) } + fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) } @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") - fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } + fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } // Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the // aspect is load-bearing. @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") - fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) } + fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) } @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") - fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) } + fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) } // The touch flow is a Material dialog over the host grid (a separate window → shootScreen). @Test @@ -116,15 +129,15 @@ class ScreenshotTest { // The console flow is the full-screen aurora takeover (a root capture). @Test - fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() } + fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() } @Test - fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() } + fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() } /** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */ @Test fun consoleSettingsLight() = - shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") } /** * Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the @@ -134,16 +147,16 @@ class ScreenshotTest { @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun consoleSettingsLandscape() = - shootRoot("console-settings-landscape") { ConsoleSettingsScene() } + shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() } // The console home, the screen the living backdrop is most of. The default sdk (36) draws the // real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two // renderings of the same palette can be compared rather than assumed equivalent. @Test - fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() } + fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() } @Test - fun consoleHomeLight() = shootRoot("console-home-light") { ConsoleHomeScene(paletteId = "holo") } + fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") } /** * Landscape — the orientation the console UI actually runs in, and the only one wide enough to @@ -151,7 +164,7 @@ class ScreenshotTest { */ @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") - fun consoleHomeLandscape() = shootRoot("console-home-landscape") { ConsoleHomeScene() } + fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() } /** * The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four @@ -160,20 +173,20 @@ class ScreenshotTest { */ @Test @Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi") - fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() } + fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() } // The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a // pale palette, because the console draws them through a ColorScheme derived from the palette's // ink — and the pale one is the only place a grey-on-pastel slip can show up. @Test - fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() } + fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() } @Test fun consoleLicensesLight() = - shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") } + shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") } @Test - fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() } /** * The touch presentation, pads connected — landscape, like every store frame: the app is @@ -187,7 +200,7 @@ class ScreenshotTest { @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun consoleControllersLandscape() = - shootRoot("console-controllers-landscape") { ConsoleControllersScene() } + shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() } /** * The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the @@ -195,11 +208,11 @@ class ScreenshotTest { */ @Test @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") - fun library() = shootRoot("library") { LibraryScene() } + fun library() = shootRoot("library", statusBar = false) { LibraryScene() } @Test fun consoleControllersLight() = - shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") } + shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") } @Test fun trust() = shootScreen("trust") { diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index a0b243ef..40179d8a 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -15,11 +15,18 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryFull +import androidx.compose.material.icons.filled.SignalCellular4Bar +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.Icon import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -100,6 +107,51 @@ internal fun ShotTheme(content: @Composable () -> Unit) { MaterialTheme(colorScheme = BrandDark, content = content) } +/** + * Robolectric has no system UI, so every capture was missing the status bar and the content sat + * where the bar belongs — on the Pixel render the app title collided with the camera punch-hole. + * This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole) + * and pushes the scene below it, the same geometry real insets produce. The height mirrors a + * Pixel's tall bar as measured off a real 1344×2992 capture (~145 px ≈ 40 dp). + */ +@Composable +internal fun ShotStatusFrame(content: @Composable () -> Unit) { + Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + Row( + Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "21:47", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Wifi, contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), + modifier = Modifier.size(15.dp), + ) + Icon( + Icons.Filled.SignalCellular4Bar, contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), + modifier = Modifier.size(14.dp), + ) + Icon( + Icons.Filled.BatteryFull, contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), + modifier = Modifier.size(16.dp), + ) + } + } + Box(Modifier.weight(1f).fillMaxWidth()) { content() } + } +} + private data class MockHost( val name: String, val address: String, -- 2.54.0