jb-website/docs/superpowers/specs/2026-06-01-blog-search-design.md
Josh Bairstow 0a5a7d5e45 fix(blog): make featured article respect search match
The featured article previously hid whenever a query was active,
regardless of whether it matched. Treat it as a normal [data-search]
row instead: visible iff it matches the query, and counted toward
visibleCount so a featured-only match no longer triggers the
"no entries match" message.

Closes the follow-up at
docs/superpowers/follow-ups/2026-06-02-blog-search-featured-match.md
and updates the design spec to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:48:35 +10:00

6.1 KiB

Blog search — design

Date: 2026-06-01 Status: Approved (pending user spec review) Scope: Add a light, in-page search to /blog that filters the existing archive by keyword.

Goal

Let a reader find a blog post on the /blog index by typing a keyword or short phrase. Match against the post's title, tag, standfirst/description, and body text. Filter the existing year-grouped archive in place.

Non-goals

  • Ranking, fuzzy match, excerpt highlighting (upgrade path: Pagefind).
  • URL persistence of the query.
  • Sitewide / top-bar search.
  • Tag-click-to-filter (separate feature).

Approach

A tiny client-side filter implemented as data attributes on each row (no JSON index, no fetch, no React island).

Each rendered post element carries a data-search attribute containing a lowercased, whitespace-collapsed concatenation of its searchable fields. A small inline <script> on the blog index reads the input, tokenizes the query, and toggles a .is-hidden class on every element whose data-search doesn't contain all tokens.

Rejected alternatives:

  • Inline JSON index + slug→row mapping: viable but adds a parsing/lookup layer for no real benefit at this scale.
  • Fetched /blog/search-index.json: only pays off past ~50 posts; reconsider then.
  • Pagefind: full-text engine with ranking; ~70KB+ WASM. Overkill for current archive size. Document as the upgrade path.

Components

New: src/components/molecules/BlogSearch.astro

Renders a single <input type="search"> with a small inline <script> (vanilla JS).

  • aria-label="Search posts", placeholder Search the journal….
  • Right of the input on focus: a mono Esc to clear hint.
  • Width: caps at ~28ch desktop, full-width mobile.
  • Styling: font-mono 13px, hairline bottom border using --hairline-on-dark (light/dark variants like existing .tag / .date).

The inline script (~30 lines) does:

  1. On input, normalize query: value.toLowerCase().trim().split(/\s+/).filter(Boolean).
  2. For each [data-search]: visible iff every token is a substring of its data-search value. Toggle .is-hidden.
  3. The featured block (marked data-featured) is itself a [data-search] row, so it filters by the same match check — visible iff it matches the query. (Originally it hid whenever a query was active; changed per docs/superpowers/follow-ups/2026-06-02-blog-search-featured-match.md so featured behaves like any other filterable row.)
  4. For each [data-year-head]: count visible siblings until the next year head; hide the head if zero.
  5. Toggle a no-results message (<p data-empty>) on/off based on whether any row is visible.
  6. Empty query: clear .is-hidden from everything (including featured); hide the no-results message.

Keyboard:

  • / anywhere on the page focuses the input (skip if a text input is already focused; check document.activeElement).
  • Esc while focused: clear value, blur, restore full archive.
  • Enter: no-op (filter is live).

No debouncing — per-keystroke iteration over a few hundred elements is sub-millisecond.

Edited: src/pages/blog/index.astro

  • Build searchText per post in the frontmatter: [title, tag, standfirst, description, post.body].filter(Boolean).join(' '), then lowercase, strip markdown punctuation (#*_>\`), and collapse whitespace.
  • Mount <BlogSearch /> between .masthead and .feature.
  • Add data-featured data-search={featuredSearchText} to the featured <article>.
  • Pass searchText to each PostRow.
  • Render <p data-empty hidden>No entries match "<span data-empty-query></span>".</p> after .archive, styled like .count (mono 11px, --fg-on-dark-3), aria-live="polite".

Edited: src/components/molecules/PostRow.astro

  • Accept a new prop searchText: string.
  • Emit data-search={searchText} on the row's root element.

Edited: src/components/molecules/YearHead.astro

  • Emit data-year-head on the root element so the script can find heads.

Data flow

Build-time: each post's frontmatter and body are concatenated, normalized, and embedded as a data-search attribute string on the rendered row. No runtime parsing.

Runtime: input event → tokenize → DOM walk → toggle classes. All synchronous. No fetch, no JSON, no async.

CSS

Add to src/pages/blog/index.astro's scoped styles:

.is-hidden { display: none; }

Empty-state <p> reuses the .count style (mono 11px, --fg-on-dark-3).

Accessibility

  • Input has aria-label="Search posts".
  • Empty-state message has aria-live="polite" so result-count changes are announced.
  • .is-hidden uses display: none so hidden content is skipped by assistive tech.
  • / shortcut respects existing focus — won't trap users typing elsewhere.

Edge cases

Case Behavior
Markdown syntax in body Stripped to plain text before joining; code fence contents kept (useful for searching function names).
Whitespace-only query Treated as empty: full archive restored.
Tokens with regex-special chars Safe — only String.includes() is used.
Query matches nothing No-results message shown; all rows + year heads hidden.
Single-post archive Featured shows with empty query; a non-empty query keeps the featured (and lone archive entry) visible iff it matches. Acceptable.
JavaScript disabled Input renders but does nothing; full archive visible. No regression.

Theme parity

Input and empty-state colors follow the existing body.dark / body:not(.dark) pattern used by .tag, .date, .count in the current index.astro. No additions to colors_and_type.css.

Testing

No test harness exists in the repo. Verification is manual via npm run dev:

  • Golden path: typing narrows results; year heads collapse when their group empties; clearing restores the full archive.
  • Edge cases listed above.
  • / keyboard shortcut, Esc clearing.
  • Light/dark theme parity.

Upgrade path

If the archive grows past ~50 posts, or readers ask for fuzzy match / ranking / highlighted excerpts, swap this implementation for Pagefind. The BlogSearch component is the only surface that needs to change; the rest of the page is unaffected.