Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 KiB
Lightbox Refresh Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Co-locate all lightbox code under /features/lightbox/ and refresh the chrome (native = full redesign; web = light reskin).
Architecture: Phase 1 is a pure file migration. Phase 2 builds a shared circle button primitive. Phase 3 builds four new native chrome components (header, footer, menu, pager dots) and wires them into the existing pager. Phase 4 applies a light reskin on web. Phase 5 is end-to-end verification. Every phase leaves the app runnable.
Tech Stack: React Native 0.81 / Expo 54, TypeScript, ALF design system, React Compiler, Reanimated V3, Gesture Handler, Lingui for i18n. Existing #/components/ContextMenu is reused for the native menu popover; existing #/components/Menu (radix dropdown) is reused on web.
Ticket: APP-2046 · Spec: docs/superpowers/specs/2026-04-21-lightbox-refresh-design.md
Testing note: This codebase has no UI rendering tests — existing Jest tests are for pure logic only. Verification for this work is yarn typecheck + yarn lint + yarn test + manual smoke on iOS simulator, Android emulator, and web. Do NOT invent new UI unit tests; rely on type-checking and visual verification.
Phase 1 — File migration (pure moves, no behavior change)
Task 1: Move state module and update all consumers
Files:
-
Create:
src/features/lightbox/state.tsx(identical content tosrc/state/lightbox.tsx) -
Delete:
src/state/lightbox.tsx -
Modify: 7 consumers listed below
-
Step 1: Move the file with git mv
mkdir -p src/features/lightbox
git mv src/state/lightbox.tsx src/features/lightbox/state.tsx
- Step 2: Update every consumer import
Change the import path in each of these files from #/state/lightbox to #/features/lightbox/state:
| File | Line | Exports used |
|---|---|---|
src/App.native.tsx |
34 | Provider as LightboxStateProvider |
src/App.web.tsx |
27 | Provider as LightboxStateProvider |
src/state/util.ts |
4 | useLightboxControls |
src/screens/Profile/Header/Shell.tsx |
17 | useLightboxControls |
src/components/Post/Embed/ImageEmbed.tsx |
5 | useLightboxControls |
src/view/com/profile/ProfileSubpageHeader.tsx |
16 | useLightboxControls |
src/view/com/util/List.tsx |
15 | useLightbox |
Use find-and-replace (case-sensitive) across those files:
-
Find:
'#/state/lightbox' -
Replace:
'#/features/lightbox/state' -
Step 3: Update references inside the still-existing lightbox files
Two files in the old location still reference #/state/lightbox by path — update them too so Phase 1 is self-consistent:
src/view/com/lightbox/Lightbox.tsx:5src/view/com/lightbox/Lightbox.web.tsx:12src/view/com/lightbox/ImageViewing/index.tsx:47
Same find-and-replace.
- Step 4: Verify compile
yarn typecheck
Expected: no errors referencing #/state/lightbox.
- Step 5: Commit
git add -u src/features/lightbox/state.tsx src/App.native.tsx src/App.web.tsx src/state/util.ts src/screens/Profile/Header/Shell.tsx src/components/Post/Embed/ImageEmbed.tsx src/view/com/profile/ProfileSubpageHeader.tsx src/view/com/util/List.tsx src/view/com/lightbox/Lightbox.tsx src/view/com/lightbox/Lightbox.web.tsx src/view/com/lightbox/ImageViewing/index.tsx
git commit -m "refactor(lightbox): move state module to features/lightbox/state"
Task 2: Move the pager and image-item files into features/lightbox/pager/
Files:
-
src/view/com/lightbox/ImageViewing/index.tsx→src/features/lightbox/pager/ImagePager.tsx -
src/view/com/lightbox/ImageViewing/transforms.ts→src/features/lightbox/pager/transforms.ts -
src/view/com/lightbox/ImageViewing/@types/index.ts→src/features/lightbox/types.ts -
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx→src/features/lightbox/pager/ImageItem/ImageItem.tsx -
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx→src/features/lightbox/pager/ImageItem/ImageItem.ios.tsx -
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx→src/features/lightbox/pager/ImageItem/ImageItem.android.tsx -
src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx→ keep in place for now (will be deleted in Task 10 when replaced) -
Step 1: Create directories and move files
mkdir -p src/features/lightbox/pager/ImageItem
git mv src/view/com/lightbox/ImageViewing/index.tsx src/features/lightbox/pager/ImagePager.tsx
git mv src/view/com/lightbox/ImageViewing/transforms.ts src/features/lightbox/pager/transforms.ts
git mv src/view/com/lightbox/ImageViewing/@types/index.ts src/features/lightbox/types.ts
git mv src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx src/features/lightbox/pager/ImageItem/ImageItem.tsx
git mv src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx src/features/lightbox/pager/ImageItem/ImageItem.ios.tsx
git mv src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx src/features/lightbox/pager/ImageItem/ImageItem.android.tsx
- Step 2: Fix internal imports inside moved files
In src/features/lightbox/pager/ImagePager.tsx, update relative imports:
from './@types'orfrom './@types/index'→from '../types'from './transforms'stays as is (still adjacent)from './components/ImageItem/ImageItem'→from './ImageItem/ImageItem'from './components/ImageDefaultHeader'→from '../../../view/com/lightbox/ImageViewing/components/ImageDefaultHeader'(yes, that's an ugly backward relative path — it is intentional and short-lived.ImageDefaultHeaderis replaced in Task 10, at which point the import and the file both get deleted.)
In each ImageItem*.tsx, update:
from '../../@types'or similar →from '../../types'from '../../transforms'→from '../transforms'
Use grep to double-check no stale relative paths remain:
grep -rn "@types\|ImageViewing\|components/ImageItem" src/features/lightbox
Expected: no hits except the intentional ImageDefaultHeader import noted above (if any).
- Step 3: Fix the external import in
Lightbox.tsx(still in old location)
The native Lightbox.tsx wrapper at src/view/com/lightbox/Lightbox.tsx:6 imports:
import ImageView from './ImageViewing'
That path no longer resolves. Update to:
import ImageView from '#/features/lightbox/pager/ImagePager'
- Step 4: Verify compile
yarn typecheck
Expected: no errors. If errors mention missing @types or ImageViewing, fix the relative path and re-run.
- Step 5: Commit
git add -u
git commit -m "refactor(lightbox): move pager files to features/lightbox/pager"
Task 3: Move Lightbox.tsx / Lightbox.web.tsx into features/lightbox/ and update shell imports
Files:
-
src/view/com/lightbox/Lightbox.tsx→src/features/lightbox/Lightbox.tsx -
src/view/com/lightbox/Lightbox.web.tsx→src/features/lightbox/Lightbox.web.tsx -
Create:
src/features/lightbox/index.tsx(platform-neutral re-export) -
Modify:
src/view/shell/index.tsx,src/view/shell/index.web.tsx -
Step 1: Move the two Lightbox files
git mv src/view/com/lightbox/Lightbox.tsx src/features/lightbox/Lightbox.tsx
git mv src/view/com/lightbox/Lightbox.web.tsx src/features/lightbox/Lightbox.web.tsx
- Step 2: Fix the import in the moved native
Lightbox.tsx
The file previously imported from #/features/lightbox/pager/ImagePager (set in Task 2). That import remains correct — verify it still reads:
import ImageView from '#/features/lightbox/pager/ImagePager'
- Step 3: Create
src/features/lightbox/index.tsx
This file re-exports Lightbox so consumers can import from the directory root. Bundler resolves Lightbox.tsx / Lightbox.web.tsx automatically.
export {Lightbox} from './Lightbox'
- Step 4: Update shell imports
In src/view/shell/index.tsx:22 and src/view/shell/index.web.tsx:13, change:
import {Lightbox} from '#/view/com/lightbox/Lightbox'
to:
import {Lightbox} from '#/features/lightbox'
- Step 5: Remove the now-empty old directory
# The only file that should remain is ImageDefaultHeader.tsx (deleted in Task 10).
ls src/view/com/lightbox/
# Expected output:
# ImageViewing/
# with ImageViewing/components/ImageDefaultHeader.tsx as the only file inside.
If there are empty intermediate dirs (e.g. @types/, components/ImageItem/), remove them:
find src/view/com/lightbox -type d -empty -delete
- Step 6: Verify compile and run the web build briefly
yarn typecheck
yarn lint src/features/lightbox src/view/shell
Expected: clean.
- Step 7: Commit
git add -u src/features/lightbox src/view/shell/index.tsx src/view/shell/index.web.tsx
git commit -m "refactor(lightbox): move Lightbox shell to features/lightbox"
Task 4: Smoke-test the migration
No code changes. This is a pure verification checkpoint before starting the reskin.
- Step 1: Typecheck and lint full repo
yarn typecheck
yarn lint
Expected: clean (or no NEW errors compared to main).
- Step 2: Run Jest
yarn test
Expected: pass rate identical to main.
- Step 3: Visual smoke
Run the app on iOS simulator and web. Open a post with an image, tap to open lightbox, swipe between images, tap Save + Share, tap close. Confirm everything behaves identically to main — this task ends the migration phase.
No commit (verification only). If anything broke, go back and fix before proceeding.
Phase 2 — Shared chrome primitive
Task 5: Add CircleChromeButton
The reusable translucent circle button used for ••• and ✕ on both native and web.
Files:
-
Create:
src/features/lightbox/chrome/CircleChromeButton.tsx -
Step 1: Create the directory
mkdir -p src/features/lightbox/chrome
- Step 2: Write
CircleChromeButton.tsx
import {type ComponentType} from 'react'
import {Pressable, type PressableProps, StyleSheet} from 'react-native'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 36
const BG = 'rgba(0, 0, 0, 0.45)'
export function CircleChromeButton({icon: Icon, label, onPress, testID}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<Icon width={20} fill="#fff" />
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: SIZE / 2,
backgroundColor: BG,
alignItems: 'center',
justifyContent: 'center',
},
pressed: {
opacity: 0.85,
},
})
- Step 3: Verify compile
yarn typecheck
Expected: clean.
- Step 4: Commit
git add src/features/lightbox/chrome/CircleChromeButton.tsx
git commit -m "feat(lightbox): add CircleChromeButton primitive"
Phase 3 — Native chrome rebuild
Task 6: Add ImageMenu (the ••• menu wrapper)
Uses #/components/ContextMenu so the menu renders as a floating card anchored under the trigger on iOS/Android. Triggered on single tap.
Files:
-
Create:
src/features/lightbox/chrome/ImageMenu.tsx -
Step 1: Write
ImageMenu.tsx
import {Pressable} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {CircleChromeButton} from './CircleChromeButton'
type Props = {
onPressShare: () => void
onPressSave: () => void
}
export function ImageMenu({onPressShare, onPressSave}: Props) {
const {_} = useLingui()
return (
<ContextMenu.Root>
<ContextMenu.Trigger
label={_(msg`Image options`)}
contentLabel={_(msg`Image options`)}>
{triggerProps => {
if (triggerProps.IS_NATIVE) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Image options`)}
onPress={() => triggerProps.control.open('full')}
style={a.self_start}>
<CircleChromeButton
icon={DotsIcon}
label={_(msg`Image options`)}
/>
</Pressable>
)
}
return (
<CircleChromeButton
icon={DotsIcon}
label={_(msg`Image options`)}
/>
)
}}
</ContextMenu.Trigger>
<ContextMenu.Outer align="left">
<ContextMenu.Item
label={_(msg`Share image`)}
onPress={onPressShare}>
<ContextMenu.ItemIcon icon={ShareIcon} />
<ContextMenu.ItemText>{_(msg`Share image`)}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
label={_(msg`Save image`)}
onPress={onPressSave}>
<ContextMenu.ItemIcon icon={DownloadIcon} />
<ContextMenu.ItemText>{_(msg`Save image`)}</ContextMenu.ItemText>
</ContextMenu.Item>
</ContextMenu.Outer>
</ContextMenu.Root>
)
}
Note the exact API shapes are taken from src/components/dms/MessageContextMenu.tsx. If the TriggerChildProps narrowing by IS_NATIVE gives typescript trouble, cross-check that file.
- Step 2: Verify compile
yarn typecheck
Expected: clean.
- Step 3: Commit
git add src/features/lightbox/chrome/ImageMenu.tsx
git commit -m "feat(lightbox): add ImageMenu for share/save actions"
Task 7: Add Header (top chrome row)
Files:
-
Create:
src/features/lightbox/chrome/Header.tsx -
Step 1: Write
Header.tsx
import {StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {CircleChromeButton} from './CircleChromeButton'
import {ImageMenu} from './ImageMenu'
type Props = {
onRequestClose: () => void
onPressShare: () => void
onPressSave: () => void
}
export function Header({onRequestClose, onPressShare, onPressSave}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
return (
<View
style={[
styles.root,
a.flex_row,
a.justify_between,
a.align_center,
a.px_md,
{paddingTop: insets.top + 8},
]}
pointerEvents="box-none">
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
<CircleChromeButton
icon={CloseIcon}
label={_(msg`Close image`)}
onPress={onRequestClose}
/>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
})
- Step 2: Verify compile
yarn typecheck
Expected: clean.
- Step 3: Commit
git add src/features/lightbox/chrome/Header.tsx
git commit -m "feat(lightbox): add new Header with ••• menu and close"
Task 8: Add PagerDots
Files:
-
Create:
src/features/lightbox/chrome/PagerDots.tsx -
Step 1: Write
PagerDots.tsx
import {StyleSheet, View} from 'react-native'
import {atoms as a} from '#/alf'
type Props = {
count: number
activeIndex: number
}
const DOT = 6
const GAP = 4
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={[a.flex_row, a.justify_center, a.align_center, styles.row]}>
{Array.from({length: count}).map((_, i) => (
<View
key={i}
style={[
styles.dot,
i === activeIndex ? styles.active : styles.inactive,
]}
/>
))}
</View>
)
}
const styles = StyleSheet.create({
row: {
gap: GAP,
},
dot: {
width: DOT,
height: DOT,
borderRadius: DOT / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})
- Step 2: Verify compile
yarn typecheck
Expected: clean.
- Step 3: Commit
git add src/features/lightbox/chrome/PagerDots.tsx
git commit -m "feat(lightbox): add PagerDots indicator"
Task 9: Add Footer (alt text + pager dots)
Files:
-
Create:
src/features/lightbox/chrome/Footer.tsx -
Step 1: Write
Footer.tsx
import {useRef} from 'react'
import {LayoutAnimation, Pressable, ScrollView, StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {PagerDots} from './PagerDots'
type Props = {
altText: string | undefined
isAltExpanded: boolean
onToggleAltExpanded: () => void
imageCount: number
activeIndex: number
}
export function Footer({
altText,
isAltExpanded,
onToggleAltExpanded,
imageCount,
activeIndex,
}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
const isMomentumScrolling = useRef(false)
return (
<View
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
pointerEvents="box-none">
{altText ? (
<View style={[a.mx_md, a.mb_sm, styles.altWrap]}>
<ScrollView
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Expand alt text`)}
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, styles.altText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</ScrollView>
</View>
) : null}
<PagerDots count={imageCount} activeIndex={activeIndex} />
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12,
overflow: 'hidden',
},
altText: {
color: '#fff',
},
})
- Step 2: Verify compile
yarn typecheck
Expected: clean.
- Step 3: Commit
git add src/features/lightbox/chrome/Footer.tsx
git commit -m "feat(lightbox): add new Footer with alt text and pager dots"
Task 10: Wire new chrome into ImagePager.tsx and delete old chrome
Replace the two chrome blocks currently in src/features/lightbox/pager/ImagePager.tsx at lines ~417-433. Delete ImageDefaultHeader.tsx and the LightboxFooter function inside ImagePager.tsx.
Files:
-
Modify:
src/features/lightbox/pager/ImagePager.tsx -
Delete:
src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx -
Delete: the now-empty
src/view/com/lightbox/tree -
Step 1: Open
ImagePager.tsxand find the chrome block
Near line 416 there is a <View style={styles.controls}> with two animated children: ImageDefaultHeader and LightboxFooter. Replace both with the new Header and Footer.
Replace this block:
<View style={styles.controls}>
<Animated.View
style={animatedHeaderStyle}
renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={handleRequestClose} />
</Animated.View>
<Animated.View
style={animatedFooterStyle}
renderToHardwareTextureAndroid={!isAltExpanded}>
<LightboxFooter
images={images}
index={imageIndex}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setIsAltExpanded(e => !e)}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
</Animated.View>
</View>
With:
<View style={styles.controls} pointerEvents="box-none">
<Animated.View
style={animatedHeaderStyle}
pointerEvents="box-none"
renderToHardwareTextureAndroid>
<Header
onRequestClose={handleRequestClose}
onPressShare={() => onPressShare(images[imageIndex].uri)}
onPressSave={() => onPressSave(images[imageIndex].uri)}
/>
</Animated.View>
<Animated.View
style={animatedFooterStyle}
pointerEvents="box-none"
renderToHardwareTextureAndroid={!isAltExpanded}>
<Footer
altText={images[imageIndex].alt}
isAltExpanded={isAltExpanded}
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
imageCount={images.length}
activeIndex={imageIndex}
/>
</Animated.View>
</View>
- Step 2: Update imports at the top of
ImagePager.tsx
Remove:
import ImageDefaultHeader from './components/ImageDefaultHeader'
// (wherever the old path sits — or the import added in Task 2 Step 2)
Add:
import {Footer} from '../chrome/Footer'
import {Header} from '../chrome/Header'
- Step 3: Delete the
LightboxFooterfunction and its styles
Inside ImagePager.tsx, find function LightboxFooter( (~line 610) and delete the entire function plus any type imports it uniquely required (Button, FontAwesomeIcon, Trans, s, colors, ScrollView — but ONLY if they are not used elsewhere in the file; verify with grep after deletion).
Also remove unused styles: footerScrollView, footerText, footerBtns, footerBtn from the StyleSheet.create at the bottom of the file.
Also remove this line near the top:
import ImageDefaultHeader from ...
- Step 4: Delete the old
ImageDefaultHeader.tsxfile and empty dirs
git rm src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
find src/view/com/lightbox -type d -empty -delete
Expected: src/view/com/lightbox/ is gone entirely.
- Step 5: Verify compile and lint
yarn typecheck
yarn lint src/features/lightbox
Expected: clean. If typescript complains about unused imports in ImagePager.tsx, remove them.
- Step 6: Smoke test on native
Run on iOS simulator. Open lightbox. Confirm:
-
•••menu renders top-left as a translucent dark circle -
✕close renders top-right -
Tapping
•••opens a floating menu with Share image + Save image -
Tapping Share or Save calls through (may show toast / share sheet)
-
Alt text strip appears at bottom with correct translucent bg
-
Pager dots appear at very bottom when image count > 1
-
Single tap on image still toggles chrome visibility
-
Swipe-down-to-dismiss still works
-
Pinch/zoom still works
-
Step 7: Commit
git add -u
git commit -m "feat(lightbox): replace native chrome with new header/footer"
Phase 4 — Web chrome reskin
Task 11: Swap web chrome buttons to CircleChromeButton
Files:
-
Modify:
src/features/lightbox/Lightbox.web.tsx -
Step 1: Locate the menu trigger (~line 251-296) and close button (~line 297-312)
Open src/features/lightbox/Lightbox.web.tsx. Find the existing Menu.Trigger that renders the ••• button, and the existing close Button.
- Step 2: Replace the menu trigger's visual child with
CircleChromeButton
Add imports at the top of the file:
import {CircleChromeButton} from './chrome/CircleChromeButton'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
Replace the existing Menu.Trigger render child. The radix Menu.Trigger passes props to be spread on a Pressable-like element. Wrap CircleChromeButton in a Pressable that takes those props:
<Menu.Trigger label={_(msg`Image options`)}>
{({props}) => (
<Pressable {...props} accessibilityLabel={_(msg`Image options`)}>
<CircleChromeButton icon={DotsIcon} label={_(msg`Image options`)} />
</Pressable>
)}
</Menu.Trigger>
The nested Pressable pattern matches what src/components/dms/MessageContextMenu.tsx does (radix gives us a button-like target, we render inside it). The inner CircleChromeButton's Pressable won't hijack gestures because its own onPress is undefined — it's purely visual here.
- Step 3: Replace the close button visually
Find the close Button at lines ~297-312. Replace with:
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
<CircleChromeButton
icon={CloseIcon}
label={_(msg`Close image`)}
onPress={onClose}
/>
(The existing onClose handler and a11y labels already exist in the file — reuse them.)
- Step 4: Verify compile and visual
yarn typecheck
yarn web
Open a post with an image in the browser, open lightbox, confirm buttons match native's translucent dark grey treatment. Verify left/right chevron navigation still works. Verify menu still opens on ••• click.
- Step 5: Commit
git add -u
git commit -m "feat(lightbox): reskin web chrome buttons to match native"
Task 12: Add Share image menu item on web
Files:
-
Modify:
src/features/lightbox/Lightbox.web.tsx -
Step 1: Locate the existing
Menu.Itemlist
In the same region as Task 11 (around the menu trigger), there's a Menu.Outer with a single Menu.Item labeled "Download image" (or similar). Find it.
- Step 2: Add a
Share imageitem above or belowDownload image
Add imports at the top if missing:
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
Add the new item:
<Menu.Item
label={_(msg`Share image`)}
onPress={async () => {
const uri = images[imageIndex].uri
if (navigator.share) {
try {
await navigator.share({url: uri})
} catch {
// user cancelled or unsupported — silently fall through
}
} else {
await navigator.clipboard.writeText(uri)
// Toast.show would be appropriate here — check if Toast is already imported
}
}}>
<Menu.ItemText>{_(msg`Share image`)}</Menu.ItemText>
<Menu.ItemIcon icon={ShareIcon} />
</Menu.Item>
Adapt variable names (images, imageIndex) to whatever the existing web component uses — check the file for the current active-index variable name. If the file already has a Toast import, use it on the clipboard fallback.
- Step 3: Verify
yarn typecheck
yarn web
In browser: open lightbox, click •••, confirm menu shows both Share image and Download image. Click Share — confirm native share sheet prompt (on supported browsers) or clipboard fallback.
- Step 4: Commit
git add -u
git commit -m "feat(lightbox): add Share image menu item on web"
Task 13: Restyle web alt-text strip
Files:
-
Modify:
src/features/lightbox/Lightbox.web.tsx -
Step 1: Find the alt text block (~line 227-245 in the original file)
Currently wrapped in a View with t.atoms.bg (theme bg). Replace with translucent dark treatment matching native.
- Step 2: Update the wrapping View's style
Find:
<View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
Replace the theme bg with the translucent black background and keep the fade-in animation:
<View style={[a.px_4xl, a.py_2xl, {backgroundColor: 'rgba(0, 0, 0, 0.45)'}, delayedFadeInAnim]}>
And update the text color on the inner <Text> to white. If the existing Text relies on t.atoms.text, change it to {color: '#fff'}.
- Step 3: Verify
yarn typecheck
yarn web
Open a lightbox on an image with alt text; confirm strip reads white-on-translucent-black and expand/collapse still works.
- Step 4: Commit
git add -u
git commit -m "feat(lightbox): restyle web alt-text strip to match"
Phase 5 — Final verification
Task 14: Full verification pass
No new commits — pure QA.
- Step 1: Full type + lint + test
yarn typecheck && yarn lint && yarn test
Expected: exit code 0 on all three.
- Step 2: iOS simulator smoke
Open a single-image post → lightbox → tap image to toggle chrome → tap ••• → menu appears anchored top-left → Share image → system share sheet → dismiss → Save image → confirm save → close via ✕.
Open a multi-image post → confirm pager dots appear bottom-center → swipe between images → confirm dots update → swipe down to dismiss.
Open an image with alt text → confirm strip appears with translucent bg → tap to expand → tap to collapse.
- Step 3: Android emulator smoke
Same checklist as Step 2.
- Step 4: Web smoke
Open lightbox in browser → verify ••• and ✕ buttons match native translucent style → verify chevron nav still works → verify Share + Download both in menu → verify alt text strip → verify keyboard esc to close still works.
- Step 5: If everything passes, the plan is complete
Push the branch and open a PR referencing APP-2046 with a brief summary linking back to the spec.
Cross-task references
- CircleChromeButton (Task 5) is used in: Header (Task 7), ImageMenu (Task 6), Lightbox.web.tsx (Task 11).
- Header (Task 7) and Footer (Task 9) are wired into ImagePager via Task 10.
- ImageMenu (Task 6) depends on CircleChromeButton (Task 5).
- PagerDots (Task 8) is consumed by Footer (Task 9).
- State module path set in Task 1 is referenced by Lightbox.tsx and Lightbox.web.tsx (after their moves in Task 3).
Tasks within a phase must be done in order; phases must be done in order.