From 794ffd474a1835d488af47e513e8229e36dafa0f Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Tue, 14 Apr 2026 14:24:02 -0400 Subject: [PATCH] wip autocomplete --- .../content/design-overview.html | 173 +++++ .../content/suggestion-placement.html | 149 ++++ .../86663-1776187526/content/waiting.html | 3 + .../86663-1776187526/state/server-stopped | 1 + .../86663-1776187526/state/server.log | 5 + .../86663-1776187526/state/server.pid | 1 + .../plans/2026-04-14-gif-autocomplete.md | 735 ++++++++++++++++++ .../2026-04-14-gif-autocomplete-design.md | 192 +++++ 8 files changed, 1259 insertions(+) create mode 100644 .superpowers/brainstorm/86663-1776187526/content/design-overview.html create mode 100644 .superpowers/brainstorm/86663-1776187526/content/suggestion-placement.html create mode 100644 .superpowers/brainstorm/86663-1776187526/content/waiting.html create mode 100644 .superpowers/brainstorm/86663-1776187526/state/server-stopped create mode 100644 .superpowers/brainstorm/86663-1776187526/state/server.log create mode 100644 .superpowers/brainstorm/86663-1776187526/state/server.pid create mode 100644 docs/superpowers/plans/2026-04-14-gif-autocomplete.md create mode 100644 docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md diff --git a/.superpowers/brainstorm/86663-1776187526/content/design-overview.html b/.superpowers/brainstorm/86663-1776187526/content/design-overview.html new file mode 100644 index 0000000000..249253650e --- /dev/null +++ b/.superpowers/brainstorm/86663-1776187526/content/design-overview.html @@ -0,0 +1,173 @@ +

GIF Autocomplete — Full Design

+

Inline search suggestions powered by Klipy's autocomplete endpoint

+ +
+

Architecture Overview

+
+
Component & Data Flow
+
+
+ +
+
Component Tree
+
+
GifPickerDialog
+
└─ GifPickerBody
+
├─ GifPickerHeader
+
│ ├─ TextField.Input (search box)
+
│ └─ GifAutocompleteSuggestions ← NEW
+
├─ GifPickerPlaceholder
+
└─ GifPickerGrid
+
+
+ +
+
Data Flow
+
+
1. User types → rawSearch state
+
2. rawSearch throttled @ 200ms → autocomplete query
+
3. rawSearch throttled @ 500ms → GIF search query
+
4. Autocomplete response → suggestion strings
+
5. User taps suggestion → fills input + fires search
+
6. Suggestions hide when input matches a selected term
+
+
+
+
+
+
+ +
+

New Files

+
+
+ src/lib/constants.ts — add GIF_KLIPY_AUTOCOMPLETE endpoint
+
+ src/state/queries/klipy.ts — add useKlipyAutocompleteQuery hook
+
+ src/features/gifPicker/hooks/useGifAutocomplete.ts — orchestration hook (throttle + query + keyboard state)
+
+ src/features/gifPicker/components/GifAutocompleteSuggestions.tsx — inline suggestion list UI
+
~ src/features/gifPicker/components/GifPickerHeader.tsx — render suggestions below input
+
~ src/features/gifPicker/GifPickerDialog.tsx — wire up autocomplete state
+
+
+ +
+

Interaction Flow

+
+ +
+
+
1. Idle — no suggestions
+
+
+ 🔍 + Search GIFs +
+
+
+
+
+
Featured GIFs
+
+
+
+ +
+
+
2. Typing — suggestions appear
+
+
+ 🔍 + hap| +
+
+
+ 🔍 happy birthday +
+
+ 🔍 happy +
+
+ 🔍 happy easter +
+
+
+
+
+
+
+
+
+ +
+
+
3. Selected — GIFs load
+
+
+ 🔍 + happy birthday + +
+
+
+
+
+
+
+
+
+
+
+
Results for "happy birthday"
+
+
+
+
+
+ +
+

Keyboard Navigation (Web)

+
+
ArrowDown / ArrowUp — move active highlight through suggestions
+
Enter — select highlighted suggestion (fills input, fires search)
+
Escape — dismiss suggestions (existing behavior closes dialog)
+
Continue typing — resets active index to 0, re-fetches suggestions
+
+

+ ARIA: input gets role="combobox" + aria-controls, list gets role="listbox", + items get role="option" + aria-selected. ~30 lines of onKeyDown handling on web only. +

+
+ +
+

Visibility Rules

+
+
SHOW when: rawSearch.length ≥ 1 AND suggestions.length > 0 AND user hasn't just selected a suggestion
+
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 to modify)
+
+
+ +
+

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/.superpowers/brainstorm/86663-1776187526/content/suggestion-placement.html b/.superpowers/brainstorm/86663-1776187526/content/suggestion-placement.html new file mode 100644 index 0000000000..1d205da11c --- /dev/null +++ b/.superpowers/brainstorm/86663-1776187526/content/suggestion-placement.html @@ -0,0 +1,149 @@ +

Where should autocomplete suggestions appear?

+

As you type in the GIF search box, where do completion suggestions show up?

+ +
+
+
A
+
+

Inline list below the search input

+

Suggestions render as a vertical list between the search bar and the GIF grid. Tapping a suggestion fills the search field and triggers a GIF search. Simple, no z-index or portal concerns inside the dialog.

+
+
GIF Dialog — Inline Suggestions
+
+
+
+ 🔍 + hap| +
+
+
+
+ 🔍 happy birthday +
+
+ 🔍 happy +
+
+ 🔍 happy easter +
+
+ 🔍 happy dance +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
B
+
+

Horizontal chips below the search input

+

Suggestions render as a scrollable row of pill/chip buttons. Compact, leaves more vertical space for the GIF grid. Common in mobile search UIs (YouTube, Google Images).

+
+
GIF Dialog — Chip Suggestions
+
+
+
+ 🔍 + hap| +
+
+
+
happy birthday
+
happy
+
happy easter
+
happy dance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
C
+
+

Sift popover dropdown (like Composer autocomplete)

+

Uses the @bsky.app/sift library to show a positioned popover below the input, matching how @mentions work in the Composer. Keyboard navigable on web. Needs Portal/z-index verification inside the dialog.

+
+
GIF Dialog — Sift Popover
+
+
+
+
+ 🔍 + hap| +
+
+
+
+ 🔍 happy birthday +
+
+ 🔍 happy +
+
+ 🔍 happy easter +
+
+ 🔍 happy dance +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

My recommendation: Option A (inline list)

+
+
+

Why A

+
    +
  • No Portal/z-index issues inside the bottom sheet (native) or modal (web)
  • +
  • Naturally fits the reserved slot in GifPickerHeader ("future: tabs render here")
  • +
  • Clear visual hierarchy — suggestions push the grid down, making them prominent
  • +
  • Simple to implement — just a conditional list render
  • +
+
+
+

Tradeoffs

+
    +
  • Takes more vertical space than chips (B) when visible
  • +
  • Doesn't reuse Sift (C), but that's OK — Sift solves cursor-anchored popovers which we don't need here
  • +
+
+
+
diff --git a/.superpowers/brainstorm/86663-1776187526/content/waiting.html b/.superpowers/brainstorm/86663-1776187526/content/waiting.html new file mode 100644 index 0000000000..f92c257acc --- /dev/null +++ b/.superpowers/brainstorm/86663-1776187526/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/86663-1776187526/state/server-stopped b/.superpowers/brainstorm/86663-1776187526/state/server-stopped new file mode 100644 index 0000000000..62e9f8dbcb --- /dev/null +++ b/.superpowers/brainstorm/86663-1776187526/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1776191006962} diff --git a/.superpowers/brainstorm/86663-1776187526/state/server.log b/.superpowers/brainstorm/86663-1776187526/state/server.log new file mode 100644 index 0000000000..654405ece0 --- /dev/null +++ b/.superpowers/brainstorm/86663-1776187526/state/server.log @@ -0,0 +1,5 @@ +{"type":"server-started","port":49342,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:49342","screen_dir":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content","state_dir":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/state"} +{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content/suggestion-placement.html"} +{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content/waiting.html"} +{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content/design-overview.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/86663-1776187526/state/server.pid b/.superpowers/brainstorm/86663-1776187526/state/server.pid new file mode 100644 index 0000000000..9b16ab7077 --- /dev/null +++ b/.superpowers/brainstorm/86663-1776187526/state/server.pid @@ -0,0 +1 @@ +86672 diff --git a/docs/superpowers/plans/2026-04-14-gif-autocomplete.md b/docs/superpowers/plans/2026-04-14-gif-autocomplete.md new file mode 100644 index 0000000000..488ae15f5a --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-gif-autocomplete.md @@ -0,0 +1,735 @@ +# 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/specs/2026-04-14-gif-autocomplete-design.md b/docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md new file mode 100644 index 0000000000..760c67d1f5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md @@ -0,0 +1,192 @@ +# 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