Commit 7cab2544 authored by Tom Behets's avatar Tom Behets
Browse files

Add spec for fuzzy-search-fuse-01KR54PR

Replace FlexSearch with Fuse.js for typo-tolerant fuzzy search on the
/search/ page. Spec covers: 18 functional requirements (engine swap,
typo tolerance, mid-word substring matches, dedupe, snippet centering
on Fuse match indices, <mark> highlighting, debounce/threshold/toggle
preservation, ?q= URL seeding, removal of flexsearch.bundle.js), 6
NFRs with thresholds (<=300 ms keystroke-to-render, <=25 KB gzipped
bundle, no console errors, keyboard a11y, XSS safety, no first-paint
block), and 7 constraints (single library, vendored UMD, license,
template files left untouched, bulk-edit mode).

change_mode set to bulk_edit; occurrence_map.yaml will be produced
during plan.

Co-Authored-By: default avatarClaude Opus 4.7 (1M context) <noreply@anthropic.com>
parent c0d6a3c5
Loading
Loading
Loading
Loading
+45
Changes for kitty-specs/fuzzy-search-fuse-01KR54PR/checklists/requirements.md: 45 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Specification Quality Checklist: Fuse.js fuzzy search rewrite

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-09
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
  - Note: Fuse.js and FlexSearch are named because the spec is *about* swapping libraries. Library identity is the requirement, not an implementation detail leaking from another concern.
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
  - Caveat: this is a developer-facing infrastructure swap, so the readership is partly technical by nature.
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Requirement types are separated (Functional / Non-Functional / Constraints)
- [x] IDs are unique across FR-###, NFR-###, and C-### entries
- [x] All requirement rows include a non-empty Status value
- [x] Non-functional requirements include measurable thresholds
  - NFR-001: ≤ 300 ms keystroke-to-render
  - NFR-002: ≤ 25 KB gzipped Fuse bundle
  - NFR-003: named browsers, no console errors
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic
  - Caveat: "no `flexsearch` references" is by design technology-named; the rest are user-observable.
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification beyond the named library swap

## Notes

- Bulk-edit mission: `change_mode: bulk_edit` set in `meta.json`. `occurrence_map.yaml` is a plan-phase artifact and will enumerate every `FlexSearch` / `flexsearch` occurrence at that point.
- The Fuse threshold (~0.4) and weighted keys (title 3 / tags 2 / categories 2 / contents 1) are recorded in the spec as initial values; they are explicitly allowed to be tuned during implementation without amending the spec.
+4 −1
Changes for kitty-specs/fuzzy-search-fuse-01KR54PR/meta.json: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -8,5 +8,8 @@
  "purpose_context": "The /search/ page currently uses FlexSearch with token-prefix matching, so visitors who mistype a query (e.g. 'wokrshop') see no results. This mission swaps in Fuse.js as the single search engine, keeps every existing behavior (index load, debounce, past-events filter, ?q= seeding, accessible status), and adds <mark> highlighting on snippet matches.",
  "purpose_tldr": "Replace FlexSearch with Fuse.js so the site search tolerates typos.",
  "slug": "fuzzy-search-fuse-01KR54PR",
  "target_branch": "main"
  "target_branch": "main",
  "vcs": "git",
  "change_mode": "bulk_edit",
  "source_description": "User request via /spec-kitty.specify: replace FlexSearch (tokenize:'forward') with Fuse.js for typo-tolerant fuzzy search on /search/, preserve all current behavior, add <mark> match highlighting in snippets."
}
+128
Changes for kitty-specs/fuzzy-search-fuse-01KR54PR/spec.md: 128 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Specification: Fuse.js fuzzy search rewrite

**Mission slug:** `fuzzy-search-fuse-01KR54PR`
**Mission ID:** `01KR54PR83MM26FD4G7Y2HA3JG`
**Mission type:** software-dev
**Target branch:** `main`
**Status:** Draft

## Purpose

### TLDR

Replace FlexSearch with Fuse.js so the `/search` page tolerates typos (`wokrshop` → workshop, `kicd` → kicad) without sacrificing any of the existing live-search behavior, and visually highlight the matched substring in each snippet.

### Context

The mission `site-search-01KR36Y1` shipped a working FlexSearch-backed `/search` page. FlexSearch's `tokenize: "forward"` configuration only matches token *prefixes*, so typos and middle-of-word matches return zero results. The site's content is small enough that a Bitap-based fuzzy engine (Fuse.js) is fast enough on the full corpus and gives every query typo tolerance out of the box. This mission swaps engines, preserves every observable feature of the current page, and adds `<mark>`-wrapped match highlighting in the snippet.

## User Scenarios & Testing

### Primary user story

A visitor lands on `/search` (or follows the sidebar search box on any page, which redirects to `/search/?q=…`) and types a query. Their query may contain typos, transposed letters, or partial words. Within the same debounce window as today, they see a ranked list of matching pages, each with the matched substring visibly highlighted inside the snippet. The past-events toggle, the JS-disabled fallback, and the loading/error states all behave exactly as they did before.

### Acceptance Scenarios

1. **Given** the index has loaded on `/search`, **when** the visitor types a query that matches a page title or body text exactly, **then** that page appears in the result list with the matched characters wrapped in `<mark>` inside the snippet.
2. **Given** the index has loaded, **when** the visitor types a query with a small typo (one or two transposed/missing/extra letters in a short word, e.g. `wokrshop`, `kicd`, `soldring`), **then** the page that contains the intended term appears in the result list.
3. **Given** the index has loaded, **when** the visitor types a fragment that occurs in the middle of a word (e.g. `shop` for `workshop`), **then** the containing page appears in the result list (no token-prefix limitation).
4. **Given** the visitor has run a query and the past-events toggle is on (default), **when** the result list contains both past events and current pages, **then** all of them are shown.
5. **Given** the visitor unchecks the past-events toggle, **when** the toggle changes, **then** past events disappear from the visible result list without re-running the search and without reloading the page.
6. **Given** the visitor types a query of fewer than 2 characters, **when** the input event fires, **then** no search runs and the result area remains in its placeholder state.
7. **Given** the index has not yet loaded, **when** the visitor focuses the input, **then** the input is disabled and the status area says the index is loading.
8. **Given** the index fetch fails, **when** the failure is observed, **then** the status area shows a non-silent error message and the input remains disabled.
9. **Given** a visitor follows a link of the form `/search/?q=foo` from the sidebar search box, **when** the page finishes loading the index, **then** the search is run automatically with `foo` and the input is pre-filled.
10. **Given** the visitor has JavaScript disabled, **when** they open `/search`, **then** the page renders the existing `<noscript>` block listing the top-level sections, exactly as it does today.

### Edge Cases

- A query yields zero results even after fuzzy matching. The status area shows a clear "No results for `…`" message and no result rows are rendered.
- A query string contains regex-significant or HTML-significant characters (`<`, `>`, `&`, quotes). Highlighting and snippet rendering MUST remain XSS-safe — text from the index is never injected as HTML.
- A page has no body text (front-matter only, e.g. a redirect stub). It can still match by title and shows an empty or near-empty snippet without errors.
- A page contains accented or non-ASCII characters (Belgian, French, Dutch names). Fuzzy matching tolerates a normalised form (e.g. `cafe` matches `café`) at least as well as the previous FlexSearch behavior; no regressions are acceptable.
- A query that previously returned results under FlexSearch MUST continue to return at least the same page in the result list under Fuse.js (no exact-match regressions).
- A very long query (e.g. a pasted paragraph) MUST not crash or hang the page; Fuse may return zero results and that is acceptable.
- Multiple distinct matches inside one snippet MUST each be wrapped in `<mark>`, with no overlapping or broken tags.

## Requirements

### Functional Requirements

| ID | Requirement | Status |
|----|-------------|--------|
| FR-001 | The `/search` page MUST use Fuse.js as the only client-side search engine. | Confirmed |
| FR-002 | A query that matches a token prefix today (FlexSearch "forward" behavior) MUST still return at least the same matching page under Fuse.js. | Confirmed |
| FR-003 | Queries containing small typos (one or two character edits in a short word) MUST return the intended page when that page contains the target term. | Confirmed |
| FR-004 | Queries that match a substring in the middle of a word MUST return the containing page. | Confirmed |
| FR-005 | The result list MUST be deduplicated by page URL even when a query matches multiple indexed fields of the same page. | Confirmed |
| FR-006 | Each result row MUST display the page title, section label, snippet, and link, identical to the existing layout. | Confirmed |
| FR-007 | The snippet MUST be centered on the actual matched substring as reported by Fuse's `includeMatches` output, not on a naïve `indexOf(query)` lookup. | Confirmed |
| FR-008 | Each matched substring inside the rendered snippet MUST be wrapped in a `<mark>` element. The text content of `<mark>` MUST be set via DOM `textContent` to remain XSS-safe. | Confirmed |
| FR-009 | Multiple non-overlapping matches inside a single snippet MUST each be wrapped in their own `<mark>` element. Overlapping match indices MUST be coalesced into a single highlighted run. | Confirmed |
| FR-010 | The minimum-query-length threshold (2 characters) MUST be preserved. Queries shorter than 2 characters MUST NOT trigger a search. | Confirmed |
| FR-011 | The input-debounce window (150 ms) MUST be preserved. | Confirmed |
| FR-012 | The past-events filter and `#include-past` checkbox MUST continue to hide events whose `startdate` is before today by default and reveal them when checked. | Confirmed |
| FR-013 | The toggle MUST re-render the existing result list without re-running the Fuse search. | Confirmed |
| FR-014 | The page MUST seed and auto-run a search from the `?q=…` query string parameter on first load (sidebar search box flow). | Confirmed |
| FR-015 | The page MUST keep the existing aria-live status updates: "Loading search index…", "Type at least 2 characters to search.", "N results for …", "No results for …", and the index-load error message. | Confirmed |
| FR-016 | The page MUST keep the existing `<noscript>` fallback behavior unchanged. | Confirmed |
| FR-017 | The vendored bundle MUST be served from `static/js/fuse.bundle.js` and loaded by `layouts/search/list.html` via a plain `<script defer>` tag, mirroring how FlexSearch is loaded today. | Confirmed |
| FR-018 | After this mission, no source file in the repository MAY reference `FlexSearch` or `flexsearch` (case-insensitive), and `static/js/flexsearch.bundle.js` MUST be removed. | Confirmed |

### Non-Functional Requirements

| ID | Requirement | Status |
|----|-------------|--------|
| NFR-001 | After the index is loaded, the time from the visitor's last keystroke to the first rendered result row MUST be ≤ 300 ms on a recent desktop browser for the current site's corpus. | Confirmed |
| NFR-002 | The vendored Fuse.js bundle SHOULD be ≤ 25 KB gzipped. If a current Fuse release exceeds this, planning MUST justify the size or pick a smaller build target. | Confirmed |
| NFR-003 | The `/search` page MUST function in current stable versions of Firefox, Chrome, Safari, and Edge with no console errors. | Confirmed |
| NFR-004 | Keyboard-only operation MUST be preserved: input reachable by tab, result rows focusable in document order, no focus traps introduced by `<mark>` wrapping. | Confirmed |
| NFR-005 | Highlighted snippets MUST remain XSS-safe: no value from `/index.json` is ever injected via `innerHTML`. | Confirmed |
| NFR-006 | The page MUST NOT block first paint waiting for either Fuse.js or `/index.json`. The input MUST become interactive only after the index loads, the same as today. | Confirmed |

### Constraints

| ID | Constraint | Status |
|----|------------|--------|
| C-001 | The implementation MUST use Fuse.js as the single client-side search library. FlexSearch MUST NOT be retained in any form. | Confirmed |
| C-002 | Fuse.js MUST be vendored statically as `static/js/fuse.bundle.js`. No new build tool, bundler, or npm-based asset pipeline may be introduced by this mission. | Confirmed |
| C-003 | The Fuse.js release vendored MUST carry a license compatible with redistribution in this repository (Apache-2.0 or MIT, whichever the upstream project ships). The license file or header MUST be retained inside the vendored bundle. | Confirmed |
| C-004 | The Hugo template `themes/code-editor/layouts/_default/index.json` MUST NOT be modified. The on-disk shape of `/index.json` is fixed for this mission. | Confirmed |
| C-005 | The sidebar search form in `themes/code-editor/layouts/partials/menu.html` MUST NOT be modified. The `?q=` flow into `/search/` is the contract this mission preserves. | Confirmed |
| C-006 | The existing site-level override `layouts/search/list.html` is the only HTML/template file that may be edited; the `<form>`, `<input>`, `#include-past`, `#search-status`, and `#results` element IDs MUST remain stable. | Confirmed |
| C-007 | This is a bulk-edit mission per DIRECTIVE_035: every occurrence of `FlexSearch` / `flexsearch` in the repo is in scope for replacement or removal. An `occurrence_map.yaml` is produced during plan. | Confirmed |

## Key Entities

- **Search Index** — The static `/index.json` artifact produced by Hugo. Shape unchanged by this mission; only how it is consumed changes.
- **Search Query** — Free-text input from the visitor. Now interpreted by Fuse.js with typo tolerance.
- **Fuse Match Record** — The `{indices: [[start, end], …], value}` entries Fuse.js returns when `includeMatches: true`. The renderer uses these indices to build `<mark>`-wrapped snippet HTML.
- **Past-Events Toggle** — Unchanged: `#include-past` checkbox; `state.includePast` controls re-render.

## Success Criteria

- Typing `wokrshop`, `kicd`, or `soldring` on `/search` returns the corresponding HSBXL page in the result list.
- A query that returned a result under the previous FlexSearch implementation still returns at least that page after the swap (no regression on exact matches).
- The matched substring in each snippet is visually distinguishable (default browser `<mark>` styling is sufficient).
- A visitor with JavaScript disabled sees the same `<noscript>` block as before.
- A search reached via the sidebar search box (`/search/?q=foo`) auto-runs on page load, just as it does today.
- Repository-wide `grep -ri 'flexsearch'` after the mission returns zero hits in tracked source files.
- The vendored Fuse.js bundle is at most 25 KB gzipped.

## Assumptions

- The current Fuse.js stable release ships an Apache-2.0 or MIT-licensed UMD bundle suitable for vendoring (verified during plan).
- Fuse.js with `threshold ~0.4`, `ignoreLocation: true`, `minMatchCharLength: 2`, `includeMatches: true`, and weighted keys (title 3, tags 2, categories 2, contents 1) gives acceptable result quality on the current corpus. The threshold is a tunable knob, not a frozen requirement; if implementation finds a better default on the real index, that is a plan-phase decision.
- The site's content corpus (current size of `/index.json`) is small enough for Fuse.js to search synchronously inside the existing 150 ms debounce on a recent desktop browser. If it is not, that finding is a plan-phase concern — not a spec gate — and may motivate additional indexing work.
- Default browser styling of `<mark>` (yellow background) is acceptable. No bespoke theme styling for `<mark>` is required by this mission, but the template MAY add a single CSS rule if the default looks broken under the dark `code-editor` theme.

## Out of Scope

- Server-side, hosted, or third-party search.
- Adding new fields to the search index, new index outputs, or restructuring `/index.json`.
- A "did you mean" UI; the fuzzy threshold itself is the UX.
- Visual redesign of the result list beyond the `<mark>` highlight.
- Changes to the sidebar search box in `themes/code-editor/layouts/partials/menu.html`.
- Changes to the Hugo index-generation template `themes/code-editor/layouts/_default/index.json`.
- Search analytics, query logging, telemetry, search history, saved searches.

Empty file added.

Empty file added.

Loading