diff --git a/docs/superpowers/plans/2026-04-21-lightbox-refresh.md b/docs/superpowers/plans/2026-04-21-lightbox-refresh.md new file mode 100644 index 0000000000..3718fcc89b --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-lightbox-refresh.md @@ -0,0 +1,1042 @@ +# 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 to `src/state/lightbox.tsx`) +- Delete: `src/state/lightbox.tsx` +- Modify: 7 consumers listed below + +- [ ] **Step 1: Move the file with git mv** + +```bash +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:5` +- `src/view/com/lightbox/Lightbox.web.tsx:12` +- `src/view/com/lightbox/ImageViewing/index.tsx:47` + +Same find-and-replace. + +- [ ] **Step 4: Verify compile** + +```bash +yarn typecheck +``` + +Expected: no errors referencing `#/state/lightbox`. + +- [ ] **Step 5: Commit** + +```bash +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** + +```bash +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'` or `from './@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. `ImageDefaultHeader` is 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: + +```bash +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: +```tsx +import ImageView from './ImageViewing' +``` +That path no longer resolves. Update to: +```tsx +import ImageView from '#/features/lightbox/pager/ImagePager' +``` + +- [ ] **Step 4: Verify compile** + +```bash +yarn typecheck +``` + +Expected: no errors. If errors mention missing `@types` or `ImageViewing`, fix the relative path and re-run. + +- [ ] **Step 5: Commit** + +```bash +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** + +```bash +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: + +```tsx +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. + +```tsx +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: + +```tsx +import {Lightbox} from '#/view/com/lightbox/Lightbox' +``` + +to: + +```tsx +import {Lightbox} from '#/features/lightbox' +``` + +- [ ] **Step 5: Remove the now-empty old directory** + +```bash +# 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: + +```bash +find src/view/com/lightbox -type d -empty -delete +``` + +- [ ] **Step 6: Verify compile and run the web build briefly** + +```bash +yarn typecheck +yarn lint src/features/lightbox src/view/shell +``` + +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +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** + +```bash +yarn typecheck +yarn lint +``` + +Expected: clean (or no NEW errors compared to `main`). + +- [ ] **Step 2: Run Jest** + +```bash +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** + +```bash +mkdir -p src/features/lightbox/chrome +``` + +- [ ] **Step 2: Write `CircleChromeButton.tsx`** + +```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 + 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 ( + [styles.root, pressed && styles.pressed]}> + + + ) +} + +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** + +```bash +yarn typecheck +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +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`** + +```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 ( + + + {triggerProps => { + if (triggerProps.IS_NATIVE) { + return ( + triggerProps.control.open('full')} + style={a.self_start}> + + + ) + } + return ( + + ) + }} + + + + + + {_(msg`Share image`)} + + + + {_(msg`Save image`)} + + + + ) +} +``` + +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** + +```bash +yarn typecheck +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +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`** + +```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 ( + + + + + ) +} + +const styles = StyleSheet.create({ + root: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}) +``` + +- [ ] **Step 2: Verify compile** + +```bash +yarn typecheck +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +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`** + +```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 ( + + {Array.from({length: count}).map((_, i) => ( + + ))} + + ) +} + +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** + +```bash +yarn typecheck +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +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`** + +```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 ( + + {altText ? ( + + { + isMomentumScrolling.current = true + }} + onMomentumScrollEnd={() => { + isMomentumScrolling.current = false + }} + contentContainerStyle={[a.px_md, a.py_sm]}> + { + if (isMomentumScrolling.current) return + LayoutAnimation.configureNext({ + duration: 450, + update: {type: 'spring', springDamping: 1}, + }) + onToggleAltExpanded() + }}> + + {altText} + + + + + ) : null} + + + ) +} + +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** + +```bash +yarn typecheck +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +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.tsx` and find the chrome block** + +Near line 416 there is a `` with two animated children: `ImageDefaultHeader` and `LightboxFooter`. Replace both with the new `Header` and `Footer`. + +Replace this block: + +```tsx + + + + + + setIsAltExpanded(e => !e)} + onPressSave={onPressSave} + onPressShare={onPressShare} + /> + + +``` + +With: + +```tsx + + +
onPressShare(images[imageIndex].uri)} + onPressSave={() => onPressSave(images[imageIndex].uri)} + /> + + +