From b1fded493f354fcf70e8cd3cb3ef8b9fd7b32dd6 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Wed, 15 Apr 2026 14:24:14 -0400 Subject: [PATCH] remove giant superpowers files --- .../plans/2026-04-14-gif-autocomplete.md | 735 ------------------ .../plans/2026-04-14-gif-category-pills.md | 315 -------- .../2026-04-14-gif-autocomplete-design.md | 192 ----- .../2026-04-14-gif-category-pills-design.md | 125 --- 4 files changed, 1367 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-14-gif-autocomplete.md delete mode 100644 docs/superpowers/plans/2026-04-14-gif-category-pills.md delete mode 100644 docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md delete mode 100644 docs/superpowers/specs/2026-04-14-gif-category-pills-design.md diff --git a/docs/superpowers/plans/2026-04-14-gif-autocomplete.md b/docs/superpowers/plans/2026-04-14-gif-autocomplete.md deleted file mode 100644 index 488ae15f5a..0000000000 --- a/docs/superpowers/plans/2026-04-14-gif-autocomplete.md +++ /dev/null @@ -1,735 +0,0 @@ -# GIF Autocomplete 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 inline autocomplete suggestions to the GIF picker dialog using Klipy's `/v2/autocomplete` endpoint, so users see search term completions as they type. - -**Architecture:** A new `useKlipyAutocompleteQuery` hook fetches string suggestions from Klipy with a 200ms throttle. A `useGifAutocomplete` orchestration hook manages visibility and keyboard state. `GifAutocompleteSuggestions` renders an inline list below the search input inside `GifPickerHeader`, with ARIA attributes and web keyboard navigation. - -**Tech Stack:** React Native, TypeScript, TanStack Query, Lingui (i18n), ALF design system - -**Spec:** `docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md` - ---- - -## File Map - -| File | Action | Responsibility | -|------|--------|----------------| -| `src/lib/constants.ts` | Modify | Add `GIF_KLIPY_AUTOCOMPLETE` endpoint constant | -| `src/state/queries/klipy.ts` | Modify | Add `fetchKlipyAutocomplete` function and `useKlipyAutocompleteQuery` hook | -| `src/features/gifPicker/hooks/useGifAutocomplete.ts` | Create | Orchestration hook: 200ms throttle, visibility flag, keyboard nav state | -| `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx` | Create | Inline suggestion list UI with ARIA and keyboard highlight | -| `src/features/gifPicker/components/GifPickerHeader.tsx` | Modify | Render `GifAutocompleteSuggestions` below search input, wire keyboard events | -| `src/features/gifPicker/GifPickerDialog.tsx` | Modify | Wire `useGifAutocomplete` into `GifPickerBody`, connect to search state | - ---- - -### Task 1: Add Klipy Autocomplete Endpoint Constant - -**Files:** -- Modify: `src/lib/constants.ts:181-184` - -- [ ] **Step 1: Add the endpoint constant** - -In `src/lib/constants.ts`, add the autocomplete URL builder after the existing `GIF_KLIPY_FEATURED` constant (after line 184): - -```ts -export const GIF_KLIPY_AUTOCOMPLETE = (params: string) => - `${GIF_SERVICE}/klipy/v2/autocomplete?${params}` -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `yarn typecheck` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add src/lib/constants.ts -git commit -m "feat(gif): add GIF_KLIPY_AUTOCOMPLETE endpoint constant" -``` - ---- - -### Task 2: Add `useKlipyAutocompleteQuery` Hook - -**Files:** -- Modify: `src/state/queries/klipy.ts` - -- [ ] **Step 1: Add imports and query key** - -At the top of `src/state/queries/klipy.ts`, update the imports: - -Change: -```ts -import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' - -import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants' -``` - -To: -```ts -import { - keepPreviousData, - useInfiniteQuery, - useQuery, -} from '@tanstack/react-query' - -import { - GIF_KLIPY_AUTOCOMPLETE, - GIF_KLIPY_FEATURED, - GIF_KLIPY_SEARCH, -} from '#/lib/constants' -import {STALE} from '#/state/queries' -``` - -After the existing `RQKEY_SEARCH` line (line 11), add: - -```ts -export const RQKEY_AUTOCOMPLETE = (query: string) => [ - RQKEY_ROOT, - 'autocomplete', - query, -] -``` - -- [ ] **Step 2: Add the fetch function** - -After line 14 (`const searchGifs = ...`), add the autocomplete fetcher. This is a standalone function (not using `createKlipyApi`) because the autocomplete endpoint has a different response shape (string array, not GIF objects) and different parameters (limit=8, no contentfilter): - -```ts -async function fetchKlipyAutocomplete(query: string): Promise { - const params = new URLSearchParams() - - params.set( - 'client_key', - Platform.select({ - ios: 'bluesky-ios', - android: 'bluesky-android', - default: 'bluesky-web', - }), - ) - - params.set('limit', '8') - - const locale = getLocales?.()?.[0] - if (locale) { - params.set('locale', locale.languageTag.replace('-', '_')) - } - - params.set('q', query) - - const res = await fetch(GIF_KLIPY_AUTOCOMPLETE(params.toString()), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - if (!res.ok) { - throw new Error('Failed to fetch KLIPY autocomplete API') - } - const body: KlipyAutocompleteResponse = await res.json() - return body.results -} -``` - -- [ ] **Step 3: Add the response type** - -At the bottom of the file, after the existing `KlipyResponse` type (after line 177), add: - -```ts -type KlipyAutocompleteResponse = { - locale: string - results: string[] -} -``` - -- [ ] **Step 4: Add the query hook** - -After the existing `useGifSearchQuery` function (after line 38), add: - -```ts -export function useKlipyAutocompleteQuery( - query: string, - options?: {enabled?: boolean}, -) { - return useQuery({ - queryKey: RQKEY_AUTOCOMPLETE(query), - queryFn: () => fetchKlipyAutocomplete(query), - enabled: query.length > 0 && options?.enabled !== false, - staleTime: STALE.HOURS.ONE, - }) -} -``` - -- [ ] **Step 5: Verify typecheck passes** - -Run: `yarn typecheck` -Expected: No new errors - -- [ ] **Step 6: Commit** - -```bash -git add src/state/queries/klipy.ts -git commit -m "feat(gif): add useKlipyAutocompleteQuery hook for Klipy typeahead" -``` - ---- - -### Task 3: Create `useGifAutocomplete` Orchestration Hook - -**Files:** -- Create: `src/features/gifPicker/hooks/useGifAutocomplete.ts` - -- [ ] **Step 1: Create the hook file** - -Create `src/features/gifPicker/hooks/useGifAutocomplete.ts`: - -```ts -import {useRef, useState} from 'react' - -import {useThrottledValue} from '#/components/hooks/useThrottledValue' -import {useKlipyAutocompleteQuery} from '#/state/queries/klipy' -import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' - -export type GifAutocompleteState = { - /** The suggestion strings to display */ - suggestions: string[] - /** Whether the suggestion list should be visible */ - isVisible: boolean - /** Index of the keyboard-highlighted suggestion (web only), -1 = none */ - activeIndex: number - /** Call when the user selects a suggestion */ - selectSuggestion: (suggestion: string) => void - /** Call when the raw search text changes (from the input's onChangeText) */ - handleTextChange: (text: string) => void - /** Call with the key event from the search input (web only) */ - handleKeyDown: (key: string) => boolean - /** Call to dismiss suggestions (e.g. escape key) */ - dismiss: () => void -} - -export function useGifAutocomplete({ - onSelectSuggestion, -}: { - onSelectSuggestion: (text: string) => void -}): GifAutocompleteState { - const ax = useAnalytics() - const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable) - - const [rawText, setRawText] = useState('') - const [dismissed, setDismissed] = useState(false) - const justSelectedRef = useRef(false) - - const autocompleteQuery = useThrottledValue(rawText, 200) - const {data: suggestions} = useKlipyAutocompleteQuery(autocompleteQuery, { - enabled: useKlipy && !justSelectedRef.current, - }) - - const [activeIndex, setActiveIndex] = useState(-1) - - const isVisible = - rawText.length > 0 && - !dismissed && - !justSelectedRef.current && - (suggestions?.length ?? 0) > 0 - - const handleTextChange = (text: string) => { - setRawText(text) - if (justSelectedRef.current) { - justSelectedRef.current = false - } - setDismissed(false) - setActiveIndex(-1) - } - - const selectSuggestion = (suggestion: string) => { - justSelectedRef.current = true - setRawText(suggestion) - setActiveIndex(-1) - onSelectSuggestion(suggestion) - } - - const dismiss = () => { - setDismissed(true) - setActiveIndex(-1) - } - - const handleKeyDown = (key: string): boolean => { - if (!IS_WEB || !isVisible || !suggestions?.length) return false - - switch (key) { - case 'ArrowDown': { - setActiveIndex(i => (i + 1) % suggestions.length) - return true - } - case 'ArrowUp': { - setActiveIndex(i => - i <= 0 ? suggestions.length - 1 : i - 1, - ) - return true - } - case 'Enter': { - if (activeIndex >= 0 && activeIndex < suggestions.length) { - selectSuggestion(suggestions[activeIndex]) - return true - } - return false - } - case 'Escape': { - dismiss() - return true - } - default: - return false - } - } - - return { - suggestions: suggestions ?? [], - isVisible, - activeIndex, - selectSuggestion, - handleTextChange, - handleKeyDown, - dismiss, - } -} -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `yarn typecheck` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add src/features/gifPicker/hooks/useGifAutocomplete.ts -git commit -m "feat(gif): add useGifAutocomplete orchestration hook" -``` - ---- - -### Task 4: Create `GifAutocompleteSuggestions` Component - -**Files:** -- Create: `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx` - -- [ ] **Step 1: Create the component** - -Create `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx`: - -```tsx -import {Pressable, View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' - -import {atoms as a, useTheme, web} from '#/alf' -import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' -import {Text} from '#/components/Typography' - -const LISTBOX_ID = 'gif-autocomplete-listbox' - -export function suggestionItemId(index: number) { - return `gif-autocomplete-option-${index}` -} - -export {LISTBOX_ID as GIF_AUTOCOMPLETE_LISTBOX_ID} - -export function GifAutocompleteSuggestions({ - suggestions, - activeIndex, - onSelect, -}: { - suggestions: string[] - activeIndex: number - onSelect: (suggestion: string) => void -}) { - const {_} = useLingui() - const t = useTheme() - - if (suggestions.length === 0) return null - - return ( - - {suggestions.map((suggestion, index) => { - const isActive = index === activeIndex - return ( - onSelect(suggestion)} - style={state => [ - a.flex_row, - a.align_center, - a.gap_sm, - a.px_md, - a.py_sm, - (isActive || ('hovered' in state && state.hovered)) && - t.atoms.bg_contrast_25, - ]}> - - - {suggestion} - - - ) - })} - - ) -} -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `yarn typecheck` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add src/features/gifPicker/components/GifAutocompleteSuggestions.tsx -git commit -m "feat(gif): add GifAutocompleteSuggestions inline list component" -``` - ---- - -### Task 5: Wire Autocomplete into `GifPickerHeader` - -**Files:** -- Modify: `src/features/gifPicker/components/GifPickerHeader.tsx` - -- [ ] **Step 1: Update the component** - -Replace the entire contents of `src/features/gifPicker/components/GifPickerHeader.tsx` with: - -```tsx -import {type Ref} from 'react' -import {type TextInput, View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' - -import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import * as TextField from '#/components/forms/TextField' -import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' -import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' -import {IS_WEB} from '#/env' -import {type GifAutocompleteState} from '#/features/gifPicker/hooks/useGifAutocomplete' -import { - GIF_AUTOCOMPLETE_LISTBOX_ID, - GifAutocompleteSuggestions, - suggestionItemId, -} from '#/features/gifPicker/components/GifAutocompleteSuggestions' - -export function GifPickerHeader({ - inputRef, - onChangeText, - onClose, - onEscape, - autocomplete, -}: { - inputRef: Ref - onChangeText: (text: string) => void - onClose: () => void - onEscape: () => void - autocomplete: GifAutocompleteState -}) { - const {_} = useLingui() - const t = useTheme() - const {gtMobile} = useBreakpoints() - - return ( - - - {!gtMobile && IS_WEB && ( - - )} - - - - { - if (nativeEvent.key === 'Escape') { - if (!autocomplete.handleKeyDown('Escape')) { - onEscape() - } - } else { - autocomplete.handleKeyDown(nativeEvent.key) - } - }} - // @ts-ignore web-only ARIA props - role={autocomplete.isVisible ? 'combobox' : undefined} - aria-controls={ - autocomplete.isVisible - ? GIF_AUTOCOMPLETE_LISTBOX_ID - : undefined - } - aria-expanded={autocomplete.isVisible} - aria-autocomplete={autocomplete.isVisible ? 'list' : undefined} - aria-activedescendant={ - autocomplete.isVisible && autocomplete.activeIndex >= 0 - ? suggestionItemId(autocomplete.activeIndex) - : undefined - } - /> - - - - {autocomplete.isVisible && ( - - )} - - ) -} -``` - -Key changes from the original: -- Added `autocomplete` prop of type `GifAutocompleteState` -- Wrapped the search row and suggestion list in a single `View` (the outer `View` no longer has `a.flex_row` / `a.align_center` — those moved to an inner `View` so suggestions render below) -- `onKeyPress` now delegates to `autocomplete.handleKeyDown` first; Escape is handled by autocomplete if suggestions are visible, otherwise falls through to `onEscape` -- Added ARIA attributes to the input when suggestions are visible -- Renders `GifAutocompleteSuggestions` below the input when `autocomplete.isVisible` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `yarn typecheck` -Expected: Errors in `GifPickerDialog.tsx` because `GifPickerHeader` now requires the `autocomplete` prop. This is expected and will be fixed in Task 6. - -- [ ] **Step 3: Commit** - -```bash -git add src/features/gifPicker/components/GifPickerHeader.tsx -git commit -m "feat(gif): wire autocomplete suggestions into GifPickerHeader" -``` - ---- - -### Task 6: Wire Everything into `GifPickerDialog` - -**Files:** -- Modify: `src/features/gifPicker/GifPickerDialog.tsx` - -- [ ] **Step 1: Update GifPickerBody** - -Replace the `GifPickerBody` function in `src/features/gifPicker/GifPickerDialog.tsx` (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 search = useThrottledValue(rawSearch, 500) - - const autocomplete = useGifAutocomplete({ - onSelectSuggestion: text => { - setRawSearch(text) - // Set the TextInput's displayed value to match - textInputRef.current?.setNativeProps({text}) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }, - }) - - const { - data, - fetchNextPage, - isFetchingNextPage, - hasNextPage, - error, - isPending, - isError, - isSearching, - refetch, - } = useGifPickerData(search) - - 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('') - } else { - control.close() - } - } - - const onChangeSearch = (text: string) => { - setRawSearch(text) - autocomplete.handleTextChange(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - } - - const header = ( - <> - control.close()} - onEscape={() => control.close()} - autocomplete={autocomplete} - /> - {!hasData && ( - - )} - - ) - - return ( - <> - {gtMobile && } - - - ) -} -``` - -- [ ] **Step 2: Add the import** - -At the top of `src/features/gifPicker/GifPickerDialog.tsx`, add after the existing imports: - -```ts -import {useGifAutocomplete} from '#/features/gifPicker/hooks/useGifAutocomplete' -``` - -- [ ] **Step 3: Verify typecheck passes** - -Run: `yarn typecheck` -Expected: No 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 useGifAutocomplete into GifPickerDialog" -``` - ---- - -### Task 7: Manual Testing - -- [ ] **Step 1: Start web dev server** - -Run: `yarn web` - -- [ ] **Step 2: Test the happy path** - -1. Open the composer and click the GIF button to open the dialog -2. Start typing a search term (e.g., "hap") -3. Verify suggestions appear below the search input within ~200ms -4. Verify suggestions update as you continue typing -5. Click a suggestion — verify it fills the input and GIF results load -6. Verify suggestions disappear after selection - -- [ ] **Step 3: Test keyboard navigation (web)** - -1. Type a partial query (e.g., "dan") -2. Press ArrowDown — verify the first suggestion highlights -3. Press ArrowDown again — verify highlight moves to second suggestion -4. Press ArrowUp — verify highlight moves back -5. Press Enter — verify the highlighted suggestion is selected, input fills, GIFs load -6. Press Escape while suggestions are visible — verify suggestions dismiss but dialog stays open -7. Press Escape again — verify dialog closes - -- [ ] **Step 4: Test edge cases** - -1. Type and then clear the input — verify suggestions disappear -2. Select a suggestion, then backspace to edit — verify suggestions re-appear -3. Type something with no autocomplete results — verify no suggestion list renders -4. Rapidly type and delete — verify no visual glitches or stale suggestions -5. Verify the GIF grid still scrolls and paginates normally when suggestions are not visible - -- [ ] **Step 5: Test on Tenor path** - -1. If you can disable the `KlipyGifProviderEnable` feature flag, verify the GIF picker works normally without autocomplete (no suggestions, no errors) -2. If you can't toggle the flag, verify there are no runtime errors when the hook is called — it should simply never show suggestions - -- [ ] **Step 6: Commit any fixes from testing** - -If any issues were found and fixed during testing, commit them: - -```bash -git add -A -git commit -m "fix(gif): address issues found during manual autocomplete testing" -``` diff --git a/docs/superpowers/plans/2026-04-14-gif-category-pills.md b/docs/superpowers/plans/2026-04-14-gif-category-pills.md deleted file mode 100644 index 0640b2785e..0000000000 --- a/docs/superpowers/plans/2026-04-14-gif-category-pills.md +++ /dev/null @@ -1,315 +0,0 @@ -# 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/docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md b/docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md deleted file mode 100644 index 760c67d1f5..0000000000 --- a/docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md +++ /dev/null @@ -1,192 +0,0 @@ -# GIF Autocomplete Design - -Inline search suggestions in the GIF picker dialog, powered by Klipy's -`/v2/autocomplete` endpoint. - -## Context - -The GIF picker currently has a search field that fires a full GIF search after a -500ms throttle. There is no typeahead or suggestion behavior — the user types -blindly and waits for results. Klipy exposes an autocomplete endpoint that -returns lightweight string suggestions for a partial query, which we can use to -help users find the right search term faster. - -## Decisions - -| Question | Decision | Rationale | -|----------|----------|-----------| -| Suggestion placement | Inline list below search input | Avoids Portal/z-index issues inside the dialog. Fits the reserved slot in `GifPickerHeader`. No need for Sift's anchor-relative positioning. | -| Keyboard navigation (web) | Custom lightweight handler (~30 lines) | Sift's positioning model (`position: fixed`) fights inline rendering. Borrowing the keyboard pattern without the positioning baggage. | -| Visibility | Show when 1+ characters typed, hide on selection or clear | No suggestions on empty input — featured GIFs serve that role. | -| Provider scope | Klipy only | Tenor is being sunset. Users on the Tenor path don't get suggestions. | -| Throttle timing | 200ms for autocomplete, independent of 500ms search throttle | Autocomplete responses are tiny (string array). Snappy feel expected for typeahead. | - -## Klipy Autocomplete Endpoint - -**Request:** -``` -GET https://gifs.bsky.app/klipy/v2/autocomplete?q=&client_key=&limit=&locale= -``` - -**Response:** -```json -{ - "locale": "en", - "results": ["happy birthday", "happy", "happy easter", "happy dance"] -} -``` - -Parameters follow the same pattern as the existing search/featured endpoints -(`client_key`, `locale`). We'll request `limit=8` suggestions — enough to be -useful without overwhelming the list or pushing the GIF grid too far down. - -## Architecture - -### Component Tree - -``` -GifPickerDialog -└─ GifPickerBody - ├─ GifPickerHeader - │ ├─ TextField.Input (search box) - │ └─ GifAutocompleteSuggestions ← NEW - ├─ GifPickerPlaceholder - └─ GifPickerGrid -``` - -### Data Flow - -1. User types → `rawSearch` state updates -2. `rawSearch` throttled at **200ms** → fires `useKlipyAutocompleteQuery` -3. `rawSearch` throttled at **500ms** → fires GIF search query (existing) -4. Autocomplete response → string array rendered as suggestion list -5. User taps/selects suggestion → fills input + triggers GIF search -6. Suggestions hide when input matches a selected term - -### File Changes - -**New files:** - -- `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx` — inline - suggestion list UI. Renders a vertical list of suggestion rows, each with a - search icon and the suggestion text. Handles `onPress` to select a suggestion. - On web, tracks `activeIndex` for keyboard highlight state. - -- `src/features/gifPicker/hooks/useGifAutocomplete.ts` — orchestration hook. - Manages the 200ms throttled value, calls `useKlipyAutocompleteQuery`, - tracks whether suggestions should be visible (based on typing vs. selection), - and exposes keyboard navigation state for web. - -**Modified files:** - -- `src/lib/constants.ts` — add `GIF_KLIPY_AUTOCOMPLETE` endpoint constant. - -- `src/state/queries/klipy.ts` — add `useKlipyAutocompleteQuery` hook. Uses - `useQuery` (not infinite — no pagination). Returns `string[]`. - -- `src/features/gifPicker/components/GifPickerHeader.tsx` — render - `GifAutocompleteSuggestions` below the search input. Pass down the - autocomplete state and selection callback. - -- `src/features/gifPicker/GifPickerDialog.tsx` — wire up `useGifAutocomplete` - hook. Manage the interaction between autocomplete selection and the existing - search state (selecting a suggestion sets `rawSearch` to the suggestion text). - -## Interaction States - -### 1. Idle — no suggestions - -Search field is empty or unfocused. Featured GIFs show in the grid. No -suggestion list rendered. - -### 2. Typing — suggestions appear - -User has typed 1+ characters. Suggestion list appears between the search input -and the GIF grid, pushing the grid down. Suggestions update as the user types -(200ms throttle). On web, the first suggestion is highlighted by default. - -### 3. Selected — GIFs load - -User taps a suggestion (or presses Enter on web). The suggestion text fills the -search input. Suggestions hide. The 500ms search throttle fires with the -selected term and GIF results populate the grid. - -### 4. Editing after selection - -User modifies the input after a selection (e.g., backspace). Suggestions -re-appear with updated results for the new partial query. - -## Keyboard Navigation (Web Only) - -Handled via an `onKeyDown` listener on the search `TextInput`: - -| Key | Action | -|-----|--------| -| ArrowDown | Move active highlight to next suggestion | -| ArrowUp | Move active highlight to previous suggestion | -| Enter | Select the active suggestion (fill input, fire search) | -| Escape | Dismiss suggestions (then closes dialog on second press) | - -### Accessibility - -- Search input: `role="combobox"`, `aria-controls=""`, - `aria-expanded`, `aria-autocomplete="list"`, - `aria-activedescendant=""` -- Suggestion list: `role="listbox"`, `id=""` -- Each suggestion: `role="option"`, `aria-selected`, `id=""` - -## Visibility Rules - -``` -SHOW when: rawSearch.length >= 1 AND suggestions.length > 0 AND not just selected -HIDE when: rawSearch is empty OR user selected a suggestion OR user clears input -RE-SHOW when: user edits the input after a selection (e.g., backspace) -``` - -The "not just selected" flag prevents suggestions from flickering when the -selected term is written into the input (which would otherwise trigger a new -autocomplete query matching the full term). - -## Query Hook Design - -```ts -// src/state/queries/klipy.ts - -export const RQKEY_AUTOCOMPLETE = (query: string) => - [RQKEY_ROOT, 'autocomplete', query] - -export function useKlipyAutocompleteQuery( - query: string, - options?: {enabled?: boolean}, -) { - return useQuery({ - queryKey: RQKEY_AUTOCOMPLETE(query), - queryFn: () => fetchKlipyAutocomplete(query), - enabled: query.length > 0 && options?.enabled !== false, - staleTime: STALE.HOURS.ONE, - }) -} -``` - -Uses `useQuery` (not `useInfiniteQuery`) since there's no pagination. Results -are cached with a long stale time — autocomplete suggestions for a given prefix -don't change frequently. - -The fetch function reuses the same `client_key`/`locale`/`contentfilter` -parameter pattern as the existing `createKlipyApi` helper. - -## Scope Boundaries - -**In scope:** -- Klipy autocomplete endpoint integration -- Inline suggestion list in GifPickerHeader -- 200ms throttle for autocomplete, independent of 500ms search throttle -- Keyboard navigation on web (arrow keys, enter, escape) -- ARIA accessibility attributes -- Tap-to-select on native - -**Out of scope:** -- Tenor autocomplete (Klipy only) -- Trending/popular suggestions on empty input -- Search history / recents -- Categories or tag browsing diff --git a/docs/superpowers/specs/2026-04-14-gif-category-pills-design.md b/docs/superpowers/specs/2026-04-14-gif-category-pills-design.md deleted file mode 100644 index 69e207ea15..0000000000 --- a/docs/superpowers/specs/2026-04-14-gif-category-pills-design.md +++ /dev/null @@ -1,125 +0,0 @@ -# GIF Category Pills Design - -Horizontal scrollable row of category pills on the GIF picker's idle screen, -letting users browse GIFs by emotion/reaction category. - -## Context - -The GIF picker currently shows featured/trending GIFs on the idle screen with a -search input above. Users can type to search, but there's no way to browse by -category. Threads shows a row of category pills (Trending, Love, Happy, Sad, -etc.) that give quick access to common GIF reactions without typing. - -## Decisions - -| Question | Decision | Rationale | -|----------|----------|-----------| -| Layout | Horizontal scrollable pill row + featured grid below | Matches Threads pattern. Pills provide quick access, grid fills remaining space. | -| Categories | Hardcoded curated list of 8 | Predictable, no extra API call, intentional selection of popular emotions. | -| "Trending" pill | Uses existing featured endpoint | No search query needed — it's the default idle view. | -| Category tap behavior | Sets search query to category's searchterm | Reuses existing `useGifPickerData` search path, no new data fetching logic. | -| Pills while typing | Hide when input has text, reappear when cleared | Clean separation between browse and search modes. | - -## Curated Categories - -| Pill | Search term | Emoji | -|------|------------|-------| -| Trending | *(featured endpoint, no search)* | 🔥 | -| Love | `love` | ❤️ | -| Happy | `happy` | 😄 | -| Sad | `cry` | 😢 | -| Party | `congratulations` | 🎉 | -| Yes | `yes` | 👍 | -| LOL | `lol` | 😂 | -| Excited | `excited` | 🤩 | - -"Trending" is the default active pill when the dialog opens. - -## Architecture - -### Component Tree - -``` -GifPickerBody -├─ GifPickerHeader -│ └─ TextField.Input -├─ GifCategoryPills ← NEW (hidden when typing) -├─ GifPickerPlaceholder -└─ GifPickerGrid -``` - -### Data Flow - -1. Dialog opens → "Trending" pill active → featured endpoint fires (existing) -2. User taps a category pill → active pill updates → search query set to - pill's `searchterm` → `useGifPickerData` fires the search endpoint -3. User starts typing → pills hide → search query comes from text input -4. User clears input → pills reappear → active pill resets to "Trending" → - featured GIFs reload - -### File Changes - -**New files:** - -- `src/features/gifPicker/components/GifCategoryPills.tsx` — horizontal - scrollable row of pill buttons. Accepts `activeCategory`, `onSelectCategory`, - and `visible` props. Each pill is a `Pressable` with emoji + label. The - active pill gets a highlighted background. The component renders `null` when - `visible` is false. - -**Modified files:** - -- `src/features/gifPicker/GifPickerDialog.tsx` — add `activeCategory` state to - `GifPickerBody`. When a category is selected (and it's not "trending"), pass - its searchterm as the search query to `useGifPickerData`. Render - `GifCategoryPills` in the header area. Hide pills when `rawSearch.length > 0`. - Reset `activeCategory` to "trending" when input is cleared. - -## Interaction States - -### 1. Idle — "Trending" active - -Dialog just opened. "Trending" pill is highlighted. Featured GIFs show in the -grid. Search input is empty. - -### 2. Category selected - -User tapped a category pill (e.g., "Happy"). That pill highlights, "Trending" -unhighlights. Grid shows search results for "happy". Search input stays empty -— the query comes from the pill, not the text field. - -### 3. Typing — pills hidden - -User started typing in the search input. The pill row hides entirely. Grid -shows search results for whatever the user typed. The `activeCategory` state -stays as-is (not reset) — it's just not visible or used while typing. This -avoids unnecessary state churn; it only resets when the input is fully cleared. - -### 4. Input cleared — pills return - -User cleared the search input (backspace or clear button). Pills reappear with -"Trending" active. Featured GIFs reload in the grid. - -## Pill Styling - -- Horizontal `ScrollView` with `horizontal` and `showsHorizontalScrollIndicator={false}` -- Each pill: `Pressable` with `rounded_full`, emoji + text, theme-aware colors -- Active pill: stronger background (`t.atoms.bg_contrast_100` or similar) -- Inactive pill: subtle border (`t.atoms.border_contrast_low`) -- Row has horizontal padding matching the GIF grid - -## Scope Boundaries - -**In scope:** -- `GifCategoryPills` component with horizontal scroll -- 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 -- "Trending" as default active pill (uses featured endpoint) - -**Out of scope:** -- Recent / Favorited pills (needs client-side persistence) -- Dynamic categories from the `/v2/categories` API -- Category GIF thumbnails on the pills -- Animation for pills show/hide transition