Files
roux/docs/specs/2026-05-19-typeahead-search-design.md
T

5.9 KiB

Type-Ahead Search for Roux

Date: 2026-05-19

Overview

Add a search box to the recipe index page that filters recipes by title or filename as the user types. The index page will be client-rendered from embedded JSON data, with progressive enhancement via a small vanilla JS script.

Motivation

As the recipe collection grows, finding a recipe by scanning an alphabetical list becomes tedious. A type-ahead search box provides instant filtering with no page reloads.

Scale Assumption

The recipe collection is assumed to be small — hundreds, at most ~1000 recipes. All data is embedded in the page at load time. No pagination or server-side search queries are needed.

Architecture

Data flow

  1. Server (Haskell / Html.hs) renders the index page shell — navbar, search <input> element, and two JSON blobs in <script type="application/json"> tags.
  2. No server-rendered recipe list. The ul.roux-recipes and all recipe content is rendered entirely by JS from the embedded JSON.
  3. JS (inline <script>, vanilla, no dependencies) reads the JSON on page load, renders the initial recipe list, and re-renders on search input or sort-mode change.

Progressive enhancement

Without JavaScript the page shows the navbar, search input, and a message like "Enable JavaScript to browse recipes." This is acceptable for a personal/family site where JS availability is assumed, but the shell still provides useful navigation.

If server-side rendering is desired later, the server can continue to render the list, and JS can use the embedded JSON for client-side filtering only.

Routes / Hash-based sort mode

  • Sort mode is stored in the URL hash:
    • #alpha (default) — alphabetical list
    • #tags — grouped by tag
    • #course — grouped by course
  • JS reads the hash on load and on hashchange events.
  • Sort links in the navbar set the hash (e.g., <a href="#tags">By Tag</a>).
  • This makes sort modes linkable and bookmarkable without server round-trips.

The existing server routes GET /, GET /sorted/tags, and GET /sorted/course all render the same shell page (same HTML, same JSON, same JS). The JS reads the hash to initialise the sort mode. Visiting a server route without a hash defaults to #alpha. The server-side route distinction is no longer needed; the three endpoints can serve identical content.

JSON Payload

Recipe data

Embedded in <script type="application/json" id="roux-recipe-data">:

[
  {
    "title": "Carrot Risotto",
    "filename": "carrot-risotto.cook",
    "course": "Main Course",
    "tags": ["italian", "vegetarian", "risotto"]
  },
  ...
]

Fields:

  • title — display title from recipe metadata (or filename fallback)
  • filename — recipe file path, used for link URL
  • course — optional course/category from metadata
  • tags — list of tags from metadata (empty array if none)

Error data

Embedded in <script type="application/json" id="roux-errors-data">:

[
  {"title": "Broken Recipe", "filename": "broken.cook"}
]

Rendered at the bottom under an "Unparseable recipes" heading, matching current server-rendered behavior.

Client-Side Rendering (JS)

Initialization

  1. Parse JSON from script tags.
  2. Read location.hash to determine sort mode (default: #alpha).
  3. Render the recipe list.
  4. Attach event listeners: input on search box, hashchange on window.

Search behavior

  • Search matches against title and filename, case-insensitive (simple includes after toLowerCase()).
  • Search input is cleared on page load.
  • When search text is non-empty:
    • Recipes are filtered to matches.
    • List is always rendered as flat alphabetical (grouping suppressed).
  • When search text is empty:
    • Recipes render according to the current sort mode.

Sort modes

All three modes match the current server-rendered output:

AlphaSort (#alpha):

  • Flat <ul class="roux-recipes"> with <li><a href="/recipes/...">title</a></li>.
  • Sorted alphabetically by title.

TagSort (#tags):

  • Grouped by tag: <h3>TagName</h3> followed by <ul class="roux-recipes"> for each tag group.
  • Tag order is alphabetical.
  • Recipes that appear in multiple tags appear under each tag.
  • Untagged recipes are omitted (matches current behavior).

CourseSort (#course):

  • Grouped by course: <h3>CourseName</h3> followed by <ul class="roux-recipes"> for each course group.
  • Recipes without a course are omitted.

Styling

Reuses existing CSS classes: .roux-recipes, .roux-course, .tag, .roux-navbar, etc. No new styles needed for the recipe list itself.

Files Changed

src/Roux/Html.hs

  • Add Aeson import and encode recipe list + error list as JSON.
  • Modify indexPage to:
    • Embed JSON blobs.
    • Render search input (HTML element, present in all views).
    • Render the JS script block.
    • Remove server-rendered recipe list rendering (move sort-mode logic to JS).
  • Modify SortMode to become part of the JSON payload rather than server-side rendering selection.

src/Roux/RecipeIndex.hs

  • Minor: ensure riTitle and riFilename are exported and accessible for JSON encoding. No structural changes expected.

package.yaml

  • No new dependencies needed (aeson is already listed).

Testing

  • Manual testing in browser: verify all three sort modes render correctly.
  • Verify search filters by title and filename (case-insensitive).
  • Verify clearing search restores sort mode.
  • Verify hash-based navigation works (back/forward buttons).
  • Verify error recipes still shown at bottom.
  • Run ./hs stack build and ./hs stack test before marking complete.

Future Considerations (explicitly out of scope)

  • Server-side search endpoint — not needed at this scale.
  • Debouncing/throttling — not needed with client-side in-memory filtering on small dataset.
  • Fuzzy search — not needed with simple substring matching on small dataset.
  • External JS file — inline keeps things simple for this size.