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>
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", placeholderSearch the journal….- Right of the input on focus: a mono
Esc to clearhint. - Width: caps at ~28ch desktop, full-width mobile.
- Styling:
font-mono13px, hairline bottom border using--hairline-on-dark(light/dark variants like existing.tag/.date).
The inline script (~30 lines) does:
- On
input, normalize query:value.toLowerCase().trim().split(/\s+/).filter(Boolean). - For each
[data-search]: visible iff every token is a substring of itsdata-searchvalue. Toggle.is-hidden. - 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 perdocs/superpowers/follow-ups/2026-06-02-blog-search-featured-match.mdso featured behaves like any other filterable row.) - For each
[data-year-head]: count visible siblings until the next year head; hide the head if zero. - Toggle a no-results message (
<p data-empty>) on/off based on whether any row is visible. - Empty query: clear
.is-hiddenfrom everything (including featured); hide the no-results message.
Keyboard:
/anywhere on the page focuses the input (skip if a text input is already focused; checkdocument.activeElement).Escwhile 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
searchTextper post in the frontmatter:[title, tag, standfirst, description, post.body].filter(Boolean).join(' '), then lowercase, strip markdown punctuation (#*_>\`), and collapse whitespace. - Mount
<BlogSearch />between.mastheadand.feature. - Add
data-featured data-search={featuredSearchText}to the featured<article>. - Pass
searchTextto eachPostRow. - 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-headon 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-hiddenusesdisplay: noneso 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,Escclearing.- 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.