diff --git a/.superpowers/brainstorm/71603-1776195983/content/category-design.html b/.superpowers/brainstorm/71603-1776195983/content/category-design.html new file mode 100644 index 0000000000..cb75e508b5 --- /dev/null +++ b/.superpowers/brainstorm/71603-1776195983/content/category-design.html @@ -0,0 +1,167 @@ +

GIF Category Pills — Full Design

+

Horizontal scrollable pill row on the idle GIF picker screen

+ +
+

Interaction Flow

+
+ +
+
+
1. Idle — "Trending" active
+
+
+ 🔍 + Search GIFs +
+
+
🔥 Trending
+
❤️ Love
+
😄 Happy
+
...
+
+
+
+
+
+
Featured GIFs
+
+
+
+ +
+
+
2. "Happy" tapped
+
+
+ 🔍 + Search GIFs +
+
+
🔥 Trending
+
❤️ Love
+
😄 Happy
+
...
+
+
+
+
+
+
Search results for "happy"
+
+
+
+ +
+
+
3. Typing — pills hidden
+
+
+ 🔍 + sponge| +
+
+
+
+
+
+
+
+
+
+
+
Search results
+
+
+
+
+
+ +
+

Architecture

+
+
Component & Data Flow
+
+
+
+
Component Tree
+
+
GifPickerBody
+
├─ GifPickerHeader
+
│ └─ TextField.Input
+
├─ GifCategoryPills ← NEW
+
(hidden when typing)
+
├─ GifPickerPlaceholder
+
└─ GifPickerGrid
+
+
+
+
Data Flow
+
+
1. Idle → "Trending" active → featured endpoint
+
2. Tap pill → set search to pill's searchterm
+
3. useGifPickerData sees search → fires GIF search
+
4. User types → pills hide, search takes over
+
5. User clears input → pills reappear, "Trending" resets
+
+
+
+
+
+
+ +
+

File Changes

+
+
+ src/features/gifPicker/components/GifCategoryPills.tsx — pill row component
+
~ src/features/gifPicker/GifPickerDialog.tsx — add category state, render pills in header, connect to search
+
+
+ +
+

Curated Categories

+
+
🔥 Trending
+
❤️ Love
+
😄 Happy
+
😢 Sad
+
🎉 Party
+
👍 Yes
+
😂 LOL
+
🤩 Excited
+
+
+ +
+

Key Design Decisions

+
+
No new API endpoint needed — tapping a category pill just sets the search query to the category's searchterm and uses the existing search flow
+
"Trending" = featured endpoint — the default active pill uses the existing featured GIFs, no search query
+
Pills hide while typing — simple visibility toggle based on whether rawSearch has content
+
Clearing input resets to Trending — pills reappear with "Trending" active again
+
Hardcoded category list — curated for quality, no extra API call needed
+
+
+ +
+

Scope

+
+
+

In Scope

+
    +
  • GifCategoryPills component (horizontal scroll, active state)
  • +
  • 8 curated category pills with emoji + label
  • +
  • Tap pill → search GIFs for that category
  • +
  • Pills hide when typing, reappear when cleared
  • +
  • Active pill highlight styling
  • +
+
+
+

Out of Scope

+
    +
  • Recent / Favorited (needs client-side persistence)
  • +
  • Dynamic categories from API
  • +
  • Category GIF thumbnails on the pills
  • +
+
+
+
diff --git a/.superpowers/brainstorm/71603-1776195983/content/category-layout.html b/.superpowers/brainstorm/71603-1776195983/content/category-layout.html new file mode 100644 index 0000000000..4cdcad7604 --- /dev/null +++ b/.superpowers/brainstorm/71603-1776195983/content/category-layout.html @@ -0,0 +1,106 @@ +

Category pills — where and how?

+

These appear on the idle/featured GIF screen, before the user starts typing

+ +
+
+
A
+
+

Horizontal scrollable row of text pills

+

A single row of pill buttons below the search input. Scrolls horizontally. Compact, leaves maximum space for the GIF grid. Like how Threads does it.

+
+
Text pills — horizontal scroll
+
+
+ 🔍 + Search GIFs +
+
+
🔥 Trending
+
❤️ Love
+
😄 Happy
+
😢 Sad
+
🎉 Party
+
👍 Yes
+
😭 LOL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
B
+
+

Grid of GIF thumbnail tiles with labels

+

A 2-3 column grid of category tiles, each showing the category's preview GIF with a text label overlay. More visual, but takes up more space and replaces the featured GIFs initially.

+
+
GIF thumbnail grid
+
+
+ 🔍 + Search GIFs +
+
+
🔥 Trending
+
❤️ Love
+
😄 Happy
+
😢 Sad
+
🎉 Party
+
👍 Yes
+
+
+
+
+
+ +
+
C
+
+

Horizontal pills + featured GIFs below (Threads-style)

+

Exactly like Threads: category pills as a scrollable row, with featured/trending GIFs filling the grid below. Tapping a pill replaces the grid with results for that category. Best of both worlds.

+
+
Pills + featured grid (Threads)
+
+
+ 🔍 + Search GIFs +
+
+
🔥 Trending
+
❤️ Love
+
😄 Happy
+
😢 Sad
+
🎉 Party
+
👍 Yes
+
+
+
+
+
+
+
+
+
+
+
+
Featured / Trending GIFs
+
+
+
+
+
+ +
+

My recommendation: Option C

+

Matches the Threads pattern exactly. Pills provide quick category access, the featured GIF grid fills the space below. "Trending" is the default selected pill, tapping another pill swaps the grid to that category's results. When the user starts typing, the pills hide and the search takes over.

+
diff --git a/.superpowers/brainstorm/71603-1776195983/content/waiting.html b/.superpowers/brainstorm/71603-1776195983/content/waiting.html new file mode 100644 index 0000000000..f92c257acc --- /dev/null +++ b/.superpowers/brainstorm/71603-1776195983/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/71603-1776195983/state/server-stopped b/.superpowers/brainstorm/71603-1776195983/state/server-stopped new file mode 100644 index 0000000000..11ee5cd47e --- /dev/null +++ b/.superpowers/brainstorm/71603-1776195983/state/server-stopped @@ -0,0 +1 @@ +{"reason":"owner process exited","timestamp":1776198924022} diff --git a/.superpowers/brainstorm/71603-1776195983/state/server.log b/.superpowers/brainstorm/71603-1776195983/state/server.log new file mode 100644 index 0000000000..34250c0795 --- /dev/null +++ b/.superpowers/brainstorm/71603-1776195983/state/server.log @@ -0,0 +1,6 @@ +{"type":"server-started","port":53072,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:53072","screen_dir":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/71603-1776195983/content","state_dir":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/71603-1776195983/state"} +{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/71603-1776195983/content/category-layout.html"} +{"source":"user-event","type":"click","text":"C\n \n Horizontal pills + featured GIFs below (Threads-style)\n Exactly like Threads: category pills as a scrollable row, with featured/trending GIFs filling the grid below. Tapping a pill replaces the grid with results for that category. Best of both worlds.\n \n Pills + featured grid (Threads)\n \n \n 🔍\n Search GIFs\n \n \n 🔥 Trending\n ❤️ Love\n 😄 Happy\n 😢 Sad\n 🎉 Party\n 👍 Yes\n \n \n \n \n \n \n \n \n \n \n \n Featured / Trending GIFs","choice":"c","id":null,"timestamp":1776196240353} +{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/71603-1776195983/content/waiting.html"} +{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/71603-1776195983/content/category-design.html"} +{"type":"server-stopped","reason":"owner process exited"} diff --git a/.superpowers/brainstorm/71603-1776195983/state/server.pid b/.superpowers/brainstorm/71603-1776195983/state/server.pid new file mode 100644 index 0000000000..9f961dcb59 --- /dev/null +++ b/.superpowers/brainstorm/71603-1776195983/state/server.pid @@ -0,0 +1 @@ +71612 diff --git a/docs/superpowers/plans/2026-04-14-gif-category-pills.md b/docs/superpowers/plans/2026-04-14-gif-category-pills.md new file mode 100644 index 0000000000..0640b2785e --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-gif-category-pills.md @@ -0,0 +1,315 @@ +# GIF Category Pills 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:** Add a horizontal scrollable row of category pills (Trending, Love, Happy, Sad, etc.) to the GIF picker's idle screen so users can browse GIFs by emotion/reaction without typing. + +**Architecture:** A new `GifCategoryPills` component renders a horizontal `ScrollView` of `Pressable` pills. `GifPickerDialog` manages `activeCategory` state — when a non-trending pill is tapped, its searchterm is passed to the existing `useGifPickerData` hook. Pills hide when the user types and reappear when the input is cleared. + +**Tech Stack:** React Native, TypeScript, ALF design system, Lingui (i18n) + +**Spec:** `docs/superpowers/specs/2026-04-14-gif-category-pills-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/features/gifPicker/components/GifCategoryPills.tsx` | Create | Horizontal scrollable pill row with active state | +| `src/features/gifPicker/GifPickerDialog.tsx` | Modify | Add category state, render pills, connect to search | + +--- + +### Task 1: Create `GifCategoryPills` Component + +**Files:** +- Create: `src/features/gifPicker/components/GifCategoryPills.tsx` + +- [ ] **Step 1: Create the component** + +Create `src/features/gifPicker/components/GifCategoryPills.tsx`: + +```tsx +import {ScrollView, View} from 'react-native' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' + +export type GifCategory = { + id: string + emoji: string + label: ReturnType + searchterm: string | null // null = trending (uses featured endpoint) +} + +export const GIF_CATEGORIES: readonly GifCategory[] = [ + {id: 'trending', emoji: '🔥', label: msg`Trending`, searchterm: null}, + {id: 'love', emoji: '❤️', label: msg`Love`, searchterm: 'love'}, + {id: 'happy', emoji: '😄', label: msg`Happy`, searchterm: 'happy'}, + {id: 'sad', emoji: '😢', label: msg`Sad`, searchterm: 'cry'}, + {id: 'party', emoji: '🎉', label: msg`Party`, searchterm: 'congratulations'}, + {id: 'yes', emoji: '👍', label: msg`Yes`, searchterm: 'yes'}, + {id: 'lol', emoji: '😂', label: msg`LOL`, searchterm: 'lol'}, + {id: 'excited', emoji: '🤩', label: msg`Excited`, searchterm: 'excited'}, +] as const + +export function GifCategoryPills({ + activeId, + onSelect, +}: { + activeId: string + onSelect: (category: GifCategory) => void +}) { + const {_} = useLingui() + const t = useTheme() + + return ( + + + {GIF_CATEGORIES.map(category => { + const isActive = category.id === activeId + return ( + + ) + })} + + + ) +} +``` + +Notes on the implementation: +- Uses the existing `Button` component with `variant="solid"` for active and `variant="outline"` for inactive, matching ALF patterns +- `shape="default"` gives pill-shaped buttons (the default shape is a pill/rounded) +- `ScrollView` with `horizontal` and `showsHorizontalScrollIndicator={false}` +- `px_xl` padding matches the GIF grid's `contentContainerStyle` on native +- The `GIF_CATEGORIES` array and `GifCategory` type are exported so `GifPickerDialog` can reference them +- `searchterm: null` for trending means "use the featured endpoint" — handled by the consumer +- Emoji is wrapped in `` per codebase convention for emoji rendering + +- [ ] **Step 2: Verify typecheck passes** + +Run: `yarn typecheck` +Expected: No new errors + +- [ ] **Step 3: Commit** + +```bash +git add src/features/gifPicker/components/GifCategoryPills.tsx +git commit -m "feat(gif): add GifCategoryPills component" +``` + +--- + +### Task 2: Wire Category Pills into `GifPickerDialog` + +**Files:** +- Modify: `src/features/gifPicker/GifPickerDialog.tsx` + +- [ ] **Step 1: Add imports** + +At the top of `src/features/gifPicker/GifPickerDialog.tsx`, add after the existing imports: + +```ts +import { + GIF_CATEGORIES, + GifCategoryPills, + type GifCategory, +} from '#/features/gifPicker/components/GifCategoryPills' +``` + +- [ ] **Step 2: Replace `GifPickerBody`** + +Replace the entire `GifPickerBody` function (lines 56-140) with: + +```tsx +function GifPickerBody({ + control, + onSelectGif, +}: { + control: Dialog.DialogControlProps + onSelectGif: (gif: Gif) => void +}) { + const {gtMobile} = useBreakpoints() + const textInputRef = useRef(null) + const listRef = useRef(null) + const [rawSearch, setRawSearch] = useState('') + const [activeCategory, setActiveCategory] = useState('trending') + const search = useThrottledValue(rawSearch, 750) + + // Determine the effective search query: + // - If user is typing, use the typed text + // - If a non-trending category is active, use its searchterm + // - Otherwise (trending), empty string triggers the featured endpoint + const activeCategorySearchterm = + GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? '' + const effectiveSearch = search.length > 0 ? search : activeCategorySearchterm + + const { + data, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + error, + isPending, + isError, + isSearching, + refetch, + } = useGifPickerData(effectiveSearch) + + const items = data?.pages.flatMap(page => page.results) ?? [] + const hasData = items.length > 0 + + const onEndReached = () => { + if (isFetchingNextPage || !hasNextPage || error) return + void fetchNextPage() + } + + const onGoBack = () => { + if (isSearching) { + textInputRef.current?.clear() + setRawSearch('') + setActiveCategory('trending') + } else { + control.close() + } + } + + const onChangeSearch = (text: string) => { + setRawSearch(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + } + + const onSelectCategory = (category: GifCategory) => { + setActiveCategory(category.id) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + } + + const showPills = rawSearch.length === 0 + + const header = ( + <> + control.close()} + onEscape={() => control.close()} + /> + {showPills && ( + + )} + {!hasData && ( + + )} + + ) + + return ( + <> + {gtMobile && } + + + ) +} +``` + +Key changes from the current version: +- Added `activeCategory` state (defaults to `'trending'`) +- Added `effectiveSearch` that resolves the search query from either typed text or the active category's searchterm +- Added `onSelectCategory` handler +- Added `showPills` flag — `true` when `rawSearch` is empty +- Render `GifCategoryPills` between the header and placeholder, conditionally on `showPills` +- `onGoBack` now also resets `activeCategory` to `'trending'` +- `GifPickerPlaceholder` receives `effectiveSearch` instead of `search` so error messages reflect the actual query + +- [ ] **Step 3: Verify typecheck passes** + +Run: `yarn typecheck` +Expected: No new errors + +- [ ] **Step 4: Verify lint passes** + +Run: `yarn lint` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add src/features/gifPicker/GifPickerDialog.tsx +git commit -m "feat(gif): wire category pills into GIF picker dialog" +``` + +--- + +### Task 3: Manual Testing + +- [ ] **Step 1: Test on web (CORS-disabled Chrome) or iOS simulator** + +1. Open the composer and click the GIF button +2. Verify the pill row appears below the search input with "Trending" highlighted +3. Verify featured GIFs load in the grid below + +- [ ] **Step 2: Test category selection** + +1. Tap "Happy" — verify the pill highlights, grid shows happy GIFs +2. Tap "Sad" — verify it switches, grid shows sad GIFs +3. Tap "Trending" — verify it goes back to featured GIFs +4. Tap several pills quickly — verify no visual glitches + +- [ ] **Step 3: Test interaction with search** + +1. Start typing in the search box — verify pills hide +2. Clear the search box — verify pills reappear with "Trending" active +3. Select a category, then type — verify pills hide and search results show +4. Clear again — verify pills return with "Trending" (not the previously selected category) + +- [ ] **Step 4: Test pill scrolling** + +1. On a narrow screen (mobile or narrow browser), verify the pill row scrolls horizontally +2. Verify all 8 pills are reachable by scrolling +3. Verify no horizontal scroll indicator is visible + +- [ ] **Step 5: Commit any fixes from testing** + +```bash +git add -A +git commit -m "fix(gif): address issues found during category pills testing" +``` diff --git a/src/features/gifPicker/hooks/useGifAutocomplete.ts b/src/features/gifPicker/hooks/useGifAutocomplete.ts index 0ee217cb34..d6af1f59cf 100644 --- a/src/features/gifPicker/hooks/useGifAutocomplete.ts +++ b/src/features/gifPicker/hooks/useGifAutocomplete.ts @@ -28,7 +28,8 @@ export function useGifAutocomplete({ onSelectSuggestion: (text: string) => void }): GifAutocompleteState { const ax = useAnalytics() - const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable) + // TODO: revert — hardcoded for local Klipy testing + const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable) const [rawText, setRawText] = useState('') const [dismissed, setDismissed] = useState(false) diff --git a/src/features/gifPicker/hooks/useGifPickerData.ts b/src/features/gifPicker/hooks/useGifPickerData.ts index 44898868f3..fa0d58da52 100644 --- a/src/features/gifPicker/hooks/useGifPickerData.ts +++ b/src/features/gifPicker/hooks/useGifPickerData.ts @@ -21,7 +21,8 @@ export function useGifPickerData( {enabled = true}: {enabled?: boolean} = {}, ) { const ax = useAnalytics() - const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable) + // TODO: revert — hardcoded for local Klipy testing + const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable) const isSearching = query.length > 0 const provider: GifPickerProvider = useKlipy ? 'klipy' : 'tenor'