# Type-Ahead Search 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 client-rendered type-ahead search box to the recipe index page with hash-based sort mode navigation. **Architecture:** Embed recipe data as JSON in the index page; render the recipe list entirely in vanilla JS. No new API endpoints or server-side search logic needed. Sort modes (alpha/tags/course) controlled via URL hash. **Tech Stack:** Haskell (WAI, blaze-html, aeson), Vanilla JS, Pico CSS --- ### Task 1: Add JSON encoding for recipe search data in `RecipeIndex.hs` **Files:** - Modify: `src/Roux/RecipeIndex.hs` - [ ] **Step 1: Add `aeson` import and `RecipeSearchEntry` data type** Add a new data type for the JSON payload — a lightweight projection of `RecipeInfo` containing only the fields the JS needs. ```haskell -- | Lightweight recipe data for the client-side search JSON payload. data RecipeSearchEntry = RecipeSearchEntry { rseTitle :: !Text , rseFilename :: !Text , rseCourse :: !(Maybe Text) , rseTags :: ![Text] } deriving stock (Eq, Show, Generic) instance ToJSON RecipeSearchEntry where toJSON = genericToJSON defaultOptions{fieldLabelModifier = camelTo2 '_' . drop 3} ``` Also add to the module's export list: `RecipeSearchEntry(..)`, `toSearchEntry`. - [ ] **Step 2: Add `toSearchEntry` conversion function** ```haskell -- | Convert RecipeInfo to the lightweight search entry. toSearchEntry :: RecipeInfo -> RecipeSearchEntry toSearchEntry info = let (course, tags) = case riRecipe info of Right r -> ( metaCourse (recipeMetadata r) , metaTags (recipeMetadata r) ) Left _ -> (Nothing, []) in RecipeSearchEntry { rseTitle = riTitle info , rseFilename = T.pack (riFilename info) , rseCourse = course , rseTags = tags } ``` - [ ] **Step 3: Ensure imports are correct and build** The module already imports `Data.CookLang` (which has `Recipe`, `recipeMetadata`, `metaCourse`, `metaTags`). Add `GHC.Generics` and `Data.Aeson` to imports: ```haskell import Data.Aeson (ToJSON, genericToJSON, defaultOptions, fieldLabelModifier, camelTo2) import GHC.Generics (Generic) ``` - [ ] **Step 4: Build to verify compilation** Run: `./hs stack build` Expected: Compiles successfully. - [ ] **Step 5: Commit** ```bash git add src/Roux/RecipeIndex.hs git commit -m "feat: add RecipeSearchEntry JSON encoding for search payload" ``` --- ### Task 2: Rework `Html.hs` — shell page with embedded JSON and JS **Files:** - Modify: `src/Roux/Html.hs` - [ ] **Step 1: Add new imports and helpers** Add to imports at the top: ```haskell import Data.Aeson (encode) import qualified Data.ByteString.Lazy as LB import Data.Char (toLower) import Data.List (sort) import Data.Maybe (mapMaybe) import Data.Text.Encoding (decodeUtf8) ``` - [ ] **Step 2: Modify `SortMode` — keep the type but it won't be used for routing anymore** No changes needed to the `SortMode` type itself. It can remain for clarity — we'll just stop routing on it server-side (that's Task 3). - [ ] **Step 3: Rewrite `indexPage` to produce the shell page** Replace the current `indexPage` with one that: 1. Builds the JSON payloads 2. Renders the page shell (nav, search input, JSON script tags, JS script, empty container for the recipe list) ```haskell -- | Render the recipe index page — shell with embedded JSON + JS rendering. indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString indexPage _mode recipes = page "Roux — Recipes" $ do H.div ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.strong "Roux" H.ul $ do H.li $ H.a ! A.href "#alpha" $ "A-Z" H.li $ H.a ! A.href "#tags" $ "By Tag" H.li $ H.a ! A.href "#course" $ "By Course" -- Search input H.input ! A.type_ "search" ! A.id "roux-search" ! A.placeholder "Search recipes…" ! A.style "margin-bottom: 1rem;" -- Container for JS-rendered recipe list H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes…" -- Embedded JSON: recipe data H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $ H.toHtml (decodeUtf8 (LB.toStrict (encode (map Idx.toSearchEntry (filterOk recipes))))) -- Embedded JSON: error data H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $ H.toHtml (decodeUtf8 (LB.toStrict (encode (map Idx.toSearchEntry (filterBad recipes))))) -- Inline JS H.script ! A.type_ "text/javascript" $ H.toHtml searchJs where filterOk = filter (isRight . Idx.riRecipe) filterBad = filter (isLeft . Idx.riRecipe) ``` Note: `Data.Aeson.encode` returns a lazy `ByteString`. `decodeUtf8` expects a strict `ByteString`, so we use `LB.toStrict` to convert. Both `Data.ByteString.Lazy` (qualified as `LB`) and `Data.Text.Encoding` are imported above. ```haskell import Data.Text.Encoding (decodeUtf8) ``` The old helper functions like `sortLink`, `renderAlpha`, `renderByTags`, `renderByCourse`, `recipeItem`, `alphaSortKey`, `renderTagGroup`, `renderCourseGroup` should be removed since they're no longer used. Keep `takeBaseName`, `nubOrd`, `isLeft`, `isRight` since they may be used elsewhere (check). - [ ] **Step 4: Write the inline JavaScript** Add this function returning the JS string. Place it after the imports / before `indexPage`: ```haskell -- | Inline JavaScript for client-side search + sort rendering. searchJs :: Text searchJs = T.unlines [ "(function(){" , "'use strict';" , "" , "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);" , "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);" , "const listEl = document.getElementById('roux-recipe-list');" , "const searchEl = document.getElementById('roux-search');" , "" , "function getSortMode() {" , " const h = location.hash.slice(1);" , " if (h === 'tags') return 'tags';" , " if (h === 'course') return 'course';" , " return 'alpha';" , "}" , "" , "function render() {" , " const query = searchEl.value.toLowerCase().trim();" , " const mode = getSortMode();" , " let filtered = recipes;" , " if (query) {" , " filtered = recipes.filter(r =>" , " r.title.toLowerCase().includes(query) ||" , " r.filename.toLowerCase().includes(query)" , " );" , " }" , " let html = '';" , " if (query || mode === 'alpha') {" , " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));" , " html = renderFlatList(sorted);" , " } else if (mode === 'tags') {" , " html = renderByTags(filtered);" , " } else if (mode === 'course') {" , " html = renderByCourse(filtered);" , " }" , " if (errors.length > 0) {" , " html += '
No recipes found.
';" , " let html = 'No tagged recipes.
';" , " let html = '';" , " tags.forEach(t => {" , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));" , " html += 'No recipes with course metadata.
';" , " let html = '';" , " courses.forEach(c => {" , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));" , " html += '