Compare commits
No commits in common. "5c9d4a99cce0603e3055b22b3faded7bf4cb70bf" and "d7dc22d0c89ab8389072281a095323c26a257ad6" have entirely different histories.
5c9d4a99cc
...
d7dc22d0c8
7 changed files with 22 additions and 1636 deletions
|
|
@ -1,34 +0,0 @@
|
|||
# Follow-up: featured article should respect search match
|
||||
|
||||
**Date filed:** 2026-06-02
|
||||
**Source:** Manual test of `feat/blog-search` before merge
|
||||
**Relates to:** `docs/superpowers/specs/2026-06-01-blog-search-design.md` (§ "What should happen to the featured article when a query is active?")
|
||||
|
||||
## What's wrong
|
||||
|
||||
When a query is typed into the blog search input, the featured article currently hides regardless of whether its content matches the query. In manual testing this felt wrong — if the featured article matches the search term, it should remain visible like any other matching row.
|
||||
|
||||
## What the original design said
|
||||
|
||||
During brainstorming, three options were presented for featured-article behavior under filtering. The user chose "Hide while filtering" with the reasoning "simpler mental model: query active = filter mode." After actually using the feature, the user now wants option 2 from that brainstorm: **treat the featured article as a filterable row** — visible iff it matches the query.
|
||||
|
||||
## What needs to change
|
||||
|
||||
In `src/components/molecules/BlogSearch.astro`, the `applyFilter` function has this branch:
|
||||
|
||||
```ts
|
||||
} else if (isFeatured) {
|
||||
// Featured hides whenever there's a query, regardless of match.
|
||||
visible = false;
|
||||
}
|
||||
```
|
||||
|
||||
Replace it so the featured row goes through the same `tokens.every(t => haystack.includes(t))` check as every other row. Drop the `isFeatured` special case entirely — it can fall through to the general match branch.
|
||||
|
||||
Then sanity-check the empty-state count: `visibleCount` currently skips the featured (`if (visible && !isFeatured) visibleCount += 1`). Once featured participates in the filter, it should count toward the visible total — otherwise a query that *only* matches the featured will trigger the "no entries match" message even though featured is showing. Drop the `!isFeatured` guard there too.
|
||||
|
||||
Visually the featured block is much bigger than a row, so when it's the sole match the layout will look unusual but not broken. If that turns out to feel wrong in practice, the second-order fix would be to render the featured-as-result in the same compact PostRow style as the archive. Don't pre-emptively build that — wait until the simple behavior change is in and see if it's actually needed.
|
||||
|
||||
## Update the spec when you fix this
|
||||
|
||||
Update `docs/superpowers/specs/2026-06-01-blog-search-design.md`'s relevant section so the spec reflects the new behavior. The current text claims "Featured hides while filtering" — that line is now wrong.
|
||||
|
|
@ -1,601 +0,0 @@
|
|||
# Blog 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 light, in-page keyword/topic filter to `/blog` that narrows the year-grouped archive as the reader types.
|
||||
|
||||
**Architecture:** Tiny client-side filter implemented as `data-search` attributes on each row + featured block. A small inline vanilla-JS script in a new `BlogSearch.astro` component tokenizes the query, toggles `.is-hidden` on rows whose `data-search` doesn't contain every token, hides empty year heads, and toggles a no-results message. No JSON index, no fetch, no React island, no new dependencies.
|
||||
|
||||
**Tech Stack:** Astro 5 content collections, Astro components (`.astro`), scoped CSS, vanilla JS.
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-06-01-blog-search-design.md`
|
||||
|
||||
**Verification note:** This repo has no automated test harness, and the spec deliberately doesn't add one. Each task ends with a manual verification step using the Astro dev server (`npm run dev` → http://localhost:4321/blog) and/or grep/curl against the rendered HTML. Confirm the verification passes before committing.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/components/molecules/PostRow.astro` | modify | Accept `searchText` prop, emit `data-search` on root anchor |
|
||||
| `src/components/molecules/YearHead.astro` | modify | Emit `data-year-head` on root |
|
||||
| `src/pages/blog/index.astro` | modify | Build `searchText` per post; wire to `PostRow` + featured `<article>`; render `<BlogSearch />` and the empty-state `<p>`; add `.is-hidden` rule |
|
||||
| `src/components/molecules/BlogSearch.astro` | create | Input, styles, and the filter script (including keyboard shortcuts) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `PostRow` accepts and emits `data-search`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/molecules/PostRow.astro`
|
||||
|
||||
- [ ] **Step 1: Add the `searchText` prop and emit it on the root anchor**
|
||||
|
||||
Edit `src/components/molecules/PostRow.astro` — change the frontmatter and the `<a>` tag:
|
||||
|
||||
```astro
|
||||
---
|
||||
interface Props {
|
||||
date: string;
|
||||
title: string;
|
||||
href: string;
|
||||
deck?: string;
|
||||
tag?: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
const { date, title, href, deck, tag, searchText } = Astro.props;
|
||||
---
|
||||
|
||||
<a class="postrow" href={href} data-search={searchText}>
|
||||
<span class="pdate">{date}</span>
|
||||
<span class="middle">
|
||||
<span class="pttl">{title}<span class="tick"></span></span>
|
||||
{deck && <span class="pdek">{deck}</span>}
|
||||
</span>
|
||||
{tag && <span class="ptag">{tag}</span>}
|
||||
</a>
|
||||
```
|
||||
|
||||
Leave the `<style>` block unchanged.
|
||||
|
||||
- [ ] **Step 2: Verify the build still type-checks**
|
||||
|
||||
Run: `npx astro check`
|
||||
Expected: One or more errors about `src/pages/blog/index.astro` not passing `searchText` to `<PostRow>`. That's the only failure — it confirms the prop is now required and will be supplied in Task 3. If you see errors anywhere other than `blog/index.astro`, stop and investigate.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/molecules/PostRow.astro
|
||||
git commit -m "feat(blog): PostRow accepts searchText, emits data-search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `YearHead` emits `data-year-head`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/molecules/YearHead.astro`
|
||||
|
||||
- [ ] **Step 1: Add the attribute**
|
||||
|
||||
In `src/components/molecules/YearHead.astro`, change the root `<div>`:
|
||||
|
||||
```astro
|
||||
<div class="yearhead" data-year-head>
|
||||
<span class="yr">{year}</span>
|
||||
<span class="ln"></span>
|
||||
<span class="n">{formatted}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Nothing else changes.
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `npm run build 2>&1 | tail -20`
|
||||
Expected: Build still fails at `blog/index.astro` (PostRow searchText). No new errors. (You can also `grep -R "data-year-head" src/` to confirm the attribute is present in source.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/molecules/YearHead.astro
|
||||
git commit -m "feat(blog): YearHead emits data-year-head for filter targeting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Build `searchText` in blog index frontmatter and wire it through
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/blog/index.astro`
|
||||
|
||||
- [ ] **Step 1: Add the `buildSearchText` helper and compute per-post search text in the frontmatter**
|
||||
|
||||
In `src/pages/blog/index.astro`, after the imports and before the `posts.sort(...)` line, add the helper. Then build a `Map<id, string>` keyed by post id.
|
||||
|
||||
Replace the existing frontmatter block (the section between the two `---` fences) with:
|
||||
|
||||
```astro
|
||||
---
|
||||
import BaseLayout from "~/layouts/BaseLayout.astro";
|
||||
import Eyebrow from "~/components/atoms/Eyebrow.astro";
|
||||
import Mark from "~/components/atoms/Mark.astro";
|
||||
import PostRow from "~/components/molecules/PostRow.astro";
|
||||
import YearHead from "~/components/molecules/YearHead.astro";
|
||||
import InkArrow from "~/components/atoms/InkArrow.astro";
|
||||
import BlogSearch from "~/components/molecules/BlogSearch.astro";
|
||||
import { blog } from "~/lib/urls";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
||||
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
const [featured, ...rest] = posts;
|
||||
|
||||
const dateFmtMonthYear = new Intl.DateTimeFormat("en-AU", { month: "short", year: "numeric" });
|
||||
const dateFmtMonthYearLong = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric" });
|
||||
|
||||
// Group the archive by year.
|
||||
const byYear = new Map<number, typeof rest>();
|
||||
for (const post of rest) {
|
||||
const year = post.data.date.getFullYear();
|
||||
const bucket = byYear.get(year) ?? [];
|
||||
bucket.push(post);
|
||||
byYear.set(year, bucket);
|
||||
}
|
||||
const yearGroups = Array.from(byYear.entries()).sort(([a], [b]) => b - a);
|
||||
|
||||
// Build the per-post `data-search` string at build time. We concatenate the
|
||||
// searchable fields, lowercase, strip a small set of markdown punctuation, and
|
||||
// collapse whitespace. Body text is the raw markdown from the content
|
||||
// collection entry's `.body` property — MDX expressions that survive end up as
|
||||
// literal text, which is harmless for substring matching.
|
||||
function buildSearchText(post: (typeof posts)[number]): string {
|
||||
const parts = [
|
||||
post.data.title,
|
||||
post.data.tag,
|
||||
post.data.standfirst,
|
||||
post.data.description,
|
||||
post.body ?? "",
|
||||
].filter(Boolean) as string[];
|
||||
return parts
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.replace(/[#*_>`\[\]()]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const searchTextById = new Map<string, string>();
|
||||
for (const post of posts) {
|
||||
searchTextById.set(post.id, buildSearchText(post));
|
||||
}
|
||||
---
|
||||
```
|
||||
|
||||
The only new things vs. the existing file: the `BlogSearch` import, the `buildSearchText` function, and the `searchTextById` map. Leave the body of the component (everything after the second `---`) untouched in this task.
|
||||
|
||||
- [ ] **Step 2: Pass `searchText` to each `<PostRow>` and add `data-search` + `data-featured` on the featured `<article>`**
|
||||
|
||||
Still in `src/pages/blog/index.astro`, locate the `<article class="feature">` line and add the two attributes:
|
||||
|
||||
```astro
|
||||
<article class="feature" data-featured data-search={searchTextById.get(featured.id) ?? ""}>
|
||||
```
|
||||
|
||||
Then locate the `<PostRow ... />` invocation inside `yearGroups.map(...)` and add the prop:
|
||||
|
||||
```astro
|
||||
<PostRow
|
||||
date={dateFmtMonthYear.format(post.data.date)}
|
||||
title={post.data.title}
|
||||
href={blog(`/posts/${post.id}/`)}
|
||||
deck={post.data.standfirst}
|
||||
tag={post.data.tag}
|
||||
searchText={searchTextById.get(post.id) ?? ""}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `<BlogSearch />` placeholder import target**
|
||||
|
||||
Astro will fail to import `BlogSearch` until Task 4 creates it. To unblock incremental verification, create a stub file now and replace its contents in Task 4. Create `src/components/molecules/BlogSearch.astro` containing exactly:
|
||||
|
||||
```astro
|
||||
---
|
||||
// Stub — real implementation lands in Task 4.
|
||||
---
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the build succeeds and `data-search` appears in the output**
|
||||
|
||||
Run: `npm run build 2>&1 | tail -20`
|
||||
Expected: Build succeeds (no errors).
|
||||
|
||||
Run: `grep -c 'data-search="' dist/blog/index.html`
|
||||
Expected: `4` (one for the featured `<article>` plus one for each of the three current posts). If the count is off, the wiring is wrong — fix before continuing.
|
||||
|
||||
Run: `grep -c 'data-year-head' dist/blog/index.html`
|
||||
Expected: `≥ 1` (one per year group; current posts span 1+ years).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/blog/index.astro src/components/molecules/BlogSearch.astro
|
||||
git commit -m "feat(blog): build per-post searchText, wire to PostRow and featured"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Render the `<BlogSearch />` input (markup + styles, no script yet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/molecules/BlogSearch.astro` (replace the stub)
|
||||
- Modify: `src/pages/blog/index.astro` (mount the component and add the empty-state element + `.is-hidden` rule)
|
||||
|
||||
- [ ] **Step 1: Write the component markup and scoped styles**
|
||||
|
||||
Replace the entire contents of `src/components/molecules/BlogSearch.astro` with:
|
||||
|
||||
```astro
|
||||
---
|
||||
// Light client-side filter for the blog archive.
|
||||
// Reads [data-search] attributes on rows + the featured article, hides
|
||||
// non-matches, collapses empty year heads, toggles a no-results message.
|
||||
// Filter script is added in Task 5; this file ships markup + styles only.
|
||||
---
|
||||
|
||||
<div class="bsearch">
|
||||
<input
|
||||
type="search"
|
||||
class="bsearch__input"
|
||||
aria-label="Search posts"
|
||||
placeholder="Search the journal…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="bsearch__hint" aria-hidden="true">Esc to clear</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bsearch {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: clamp(14px, 2.4vh, 22px) 0;
|
||||
border-top: 1px solid var(--hairline-on-dark);
|
||||
border-bottom: 1px solid var(--hairline-on-dark);
|
||||
}
|
||||
:global(body:not(.dark)) .bsearch {
|
||||
border-top-color: var(--hairline);
|
||||
border-bottom-color: var(--hairline);
|
||||
}
|
||||
.bsearch__input {
|
||||
flex: 1;
|
||||
max-width: 28ch;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 6px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--fg-on-dark-1);
|
||||
letter-spacing: 0.02em;
|
||||
outline: none;
|
||||
transition: border-color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
:global(body:not(.dark)) .bsearch__input {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.bsearch__input::placeholder {
|
||||
color: var(--fg-on-dark-3);
|
||||
}
|
||||
:global(body:not(.dark)) .bsearch__input::placeholder {
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.bsearch__input:focus {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.bsearch__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-on-dark-3);
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-base) var(--ease-out);
|
||||
}
|
||||
:global(body:not(.dark)) .bsearch__hint {
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.bsearch:focus-within .bsearch__hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.bsearch__input {
|
||||
max-width: none;
|
||||
}
|
||||
.bsearch__hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mount the component and add the empty-state element + `.is-hidden` rule in the blog index**
|
||||
|
||||
In `src/pages/blog/index.astro`:
|
||||
|
||||
(a) Inside the `<BaseLayout>` body, place `<BlogSearch />` between the closing `</header>` of `.masthead` and the `{featured && (...)}` block. The resulting structure should look like:
|
||||
|
||||
```astro
|
||||
<header class="masthead">
|
||||
{/* ...unchanged... */}
|
||||
</header>
|
||||
|
||||
<BlogSearch />
|
||||
|
||||
{
|
||||
featured && (
|
||||
<article class="feature" data-featured data-search={searchTextById.get(featured.id) ?? ""}>
|
||||
{/* ...unchanged... */}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
(b) After the closing `</section>` of `.archive`, add the empty-state paragraph:
|
||||
|
||||
```astro
|
||||
<p class="empty" data-empty hidden aria-live="polite">
|
||||
No entries match “<span data-empty-query></span>”.
|
||||
</p>
|
||||
```
|
||||
|
||||
(c) Inside the existing `<style>` block in `index.astro`, add two rules at the end (just before the closing `</style>`):
|
||||
|
||||
```css
|
||||
.is-hidden { display: none !important; }
|
||||
|
||||
.empty {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-on-dark-3);
|
||||
margin: clamp(28px, 5vh, 52px) 0 0;
|
||||
font-feature-settings: "onum" 1;
|
||||
}
|
||||
:global(body:not(.dark)) .empty { color: var(--fg-3); }
|
||||
```
|
||||
|
||||
(The `!important` on `.is-hidden` defends against any future row-level `display` rule overriding it.)
|
||||
|
||||
- [ ] **Step 3: Verify the build and visual placement**
|
||||
|
||||
Run: `npm run build 2>&1 | tail -10`
|
||||
Expected: Build succeeds.
|
||||
|
||||
Run: `grep -c 'class="bsearch"' dist/blog/index.html`
|
||||
Expected: `1`.
|
||||
|
||||
Run: `grep -c 'data-empty' dist/blog/index.html`
|
||||
Expected: `2` (the `<p data-empty>` and the inner `<span data-empty-query>`).
|
||||
|
||||
Run: `npm run dev` (background), open http://localhost:4321/blog, confirm the search input renders between the masthead and the featured article and looks at home with the rest of the page. Typing has no effect yet — that's expected. Then stop the dev server.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/molecules/BlogSearch.astro src/pages/blog/index.astro
|
||||
git commit -m "feat(blog): render search input, empty-state, hidden utility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add the filter script to `BlogSearch`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/molecules/BlogSearch.astro`
|
||||
|
||||
- [ ] **Step 1: Append the `<script>` block**
|
||||
|
||||
At the end of `src/components/molecules/BlogSearch.astro` (after the closing `</style>`), append the filter script. Astro hoists `<script>` blocks and bundles them; this is the canonical way to ship per-component JS.
|
||||
|
||||
```astro
|
||||
<script>
|
||||
// Lives on /blog. No-ops gracefully on any other page (selectors return empty).
|
||||
const input = document.querySelector<HTMLInputElement>(".bsearch__input");
|
||||
if (input) {
|
||||
const rows = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-search]")
|
||||
);
|
||||
const featured = document.querySelector<HTMLElement>("[data-featured]");
|
||||
const yearHeads = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-year-head]")
|
||||
);
|
||||
const empty = document.querySelector<HTMLElement>("[data-empty]");
|
||||
const emptyQuery = empty?.querySelector<HTMLElement>("[data-empty-query]");
|
||||
|
||||
function applyFilter() {
|
||||
const raw = input!.value.toLowerCase().trim();
|
||||
const tokens = raw.length ? raw.split(/\s+/) : [];
|
||||
const isFiltering = tokens.length > 0;
|
||||
|
||||
let visibleCount = 0;
|
||||
for (const row of rows) {
|
||||
const haystack = row.dataset.search ?? "";
|
||||
const isFeatured = row === featured;
|
||||
let visible: boolean;
|
||||
if (!isFiltering) {
|
||||
visible = true;
|
||||
} else if (isFeatured) {
|
||||
// Featured hides whenever there's a query, regardless of match.
|
||||
visible = false;
|
||||
} else {
|
||||
visible = tokens.every((t) => haystack.includes(t));
|
||||
}
|
||||
row.classList.toggle("is-hidden", !visible);
|
||||
if (visible && !isFeatured) visibleCount += 1;
|
||||
}
|
||||
|
||||
// Hide year heads whose following rows are all hidden.
|
||||
for (const head of yearHeads) {
|
||||
let anyVisible = false;
|
||||
let node: Element | null = head.nextElementSibling;
|
||||
while (node && !node.matches("[data-year-head]")) {
|
||||
if (node.matches("[data-search]") && !node.classList.contains("is-hidden")) {
|
||||
anyVisible = true;
|
||||
break;
|
||||
}
|
||||
node = node.nextElementSibling;
|
||||
}
|
||||
head.classList.toggle("is-hidden", !anyVisible);
|
||||
}
|
||||
|
||||
// Toggle empty-state message.
|
||||
if (empty) {
|
||||
const showEmpty = isFiltering && visibleCount === 0;
|
||||
empty.hidden = !showEmpty;
|
||||
if (showEmpty && emptyQuery) emptyQuery.textContent = input!.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", applyFilter);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
A few notes for the engineer:
|
||||
|
||||
- The script runs once at module-evaluation time after the DOM is parsed (Astro hoists `<script>` to the page `<head>` with `type="module"`, which defers execution). No `DOMContentLoaded` guard is needed.
|
||||
- `row.dataset.search` is already lowercased at build time, so we only lowercase the query.
|
||||
- The year-head walk stops at the next `[data-year-head]` or end-of-siblings, so it correctly scopes per group.
|
||||
- The `[data-search]` selector picks up both the featured `<article>` and the post rows; the `isFeatured` branch handles them differently.
|
||||
|
||||
- [ ] **Step 2: Verify by typing in the dev server**
|
||||
|
||||
Run: `npm run dev` (background), open http://localhost:4321/blog.
|
||||
|
||||
Type these queries and confirm:
|
||||
- empty → masthead + featured + full archive visible, no empty-state.
|
||||
- `the` (likely matches multiple posts) → featured hides, only matching rows remain, year heads visible only above visible rows.
|
||||
- `xxxxx-no-match` → featured hides, every row hides, year heads all hide, empty-state appears with the query in quotes.
|
||||
- Backspace to empty → original layout restored exactly.
|
||||
|
||||
Use the browser devtools' Elements panel to verify `.is-hidden` is being toggled (not `style="display:none"`).
|
||||
|
||||
Stop the dev server.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/molecules/BlogSearch.astro
|
||||
git commit -m "feat(blog): live in-page filter via data-search attributes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Keyboard shortcuts (`/` to focus, `Esc` to clear)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/molecules/BlogSearch.astro`
|
||||
|
||||
- [ ] **Step 1: Extend the script with two global key handlers**
|
||||
|
||||
In `src/components/molecules/BlogSearch.astro`, inside the existing `if (input) { ... }` block, **after** the `input.addEventListener("input", applyFilter);` line, append:
|
||||
|
||||
```ts
|
||||
// `/` from anywhere on the page focuses the search input — unless the user
|
||||
// is already typing somewhere editable (text input, textarea, or any
|
||||
// contenteditable element). `Esc` while the input is focused clears the
|
||||
// query, blurs, and restores the full archive.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const editable =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
target?.isContentEditable === true;
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
input!.focus();
|
||||
}
|
||||
} else if (event.key === "Escape" && document.activeElement === input) {
|
||||
if (input!.value !== "") {
|
||||
input!.value = "";
|
||||
applyFilter();
|
||||
}
|
||||
input!.blur();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify keyboard behavior**
|
||||
|
||||
Run: `npm run dev` (background), open http://localhost:4321/blog.
|
||||
|
||||
- Press `/` while focus is on the page body → input gains focus, the `/` character is **not** inserted into the input (the `preventDefault()` blocks it).
|
||||
- Click into the input, type `foo`, press `Esc` → input clears, blurs, archive restored.
|
||||
- Click into the input, leave it empty, press `Esc` → input blurs (no harm done).
|
||||
- Focus the input, type `/` → the `/` should appear in the input (because the editable check skips the shortcut). Clear it.
|
||||
- Press `Cmd+/` or `Ctrl+/` → no focus change (the modifier check blocks it; the browser may show its own shortcut, which is fine).
|
||||
|
||||
Stop the dev server.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/molecules/BlogSearch.astro
|
||||
git commit -m "feat(blog): / focuses search, Esc clears and blurs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Final manual verification pass
|
||||
|
||||
**Files:** none changed; this task is verification only. Do not commit unless a fix surfaces.
|
||||
|
||||
- [ ] **Step 1: Production-build check**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Succeeds with no errors. Look at the printed bundle sizes — the new JS should be well under 2KB (gzipped reported by Astro).
|
||||
|
||||
- [ ] **Step 2: Preview the production build and walk the edge cases**
|
||||
|
||||
Run: `npm run preview` (background), open http://localhost:4321/blog.
|
||||
|
||||
Walk through each item from the spec's edge-cases table:
|
||||
|
||||
- **Markdown syntax in body**: Search for a word you know is inside backticks or near `**bold**` in a post body. It should still match.
|
||||
- **Whitespace-only query**: Type ` ` (one space) → archive fully restored, no empty-state.
|
||||
- **Regex-special characters**: Type `(` then `)` then `*` → no JS error, no matches (or harmless substring matches).
|
||||
- **Single-post / zero-result archive**: Type a query that matches nothing → featured hides, all rows hide, year heads hide, empty-state visible with the typed query rendered between curly quotes.
|
||||
- **Featured filter behavior**: Type any non-empty query → featured `<article>` is always hidden, even if its content matches.
|
||||
- **Year head collapse**: Type a query that matches posts in only one year → year heads for empty years should be hidden.
|
||||
- **JS-disabled fallback**: In devtools, disable JavaScript and reload. Input renders but does nothing; archive is fully visible. No console errors. Re-enable JS.
|
||||
|
||||
- [ ] **Step 3: Light/dark theme parity**
|
||||
|
||||
If the site exposes a way to toggle the `body.dark` class (devtools → Elements → toggle the class), do so and confirm:
|
||||
- Input border, placeholder colour, hint colour, and empty-state colour all flip correctly.
|
||||
- No element renders in an obviously wrong colour in either mode.
|
||||
|
||||
- [ ] **Step 4: Keyboard accessibility**
|
||||
|
||||
- Tab through the page from the top; the input is reachable in document order.
|
||||
- Screen-reader announcement isn't directly testable without VO/NVDA, but confirm in DevTools that the input has `aria-label="Search posts"` and the empty-state `<p>` has `aria-live="polite"`.
|
||||
|
||||
- [ ] **Step 5: Stop the preview server**
|
||||
|
||||
If everything above checks out, the feature is done. If anything fails, fix it in a new commit and re-run this task before reporting complete.
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
After Task 7 passes, the implementation matches the spec. Suggested follow-up message to the user:
|
||||
|
||||
> Blog search is in. Run `npm run dev` and try `/` to focus, `Esc` to clear. If you decide later to scale beyond ~50 posts or want fuzzy match / ranking / excerpt highlighting, the swap-in is Pagefind — the only file that needs to change is `BlogSearch.astro`.
|
||||
|
|
@ -42,7 +42,7 @@ 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.)
|
||||
3. The featured block (marked `data-featured`) is additionally `.is-hidden` whenever the query is non-empty, independent of match.
|
||||
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.
|
||||
|
|
@ -103,7 +103,7 @@ Empty-state `<p>` reuses the `.count` style (mono 11px, `--fg-on-dark-3`).
|
|||
| 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. |
|
||||
| Single-post archive | Featured shows with empty query; non-empty query hides featured and the lone archive entry. Acceptable. |
|
||||
| JavaScript disabled | Input renders but does nothing; full archive visible. No regression. |
|
||||
|
||||
## Theme parity
|
||||
|
|
|
|||
897
package-lock.json
generated
897
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,6 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/mdx": "^4.0.0",
|
||||
"@astrojs/react": "^4.0.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
|
|
@ -21,6 +20,6 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,32 +5,32 @@
|
|||
// Filter script is added in Task 5; this file ships markup + styles only.
|
||||
---
|
||||
|
||||
<div class="bsearch" role="search">
|
||||
<div class="bsearch">
|
||||
<input
|
||||
type="search"
|
||||
class="binput"
|
||||
class="bsearch__input"
|
||||
aria-label="Search posts"
|
||||
placeholder="Search the journal…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="bhint" aria-hidden="true">Esc to clear</span>
|
||||
<span class="bsearch__hint" aria-hidden="true">Esc to clear</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* No border-bottom: the adjacent .feature article carries a border-top
|
||||
that already separates this row from the page below. */
|
||||
.bsearch {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: clamp(14px, 2.4vh, 22px) 0;
|
||||
border-top: 1px solid var(--hairline-on-dark);
|
||||
border-bottom: 1px solid var(--hairline-on-dark);
|
||||
}
|
||||
:global(body:not(.dark)) .bsearch {
|
||||
border-top-color: var(--hairline);
|
||||
border-bottom-color: var(--hairline);
|
||||
}
|
||||
.binput {
|
||||
.bsearch__input {
|
||||
flex: 1;
|
||||
max-width: 28ch;
|
||||
background: transparent;
|
||||
|
|
@ -44,19 +44,19 @@
|
|||
outline: none;
|
||||
transition: border-color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
:global(body:not(.dark)) .binput {
|
||||
:global(body:not(.dark)) .bsearch__input {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.binput::placeholder {
|
||||
.bsearch__input::placeholder {
|
||||
color: var(--fg-on-dark-3);
|
||||
}
|
||||
:global(body:not(.dark)) .binput::placeholder {
|
||||
:global(body:not(.dark)) .bsearch__input::placeholder {
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.binput:focus {
|
||||
.bsearch__input:focus {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.bhint {
|
||||
.bsearch__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-on-dark-3);
|
||||
|
|
@ -64,100 +64,19 @@
|
|||
opacity: 0;
|
||||
transition: opacity var(--dur-base) var(--ease-out);
|
||||
}
|
||||
:global(body:not(.dark)) .bhint {
|
||||
:global(body:not(.dark)) .bsearch__hint {
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.bsearch:focus-within .bhint {
|
||||
.bsearch:focus-within .bsearch__hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.binput {
|
||||
.bsearch__input {
|
||||
max-width: none;
|
||||
}
|
||||
.bhint {
|
||||
.bsearch__hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Lives on /blog. No-ops gracefully on any other page (selectors return empty).
|
||||
const input = document.querySelector<HTMLInputElement>(".binput");
|
||||
if (input) {
|
||||
const rows = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-search]")
|
||||
);
|
||||
const yearHeads = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-year-head]")
|
||||
);
|
||||
const empty = document.querySelector<HTMLElement>("[data-empty]");
|
||||
const emptyQuery = empty?.querySelector<HTMLElement>("[data-empty-query]");
|
||||
|
||||
function applyFilter() {
|
||||
const raw = input!.value.toLowerCase().trim();
|
||||
const tokens = raw.length ? raw.split(/\s+/) : [];
|
||||
const isFiltering = tokens.length > 0;
|
||||
|
||||
let visibleCount = 0;
|
||||
for (const row of rows) {
|
||||
const haystack = row.dataset.search ?? "";
|
||||
// The featured article is just another [data-search] row: it's
|
||||
// visible iff it matches the query, same as every archive row.
|
||||
const visible = !isFiltering || tokens.every((t) => haystack.includes(t));
|
||||
row.classList.toggle("is-hidden", !visible);
|
||||
if (visible) visibleCount += 1;
|
||||
}
|
||||
|
||||
// Hide year heads whose following rows are all hidden.
|
||||
for (const head of yearHeads) {
|
||||
let anyVisible = false;
|
||||
let node: Element | null = head.nextElementSibling;
|
||||
while (node && !node.matches("[data-year-head]")) {
|
||||
if (node.matches("[data-search]") && !node.classList.contains("is-hidden")) {
|
||||
anyVisible = true;
|
||||
break;
|
||||
}
|
||||
node = node.nextElementSibling;
|
||||
}
|
||||
head.classList.toggle("is-hidden", !anyVisible);
|
||||
}
|
||||
|
||||
// Toggle empty-state message.
|
||||
if (empty) {
|
||||
const showEmpty = isFiltering && visibleCount === 0;
|
||||
empty.hidden = !showEmpty;
|
||||
if (showEmpty && emptyQuery) emptyQuery.textContent = input!.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", applyFilter);
|
||||
|
||||
// `/` from anywhere on the page focuses the search input — unless the user
|
||||
// is already typing somewhere editable (text input, textarea, or any
|
||||
// contenteditable element). `Esc` while the input is focused clears the
|
||||
// query, blurs, and restores the full archive.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.isComposing) return;
|
||||
if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const editable =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target?.isContentEditable === true;
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
input!.focus();
|
||||
}
|
||||
} else if (event.key === "Escape" && document.activeElement === input) {
|
||||
if (input!.value !== "") {
|
||||
input!.value = "";
|
||||
applyFilter();
|
||||
}
|
||||
input!.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function buildSearchText(post: (typeof posts)[number]): string {
|
|||
return parts
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.replace(/[<>#*_`\[\]()]/g, " ")
|
||||
.replace(/[#*_>`\[\]()]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ for (const post of posts) {
|
|||
</section>
|
||||
|
||||
<p class="empty" data-empty hidden aria-live="polite">
|
||||
No entries match “<span data-empty-query></span>”.
|
||||
No entries match "<span data-empty-query></span>".
|
||||
</p>
|
||||
</BaseLayout>
|
||||
|
||||
|
|
@ -265,9 +265,7 @@ for (const post of posts) {
|
|||
.feature .img { order: -1; aspect-ratio: 16 / 9; }
|
||||
}
|
||||
|
||||
/* :global so the runtime-toggled class reaches PostRow and YearHead roots,
|
||||
which carry their own component scope hashes — not this page's. */
|
||||
:global(.is-hidden) { display: none !important; }
|
||||
.is-hidden { display: none !important; }
|
||||
|
||||
.empty {
|
||||
font-family: var(--font-mono);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue