Skip to content

Commit cd54e7b

Browse files
juliusmarmingechrisdeeming
authored andcommitted
feat(mobile): add theme-aware showcase captures
- Support selecting palettes in the screenshot CLI and CI workflow - Apply requested themes natively and isolate output folders per palette - Add validation, tests, and operational documentation
1 parent 3adff68 commit cd54e7b

16 files changed

Lines changed: 409 additions & 78 deletions

File tree

‎.github/workflows/mobile-showcase-screenshots.yml‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ on:
2121
- both
2222
- dark
2323
- light
24+
theme:
25+
description: Palette to capture (all multiplies the run by six)
26+
required: true
27+
default: t3-code
28+
type: choice
29+
options:
30+
- t3-code
31+
- t3-chat
32+
- grove
33+
- ocean
34+
- ember
35+
- iris
36+
- all
2437

2538
permissions:
2639
contents: read
@@ -33,7 +46,9 @@ jobs:
3346
name: iPhone 6.9, iPhone 6.5, and iPad 13
3447
if: inputs.platform == 'all' || inputs.platform == 'ios'
3548
runs-on: blacksmith-12vcpu-macos-26
36-
timeout-minutes: 60
49+
# Capturing every palette multiplies the device matrix by six, and only the
50+
# one native build is shared between them.
51+
timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }}
3752
steps:
3853
- name: Checkout
3954
uses: actions/checkout@v6
@@ -62,10 +77,10 @@ jobs:
6277
"$vp_pnpm_bin/pnpm" --version
6378
6479
- name: Capture iOS showcase
65-
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}"
80+
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
6681

6782
- name: Validate App Store Connect assets
68-
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only
83+
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
6984

7085
- name: Upload iOS screenshots
7186
if: always()
@@ -80,7 +95,9 @@ jobs:
8095
name: Android phone, 7-inch tablet, and 10-inch tablet
8196
if: inputs.platform == 'all' || inputs.platform == 'android'
8297
runs-on: blacksmith-16vcpu-ubuntu-2404
83-
timeout-minutes: 60
98+
# Capturing every palette multiplies the device matrix by six, and only the
99+
# one native build is shared between them.
100+
timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }}
84101
env:
85102
T3_SHOWCASE_ANDROID_ABI: x86_64
86103
steps:
@@ -137,10 +154,10 @@ jobs:
137154
cores: 8
138155
ram-size: 4096M
139156
disable-animations: false
140-
script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}"
157+
script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
141158

142159
- name: Validate Google Play assets
143-
run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only
160+
run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
144161

145162
- name: Upload Android screenshots
146163
if: always()

‎apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() {
2222
storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene")
2323
}
2424

25+
// The palette is fixed for the whole capture, so it only ever arrives as a
26+
// launch extra — unlike the scene, which the runner rewrites in place.
27+
Function("getShowcaseTheme") {
28+
appContext.currentActivity?.intent?.getStringExtra("showcaseTheme")
29+
}
30+
2531
Function("prepareShowcaseCapture") {
2632
// Android app data is cleared by the host runner before launch.
2733
}

‎apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module {
3333
return arguments[flagIndex + 1]
3434
}
3535

36+
// The palette is fixed for the whole capture, so it only ever arrives as a
37+
// launch argument — unlike the scene, which the runner rewrites in place.
38+
Function("getShowcaseTheme") { () -> String? in
39+
let arguments = ProcessInfo.processInfo.arguments
40+
guard
41+
let flagIndex = arguments.firstIndex(of: "--showcaseTheme"),
42+
arguments.indices.contains(flagIndex + 1)
43+
else {
44+
return nil as String?
45+
}
46+
return arguments[flagIndex + 1]
47+
}
48+
3649
Function("getShowcaseOrientation") { () -> String? in
3750
let arguments = ProcessInfo.processInfo.arguments
3851
guard

‎apps/mobile/src/features/review/ReviewSheet.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import { resolveReviewAvailability } from "./reviewAvailability";
7373
import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
7474
import { buildReviewSectionMenu } from "./review-section-menu";
7575
import type { ReviewSectionItem } from "./reviewModel";
76-
import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
76+
import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
7777

7878
const REVIEW_HEADER_SPACING = 0;
7979
const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
@@ -443,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
443443
});
444444
const showcaseReviewKey =
445445
SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection
446-
? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}`
446+
? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}`
447447
: null;
448448
const handleNativeDebug = useCallback(
449449
(event: NativeSyntheticEvent<Record<string, unknown>>) => {
@@ -456,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) {
456456
return;
457457
}
458458
showcasedReviewDrawRef.current = showcaseReviewKey;
459-
markNativeShowcaseReady("review");
459+
reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId });
460460
},
461-
[nativeBridge.onDebug, showcaseReviewKey],
461+
[nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey],
462462
);
463463

464464
const handleSelectFile = useCallback(

‎apps/mobile/src/features/review/useNativeReviewDiffBridge.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export function useNativeReviewDiffBridge(input: {
105105
);
106106

107107
return {
108+
themeId,
108109
theme,
109110
rowsJson,
110111
collapsedFileIdsJson,

‎apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
22
import { useNavigation } from "@react-navigation/native";
33
import { SymbolView } from "../../components/AppSymbol";
44
import type { EnvironmentId } from "@t3tools/contracts";
5-
import { useCallback, useEffect, useState } from "react";
5+
import { useCallback, useState } from "react";
66
import { Platform, ScrollView, View } from "react-native";
77
import { useSafeAreaInsets } from "react-native-safe-area-context";
88

@@ -20,7 +20,6 @@ import {
2020
SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
2121
SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS,
2222
} from "../showcase/showcaseEnvironmentRows";
23-
import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
2423

2524
const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
2625

@@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {
4847
const accentColor = useThemeColor("--color-icon-muted");
4948
const headerIconColor = useThemeColor("--color-icon");
5049

51-
useEffect(() => {
52-
if (!SHOWCASE_ENABLED) return;
53-
const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500);
54-
return () => clearTimeout(timer);
55-
}, []);
56-
5750
const handleToggle = useCallback((environmentId: EnvironmentId) => {
5851
setExpandedId((prev) => (prev === environmentId ? null : environmentId));
5952
}, []);

‎apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx‎

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
22
import { Keyboard, View } from "react-native";
33
import {
44
CommonActions,
@@ -10,6 +10,8 @@ import {
1010
import { AsyncResult } from "effect/unstable/reactivity";
1111

1212
import { useConnectionController } from "../connection/useConnectionController";
13+
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14+
import type { MobileThemeId } from "../../lib/mobileTheme";
1315
import { useProjects, useThreadShells } from "../../state/entities";
1416
import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
1517
import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
@@ -19,6 +21,7 @@ import {
1921
getNativeShowcaseOrientation,
2022
getNativeShowcasePairingUrls,
2123
getNativeShowcaseScene,
24+
getNativeShowcaseTheme,
2225
markNativeShowcaseReady,
2326
type ShowcaseScene,
2427
} from "./nativeShowcaseScene";
@@ -27,6 +30,12 @@ import {
2730
SHOWCASE_PENDING_TASK_DEFINITIONS,
2831
} from "./showcasePendingTasks";
2932
import { retryShowcaseOperation } from "./showcaseRetry";
33+
import {
34+
clearShowcaseRenderSignal,
35+
getShowcaseRenderSignal,
36+
isShowcaseNativeContentReady,
37+
subscribeToShowcaseRenderSignal,
38+
} from "./showcaseRenderSignal";
3039

3140
const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
3241
const SHOWCASE_THREAD_ID = "remote-command-center";
@@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {
4857
export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
4958
const navigation = useNavigation();
5059
const { connectPairingUrl } = useConnectionController();
60+
const {
61+
isReady: appearancePreferencesReady,
62+
themeId,
63+
themeIds,
64+
setThemeIdForBothAppearances,
65+
} = useAppearancePreferences();
5166
const workspace = useWorkspaceState();
5267
const projects = useProjects();
5368
const threads = useThreadShells();
@@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
5671
const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]);
5772
const [pendingTasksReady, setPendingTasksReady] = useState(false);
5873
const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
74+
const [requestedTheme, setRequestedTheme] = useState<MobileThemeId | null>(null);
75+
const [themeRequestSettled, setThemeRequestSettled] = useState(false);
5976
const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
6077
const [orientationSettled, setOrientationSettled] = useState(false);
78+
const requestedSceneRef = useRef<ShowcaseScene | null>(null);
79+
const renderSignal = useSyncExternalStore(
80+
subscribeToShowcaseRenderSignal,
81+
getShowcaseRenderSignal,
82+
getShowcaseRenderSignal,
83+
);
6184

6285
useEffect(() => {
6386
if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
6487

65-
const readPairingUrls = () => {
88+
const readLaunchRequest = () => {
6689
const values = getNativeShowcasePairingUrls();
67-
if (values.length > 0) setPairingUrls(values);
90+
if (values.length === 0) return;
91+
// The palette rides the same launch request as the pairing URLs, so
92+
// reading it here settles it without a timeout that could expire while
93+
// the request is still on its way.
94+
setRequestedTheme(getNativeShowcaseTheme());
95+
setThemeRequestSettled(true);
96+
setPairingUrls(values);
6897
};
69-
readPairingUrls();
70-
const interval = setInterval(readPairingUrls, 250);
98+
readLaunchRequest();
99+
const interval = setInterval(readLaunchRequest, 250);
71100
return () => clearInterval(interval);
72101
}, [pairingUrls.length]);
73102

@@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
95124

96125
const readRequestedScene = () => {
97126
const value = getNativeShowcaseScene();
98-
if (value) setRequestedScene(value);
127+
if (!value || requestedSceneRef.current === value) return;
128+
requestedSceneRef.current = value;
129+
// A native draw belongs only to the scene request that produced it. In
130+
// particular, revisiting review must wait for its newly mounted surface.
131+
clearShowcaseRenderSignal();
132+
setRequestedScene(value);
99133
};
100134
readRequestedScene();
101135
const interval = setInterval(readRequestedScene, 250);
102136
return () => clearInterval(interval);
103137
}, []);
104138

139+
// Captures pick a palette for both color schemes so the requested theme is
140+
// used whichever system appearance the runner set on the device.
141+
const themeApplied =
142+
requestedTheme === null
143+
? themeRequestSettled
144+
: themeIds.light === requestedTheme && themeIds.dark === requestedTheme;
145+
146+
useEffect(() => {
147+
if (
148+
!SHOWCASE_ENABLED ||
149+
requestedTheme === null ||
150+
themeApplied ||
151+
// Writing before stored preferences load would be overwritten by them.
152+
!appearancePreferencesReady
153+
) {
154+
return;
155+
}
156+
setThemeIdForBothAppearances(requestedTheme);
157+
}, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]);
158+
105159
useEffect(() => {
106160
if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return;
107161
let cancelled = false;
@@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
219273
!hasFixture ||
220274
// Never report a scene ready while the capture orientation is still
221275
// being applied — a screenshot taken early has the wrong dimensions.
222-
!orientationSettled
276+
!orientationSettled ||
277+
// Likewise for the palette: an early screenshot shows the default theme.
278+
!themeApplied ||
279+
!isShowcaseNativeContentReady({ scene, themeId, renderSignal })
223280
) {
224281
setReadyScene(null);
225282
return;
226283
}
227-
// Review owns its readiness marker because route activation happens before
228-
// the VCS request is parsed and the native diff surface is mounted.
229-
if (scene === "review") {
230-
setReadyScene(null);
231-
return;
232-
}
233284
if (scene === "terminal") Keyboard.dismiss();
234285

235286
let renderFrame: number | null = null;
@@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
247298
if (renderFrame !== null) cancelAnimationFrame(renderFrame);
248299
if (readyFrame !== null) cancelAnimationFrame(readyFrame);
249300
};
250-
}, [hasFixture, orientationSettled, requestedScene, scene]);
301+
}, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]);
251302

252303
if (!SHOWCASE_ENABLED || readyScene === null) return null;
253304

‎apps/mobile/src/features/showcase/nativeShowcaseScene.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { requireOptionalNativeModule } from "expo";
22

3+
import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme";
4+
35
export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
46
export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
57

@@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape";
810
interface NativeShowcaseControls {
911
readonly getShowcasePairingUrl?: () => string | null;
1012
readonly getShowcaseScene?: () => string | null;
13+
readonly getShowcaseTheme?: () => string | null;
1114
readonly getShowcaseOrientation?: () => string | null;
1215
readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>;
1316
readonly getInterfaceOrientation?: () => Promise<string>;
@@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null {
5659
}
5760
}
5861

62+
/**
63+
* Returns null when the runner requested no palette, which leaves the stored
64+
* theme preference untouched. An unknown id also reads as null rather than
65+
* silently falling back, so a capture never claims to show a theme it does not.
66+
*/
67+
export function getNativeShowcaseTheme(): MobileThemeId | null {
68+
try {
69+
const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim();
70+
return MOBILE_THEME_IDS.find((candidate) => candidate === theme) ?? null;
71+
} catch {
72+
return null;
73+
}
74+
}
75+
5976
export function prepareNativeShowcaseCapture(): void {
6077
try {
6178
nativeShowcaseControls()?.prepareShowcaseCapture?.();

0 commit comments

Comments
 (0)