From cc04ea7753940fccb7316c78623e02f2b98846a1 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 00:08:25 +1000 Subject: [PATCH 01/12] feat(blog): PostRow accepts searchText, emits data-search --- src/components/molecules/PostRow.astro | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/molecules/PostRow.astro b/src/components/molecules/PostRow.astro index 558b926..c05c4cc 100644 --- a/src/components/molecules/PostRow.astro +++ b/src/components/molecules/PostRow.astro @@ -5,12 +5,13 @@ interface Props { href: string; deck?: string; tag?: string; + searchText: string; } -const { date, title, href, deck, tag } = Astro.props; +const { date, title, href, deck, tag, searchText } = Astro.props; --- - + {date} {title} From 91582076575049969d7d11f38545cf3bf3d71fd7 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 00:10:25 +1000 Subject: [PATCH 02/12] chore(blog): reorder PostRow props to group required before optional Code review nit: searchText (required) was appended after the optional deck/tag, breaking the required-then-optional convention used by the rest of the interface. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/molecules/PostRow.astro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/molecules/PostRow.astro b/src/components/molecules/PostRow.astro index c05c4cc..a7fec85 100644 --- a/src/components/molecules/PostRow.astro +++ b/src/components/molecules/PostRow.astro @@ -3,12 +3,12 @@ interface Props { date: string; title: string; href: string; + searchText: string; deck?: string; tag?: string; - searchText: string; } -const { date, title, href, deck, tag, searchText } = Astro.props; +const { date, title, href, searchText, deck, tag } = Astro.props; --- From 44d4d327a1935d62bbe0ef5cece1055f219579c9 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 00:18:20 +1000 Subject: [PATCH 03/12] feat(blog): YearHead emits data-year-head for filter targeting --- src/components/molecules/YearHead.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/molecules/YearHead.astro b/src/components/molecules/YearHead.astro index b388b5d..dbd3555 100644 --- a/src/components/molecules/YearHead.astro +++ b/src/components/molecules/YearHead.astro @@ -8,7 +8,7 @@ const { year, count } = Astro.props; const formatted = String(count).padStart(2, "0"); --- -
+
{year} {formatted} From 5ac622e576337457bb9f2e8813f427506e5027ed Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 00:21:34 +1000 Subject: [PATCH 04/12] feat(blog): build per-post searchText, wire to PostRow and featured Co-Authored-By: Claude Sonnet 4.6 --- src/components/molecules/BlogSearch.astro | 3 +++ src/pages/blog/index.astro | 30 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/components/molecules/BlogSearch.astro diff --git a/src/components/molecules/BlogSearch.astro b/src/components/molecules/BlogSearch.astro new file mode 100644 index 0000000..e39a11d --- /dev/null +++ b/src/components/molecules/BlogSearch.astro @@ -0,0 +1,3 @@ +--- +// Stub — real implementation lands in Task 4. +--- diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 36fc6f2..9fa00a2 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -5,6 +5,7 @@ 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"; @@ -25,6 +26,32 @@ for (const post of rest) { 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(); +for (const post of posts) { + searchTextById.set(post.id, buildSearchText(post)); +} --- b - a); { featured && ( -
+
@@ -91,6 +118,7 @@ const yearGroups = Array.from(byYear.entries()).sort(([a], [b]) => b - a); href={blog(`/posts/${post.id}/`)} deck={post.data.standfirst} tag={post.data.tag} + searchText={searchTextById.get(post.id) ?? ""} /> ))} From d7dc22d0c89ab8389072281a095323c26a257ad6 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 01:10:27 +1000 Subject: [PATCH 05/12] feat(blog): render search input, empty-state, hidden utility Co-Authored-By: Claude Sonnet 4.6 --- src/components/molecules/BlogSearch.astro | 81 ++++++++++++++++++++++- src/pages/blog/index.astro | 18 +++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/components/molecules/BlogSearch.astro b/src/components/molecules/BlogSearch.astro index e39a11d..c694e11 100644 --- a/src/components/molecules/BlogSearch.astro +++ b/src/components/molecules/BlogSearch.astro @@ -1,3 +1,82 @@ --- -// Stub — real implementation lands in Task 4. +// 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. --- + +
+ + +
+ + diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 9fa00a2..14329fc 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -78,6 +78,8 @@ for (const post of posts) {

+ + { featured && (
@@ -125,6 +127,10 @@ for (const post of posts) { )) } + + From 8f12b3afe5c201812a85bb8a13b4401264289fbf Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 01:17:04 +1000 Subject: [PATCH 06/12] fix(blog): use curly quotes around empty-state query span Spec called for U+201C / U+201D; commit d7dc22d landed ASCII quotes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/blog/index.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 14329fc..5f2a80d 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -129,7 +129,7 @@ for (const post of posts) { From 8423759f03ac51bb94d9db154b3ec3aecc5ac6d4 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 01:25:26 +1000 Subject: [PATCH 07/12] fix(blog): drop duplicated hairline and align class names Code review nits: - .bsearch border-bottom + .feature border-top rendered a doubled hairline; removed the bottom border (feature's top is sufficient). - Renamed bsearch__input / bsearch__hint to binput / bhint to match the project's flat single-class convention (postrow, pdate, yr, ln). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/molecules/BlogSearch.astro | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/molecules/BlogSearch.astro b/src/components/molecules/BlogSearch.astro index c694e11..3e6763b 100644 --- a/src/components/molecules/BlogSearch.astro +++ b/src/components/molecules/BlogSearch.astro @@ -8,29 +8,29 @@
- +
+ + From 963ea563147b994c2d8a84ba95f9558b0591f167 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 01:32:08 +1000 Subject: [PATCH 09/12] feat(blog): / focuses search, Esc clears and blurs Co-Authored-By: Claude Sonnet 4.6 --- src/components/molecules/BlogSearch.astro | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/components/molecules/BlogSearch.astro b/src/components/molecules/BlogSearch.astro index 991d398..c7b461e 100644 --- a/src/components/molecules/BlogSearch.astro +++ b/src/components/molecules/BlogSearch.astro @@ -140,5 +140,30 @@ } 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.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(); + } + }); } From 75e78591461713111c2abed604c4ef24afa6353f Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 01:35:53 +1000 Subject: [PATCH 10/12] chore(blog): guard keydown for IME composition and as editable so / doesn't steal focus while a dropdown is open and the user is typing to seek. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/molecules/BlogSearch.astro | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/molecules/BlogSearch.astro b/src/components/molecules/BlogSearch.astro index c7b461e..d38b1e5 100644 --- a/src/components/molecules/BlogSearch.astro +++ b/src/components/molecules/BlogSearch.astro @@ -146,12 +146,14 @@ // 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(); From d80cd255d4d0a16788b84f9b85333e9a437610b8 Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Tue, 2 Jun 2026 01:45:27 +1000 Subject: [PATCH 11/12] fix(blog): make .is-hidden global, strip HTML tags from search text, add role=search Final-review findings: - .is-hidden was scoped to blog/index.astro, so it didn't match PostRow or YearHead roots (which carry their own component scope hashes). Net effect: the filter visibly only hid the featured article, while all archive rows + year heads stayed on screen. Wrap in :global() so the runtime-toggled class actually fires its display rule everywhere. - buildSearchText regex stripped > but not <, so inline HTML open-tags like leaked into data-search. Add < to the character class so the haystack stays plain text. - Add role="search" landmark to the BlogSearch wrapper so screen-reader users can navigate to it directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/molecules/BlogSearch.astro | 2 +- src/pages/blog/index.astro | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/molecules/BlogSearch.astro b/src/components/molecules/BlogSearch.astro index d38b1e5..0aad4ac 100644 --- a/src/components/molecules/BlogSearch.astro +++ b/src/components/molecules/BlogSearch.astro @@ -5,7 +5,7 @@ // Filter script is added in Task 5; this file ships markup + styles only. --- -
+