offplan · online
Plan · preview-search

Preview-site search (P11f)

Approvedplanpreview-search
Owner
roman
Ratified
2026-05-11
Created
2026-05-11
Tags
ops, ux, infra, performance

Goal

Ship static client-side keyword search across every canonical-store artefact (~200 entries at saturation) at preview.offplan.online/search/. Zero infra: no vector store, no embedding API, no server. One JSON manifest emitted at build time, one HTML page with vanilla JavaScript. The bar is "I half-remember a phrase but not where" — getting from query keystroke to clicking the right result in under five seconds on a phone, with no logged-in state.

Success Criteria

(verbatim in frontmatter above)

Translated to operational checks:

  1. Speed. Manifest fetch + first render in <500 ms on a phone (LTE); search latency <100 ms per keystroke; build script completes in <2 s.
  2. Discoverability. / keyboard shortcut works on desktop; sticky search field works on mobile; URL hash deep-links work.
  3. Quality of results. Snippet highlighting + up-to-3-snippets per row + inline "replaced by" links on superseded hits + date-decay and grouping toggles all behave as specified.
  4. Power-user syntax. kind:plan auth flow filters to plan-kind entries containing both tokens; "exact phrase" matches the literal sequence; author:roman status:approved composes with chip state.
  5. Build hygiene. Manifest is idempotent (same input → same bytes); pre-commit hook keeps it fresh; unit tests cover the indexer.

Approach

Two-file ship, no framework. One Python build script writes docs/rendered/search/manifest.json; one HTML page at docs/rendered/search/index.html loads it on boot. Vanilla JS. Inline <style> (matches render_md.py + render_about.py pattern; no separate CSS file at this scale). Visual register matches the rest of the preview site (Skeleton White, Helvetica Neue 200–300, Inter body, JetBrains Mono mono, oxidised pills).

Matcher choice: homegrown ~80-line scorer, not vendored fuzzysort. Reasons:

Design decisions captured in /plan interview (2026-05-11):

Decision Choice
Date-decay scoring Configurable toggle in UI (default on; recent boost is usually right)
Result grouping default Toggle in UI (default flat-by-score; switch to per-kind grouping)
Snippet rendering Up to 3 snippets per result (more context worth the extra height)
Empty state Curated suggested searches (4–6 hand-picked queries set the tone)
Supersedes link in results Yes, inline '→ replaced by X' link on row
Query syntax Full set: kind:, status:, author:, "quoted phrases"
Recent queries Yes — last 5 above curated (localStorage; cheap polish)
Mobile focus Sticky search field always visible (no FAB; consistent with desktop)

Manifest is committed, not gitignored. Re-emit on every commit that touches a source .md via the pre-commit hook (Gate 4). Two reasons: (1) preview.offplan.online deploys from the committed docs/rendered/ directory — there's no CI build step that could regenerate it; (2) committing makes the manifest reviewable in diffs and surfaces accidental breakages early.

No service-worker, no offline cache. Static page; browser HTTP cache is sufficient. Re-evaluate if manifest exceeds 2 MB (we'll be far past saturation by then).

Implementation Steps

Phase F1 — Manifest schema + builder

scripts/build_search_index.py:

  1. CLI: --all (default; full rebuild) or --touched <file ...> (incremental — currently writes whole manifest regardless, but signature matches /handoff Step 4.5 contract for future incremental optimisation).
  2. Walk the canonical store:
    • plans/*.md (excluding INDEX.md, TEMPLATE.md, CHANGELOG.md, anything under plans/done/)
    • workstreams/*.md (same exclusions, including workstreams/done/)
    • docs/sessions/*.md (excluding TEMPLATE.md, INDEX.md)
    • docs/decisions/*.md
    • docs/conventions/*.md
    • docs/learnings/*.md (folder may not exist; handle gracefully)
    • Skip .claude/memory/* (internal context, not public content — verified with peer 2026-05-11)
  3. For each file, read via scripts/lib/frontmatter.py (the canonical parser); emit one entry:
{
  "slug": "<file stem>",
  "kind": "plan|workstream|session|decision|convention|learning",
  "status": "<status: or null>",
  "title": "<H1 of body, fallback to frontmatter name:>",
  "summary": "<frontmatter summary: or empty>",
  "headings": ["H2: Goal", "H2: Approach", "H3: ..."],
  "body_excerpt": "<first ~400 chars of body, code-fences stripped, no frontmatter>",
  "tags": ["..."],
  "plans_touched": ["..."],
  "workstreams_touched": ["..."],
  "adrs_touched": ["..."],
  "supersedes": "<frontmatter supersedes: or null>",
  "superseded_by": "<computed: which file declares supersedes=this; or null>",
  "author": "<frontmatter owner: or 'unknown'>",
  "updated_date": "<git last-commit ISO date for this file>",
  "render_url": "/<slug>.html",
  "about_url": "/<slug>/"
}
  1. Output: docs/rendered/search/manifest.json + a small docs/rendered/search/manifest.meta.json with {generated_at, source_count, total_bytes} for diagnostics.
  2. Idempotency: byte-identical output on re-run (sort entries by slug; sort arrays; pretty-print with stable separators).
  3. Unit tests: tests/test_build_search_index.py (10 tests — frontmatter parsing, code-fence stripping, supersedes back-resolution, sort stability, empty-folder handling, missing frontmatter graceful fallback, headings extraction, byte-identical re-run, exclusion paths, author fallback).

Phase F2 — Search page HTML + matcher

docs/rendered/search/index.html:

  1. <head> — same canonical CSS as render_md.py / render_about.py (copy verbatim). Page-specific styles inlined.
  2. Body layout:
    • Sticky header: input field (full-width on mobile, capped at 720 px on desktop), filter chips below, settings toggles (date-decay on/off, grouping flat/by-kind) on the right.
    • Empty-state region (shown when input is empty).
    • Results region.
  3. Matcher (vanilla JS, ~80 lines):
    • Tokenise input. Recognise inline filters: kind:plan, status:approved, author:roman, and "quoted phrase". Strip these into a filter object before tokenising the remainder as plain keywords.
    • Score each manifest entry: per-token, sum across fields with weights — title ×3, summary ×2, headings ×2, body_excerpt ×1, tags ×2, slug ×3. Multi-token queries require ALL tokens to be present somewhere.
    • Date-decay (when toggle is on): multiply final score by exp(-Δdays / 90) so a 90-day-old artefact's score halves. Toggle via UI checkbox.
    • Filter by chip state AND inline-filter state combined.
    • Group when grouping toggle is on: bucket results by kind, sort each bucket internally by score.
  4. Result rendering:
    • Each row: title (link to about_url falling back to render_url); kind + status pills; updated_date; author; up to 3 snippets (best-scoring contiguous ~120-char windows containing matched tokens; matches wrapped in <mark>).
    • If supersedes is set: dim the title slightly; show inline → replaced by <successor> link wired to the successor's about_url. Title still clickable to view the superseded artefact.
    • No-results: "No matches for <query>" + "Clear filters" link if any chips active.
  5. Empty state:
    • "Recent: <q1> · <q2> · <q3> · <q4> · <q5>" line from localStorage['p11f.recent'] (max 5; last in front).
    • "Try:" curated suggested searches in a 2-column grid (4–6 entries hand-picked; initial list: launch plan, admin panel, stripe, canonical store, obsidian, P11e). Hardcoded in the HTML.
  6. Keyboard:
    • / focuses the input from anywhere on the page (intercepted only when input is not already focused).
    • / move selection through results.
    • Enter opens selected result.
    • Esc clears input (or blurs if already empty).
  7. URL hash: #q=<encoded query>&kind=<csv>&status=<csv>&author=<csv>&tag=<csv>&decay=<on|off>&group=<flat|kind>. Read on page load; write on every state change (debounced 150 ms).
  8. Recent-query persistence: on successful Enter into a result, push the query string to localStorage['p11f.recent'] (deduplicate, cap at 5).

Phase F3 — Pre-commit Gate 4 + handoff Step 4.5 wiring

  1. scripts/hooks/pre-commit Gate 4 — after Gates 1 (Notion-zero), 2 (frontmatter), 3 (wikilink audit): if any staged *.md lives in the indexed paths AND/OR scripts/build_search_index.py itself is staged, run the builder and git add docs/rendered/search/manifest.json docs/rendered/search/manifest.meta.json. Soft-fail (warn, don't block) on builder errors.
  2. .claude/commands/handoff.md Step 4.5 — extend the regen-indexes call site to also invoke python3 scripts/build_search_index.py --touched <files_touched_this_session>. Same soft-fail behaviour.
  3. Cross-platform Bash 3 compat — match the pattern set by Gate 2's pre-commit fix (no mapfile).

Phase F4 — Cross-page search affordance

The search page is useless if nobody knows it exists. Add a small "🔎 Search" link to:

  1. scripts/build-rendered-index.py — top-right of the header, near the filter chips. Add inside the existing P11e bracket if cleanest, or in a new clearly-commented # === P11f search link === block.
  2. scripts/render_about.py — same link in the about-page footer or header (author's call which fits better visually).
  3. scripts/render_md.py — already emits a back-to-index link; extend it to also link to search. Tiny change.

Phase F5 — One-pass spot check + smoke test

After F1–F4 ship:

  1. Build manifest with --all; verify it weighs <1 MB (~200 entries × ~5 KB each ≈ 1 MB; we're not there yet, so expect ~100–300 KB now).
  2. Spot-check 5 queries against expected results:
    • stripe (should return Notion-sync + billing-related artefacts)
    • P11f (should return this plan + the vault workstream + the brief-expansion session)
    • forge (should return forge-vault-setup workstream + the F1–F7 work)
    • CONV-30 (should return the legacy CONV-30 trinity if their body_excerpt mentions it)
    • kind:plan canonical (should return repo-as-canonical-store plan + this preview-search plan)
  3. Mobile sanity check via Chrome DevTools MCP (responsive viewport): sticky field stays sticky; tap-to-focus works; results are readable at ~360 px wide.
  4. Idempotency proof: run builder twice, assert git status is empty after the second.

Files

New:

Modified:

Dependencies

No external dependencies (no npm packages, no Python deps beyond stdlib).

Testing

  1. tests/test_build_search_index.py — unit-level coverage of the indexer.
  2. Spot-check queries (manual, Phase F5) — 5 specific queries with expected result shape.
  3. Cross-platform Bash compat — pre-commit hook tested on Roman's Mac (Bash 3 system) + Forge (Bash 5 via Homebrew).
  4. Mobile rendering — Chrome DevTools MCP responsive viewport pass.

No end-to-end browser-test framework. The page is small enough to verify by eye + DevTools. If the page grows past ~600 LOC of JS, revisit with a Playwright smoke test.

Workstreams

One workstream: workstreams/preview-search-p11f.md (P1, plan-gated creation per CONV-6780). Consumes all phases F1–F5. Roman or Sergei to /build over 1–2 sessions.

Risks

Evaluation

Two weeks after ship (target 2026-05-25):

Out of scope (deliberate)

See Also