feat(search): live results, keyboard navigation, and Enter
Typing searches. Arrow keys walk the results. Enter goes to the first
one β unless the query reads as a question about the corpus, in which
case it does not, because navigating away from a page someone is still
reading is a worse failure than making them press Enter twice.
The heuristic is one function in internal/memoria/query: ends in a
question mark, or opens with an interrogative or an auxiliary. Nothing
about length β "notes from the March offsite" is six words and still a
lookup, and a length rule would make Enter unreliable in exactly the
case it exists to serve. The false positives it does have ("will",
"can" as ordinary search terms) all fall on the safe side.
It lives on the server, and the browser only honours the attribute the
template writes. Two copies of a heuristic drift and the drift is
silent.
GET /search renders results.html alone; index.html includes the same
file. One definition, so live search and a scriptless form submit
cannot disagree about where Enter goes. The response is markup, not
JSON, so a snippet is still escaped once by safeSnippet and the browser
never builds HTML out of a document's text.
search.js is app-owned and an enhancement: delete it and the form is
still a GET to "/" that still returns results. The one subtlety is
staleness β results on screen lag the input by a debounce, so every
Enter checks that what is rendered matches what is in the box, and
where it does not, runs the search first and acts on the answer. A fast
typist gets the same document as a slow one.
Gates, each broken and watched go red before it was kept:
priming always-false, the question term dropped, the results > 0 term
dropped, the fragment route rendering a whole page, the empty query
searching anyway, the opener match loosened to a prefix, and the
question mark ignored. Seven mutations, seven caught.
One gate was written and then deleted: "a page with no results primes
nothing" could not fail, because with no results the template renders
no list items and the two outcomes are the same bytes. The invariant is
real, so it moved to query.PrimesFirstResult where it is visible.
Looked at the page, per AGENTS.md β chromium against tools/drive, light
and dark: live results without a navigation, arrows in and out of the
list, Escape and typing returning to the input, Enter opening the
primed document, Enter on a question staying put, Enter before the
debounce still landing right, and the whole thing working with
JavaScript off.
π€ Written by Claude Opus 5, reviewed by Paul.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Razr3QqRESLaKT2NETghDd
11 files changed,
+676
−53
internal/memoria/app.go+1 −0internal/memoria/handlers.go+55 −15internal/memoria/query/question.go+83 −0internal/memoria/query/question_test.go+89 −0internal/memoria/render.go+44 −12internal/memoria/static/memoria.css+18 −0internal/memoria/static/search.js+199 −0internal/memoria/templates/index.html+8 −26internal/memoria/templates/layout.html+2 −0internal/memoria/templates/results.html+41 −0internal/memoriatest/search_test.go+136 −0
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index e4e8ec6..8054ae7 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -60,6 +60,7 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireSession) |
| r.Get("/", a.index) |
| + r.Get("/search", a.searchResults) |
| r.Post("/documents", a.createDocument) |
| r.Get("/documents/{id}", a.showDocument) |
| r.Post("/documents/{id}/delete", a.deleteDocument) |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 54758ea..1df7732 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -10,6 +10,8 @@ import ( |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/query" |
| ) |
| // maxUpload caps a pasted or uploaded document. |
| @@ -50,6 +52,17 @@ type page struct { |
| FlashKind string |
| Results []Result |
| Documents []Summary |
| + |
| + // SearchFailed is not "no results". A search that errored renders |
| + // its own callout in the results region, because "nothing matched" |
| + // and "the search broke" look identical to a reader and this |
| + // codebase treats that shape as the worst available failure. |
| + SearchFailed bool |
| + |
| + // Primed means the first result is the one Enter goes to. Decided |
| + // here rather than in the browser so that one heuristic has one |
| + // home; search.js only honours the attribute the template writes. |
| + Primed bool |
| } |
| // chunkCount returns the indexed passage count, and -1 when it cannot be |
| @@ -76,21 +89,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| Query: q, |
| Searched: q != "", |
| } |
| - if p.Searched { |
| - results, err := a.store.Search(r.Context(), q, 25) |
| - if err != nil { |
| - // A search that errored must never render as no results. |
| - // "Nothing matched" and "the search broke" look identical to |
| - // a reader, and this codebase treats that shape as the worst |
| - // available failure. |
| - a.logger.Error("search failed", "err", err) |
| - p.Flash = "The search did not run. Nothing was found because nothing was searched." |
| - p.FlashKind = "danger" |
| - p.Searched = false |
| - } else { |
| - p.Results = results |
| - } |
| - } |
| + a.runSearch(r, &p) |
| docs, err := a.store.Summaries(r.Context()) |
| if err != nil { |
| a.logger.Error("list documents", "err", err) |
| @@ -105,6 +104,47 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| render(w, "index", p) |
| } |
| +// searchResults renders the results region alone, for search.js. |
| +// |
| +// It is a real route under the same session gate as everything else, and |
| +// it renders the same template the page does. The script that calls it is |
| +// an enhancement: delete search.js and the form below it still submits to |
| +// "/" and still gets these results, inside a page. |
| +func (a *app) searchResults(w http.ResponseWriter, r *http.Request) { |
| + q := strings.TrimSpace(r.URL.Query().Get("q")) |
| + p := page{Signed: true, Query: q, Searched: q != ""} |
| + a.runSearch(r, &p) |
| + // The passage count is only spoken by the no-match copy, and this |
| + // route runs once per keystroke. Counting only when the count will |
| + // be read keeps a per-keystroke query off the database. |
| + if p.Searched && len(p.Results) == 0 { |
| + p.Chunks = a.chunkCount() |
| + } |
| + renderFragment(w, "results", p) |
| +} |
| + |
| +// runSearch fills in Results, SearchFailed and Primed for a query that |
| +// has already been trimmed onto p. |
| +// |
| +// Shared by the page and the fragment on purpose: the two renderings must |
| +// agree about which result is primed, or where Enter goes would depend on |
| +// whether the script had loaded yet. |
| +func (a *app) runSearch(r *http.Request, p *page) { |
| + if !p.Searched { |
| + return |
| + } |
| + results, err := a.store.Search(r.Context(), p.Query, 25) |
| + if err != nil { |
| + // No query text in the log line: AGENTS.md, and `carlos logs` is |
| + // storage we cannot grep. |
| + a.logger.Error("search failed", "err", err) |
| + p.Searched, p.SearchFailed = false, true |
| + return |
| + } |
| + p.Results = results |
| + p.Primed = query.PrimesFirstResult(p.Query, len(results)) |
| +} |
| + |
| // flashFor maps an error code to copy. Codes travel in the URL rather |
| // than messages, so nothing a person typed is reflected back into the |
| // page or into a log line. |
diff --git a/internal/memoria/query/question.go b/internal/memoria/query/question.go| new file mode 100644 |
| index 0000000..e263d44 |
| --- /dev/null |
| +++ b/internal/memoria/query/question.go |
| @@ -0,0 +1,83 @@ |
| +// Package query reads the shape of a search box, not its meaning. |
| +// |
| +// It exists so that one decision β is this person looking something up, |
| +// or asking about the corpus? β has a single home, is unit-testable |
| +// without a browser, and is made on the server. The browser script that |
| +// acts on it only honours a flag the server set; it does not re-derive |
| +// the rule, because two copies of a heuristic drift and the drift is |
| +// silent. |
| +package query |
| + |
| +import ( |
| + "strings" |
| + "unicode" |
| +) |
| + |
| +// openers are the words that, first in a query, mean the person is |
| +// asking rather than looking up. |
| +// |
| +// Interrogatives and auxiliaries only. Each one costs a false positive |
| +// when it is also an ordinary search term ("will" the noun, "can" the |
| +// verb), and that trade is deliberately one-sided: a missed prime makes |
| +// someone press Enter twice, while a wrong prime navigates away from a |
| +// page they were still reading. The safe direction is not priming. |
| +var openers = map[string]bool{ |
| + "who": true, "whom": true, "whose": true, "what": true, "when": true, |
| + "where": true, "which": true, "why": true, "how": true, |
| + |
| + "am": true, "is": true, "are": true, "was": true, "were": true, |
| + "do": true, "does": true, "did": true, |
| + "can": true, "could": true, "should": true, "would": true, "will": true, |
| + "has": true, "have": true, "had": true, "may": true, "might": true, |
| + "must": true, |
| +} |
| + |
| +// PrimesFirstResult reports whether the first of results results should |
| +// be the one Enter goes to. |
| +// |
| +// Both terms of the decision live here rather than at the call site, |
| +// because only one of them is visible from outside. An HTTP test cannot |
| +// see the results > 0 term at all: with no results the template renders |
| +// no list items, so priming nothing and priming a result that does not |
| +// exist produce byte-identical markup. A guard whose failure is |
| +// invisible is a guard written to pass, so this is the level the guard |
| +// is tested at. |
| +func PrimesFirstResult(q string, results int) bool { |
| + return results > 0 && !IsQuestion(q) |
| +} |
| + |
| +// IsQuestion reports whether a query reads as a question about the |
| +// corpus rather than a lookup in it. |
| +// |
| +// Two signals, both cheap and both explicable to the person typing: it |
| +// ends in a question mark, or it opens with an interrogative or an |
| +// auxiliary. Nothing about length β "notes from the March offsite" is |
| +// six words and still a lookup, and a rule that punished it would make |
| +// Enter unreliable in exactly the case it exists to serve. |
| +func IsQuestion(q string) bool { |
| + q = strings.TrimSpace(q) |
| + if q == "" { |
| + return false |
| + } |
| + if strings.HasSuffix(q, "?") { |
| + return true |
| + } |
| + return openers[firstWord(q)] |
| +} |
| + |
| +// firstWord is the leading run of letters, lowercased. |
| +// |
| +// Letters only, and only the leading run, so that "who's" opens with |
| +// "who" while "whatever" and "canary" open with themselves β a prefix |
| +// match here would have made every query beginning "canβ¦" or "willβ¦" |
| +// unprimeable. |
| +func firstWord(q string) string { |
| + var b strings.Builder |
| + for _, r := range q { |
| + if !unicode.IsLetter(r) { |
| + break |
| + } |
| + b.WriteRune(unicode.ToLower(r)) |
| + } |
| + return b.String() |
| +} |
diff --git a/internal/memoria/query/question_test.go b/internal/memoria/query/question_test.go| new file mode 100644 |
| index 0000000..2316998 |
| --- /dev/null |
| +++ b/internal/memoria/query/question_test.go |
| @@ -0,0 +1,89 @@ |
| +package query |
| + |
| +import "testing" |
| + |
| +// Broken by dropping the results > 0 term β red, then reverted. |
| +// 2026-09-05. This test exists because the HTTP-level version of it |
| +// could not fail: with no results the template renders no list items, so |
| +// a wrongly-primed empty page and a correctly-unprimed one are the same |
| +// bytes. |
| +func TestPrimesFirstResult(t *testing.T) { |
| + cases := []struct { |
| + q string |
| + results int |
| + want bool |
| + }{ |
| + {"pass", 3, true}, |
| + {"pass", 1, true}, |
| + {"pass", 0, false}, // nothing to prime |
| + {"what did we decide about passwords", 3, false}, |
| + {"what did we decide about passwords", 0, false}, |
| + {"", 0, false}, |
| + } |
| + for _, c := range cases { |
| + if got := PrimesFirstResult(c.q, c.results); got != c.want { |
| + t.Errorf("PrimesFirstResult(%q, %d) = %v, want %v", c.q, c.results, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// The whole point of the heuristic is which side of the line a real |
| +// query falls on, so the table is real queries, not shapes. |
| +func TestIsQuestion(t *testing.T) { |
| + lookups := []string{ |
| + "pass", |
| + "password rotation policy", |
| + "notes from the March offsite", |
| + "Q3 board pack", |
| + "revenue", |
| + "DEK", |
| + "hosting migration", |
| + "whatever happened", // not "what" β a prefix must not match |
| + "canary tokens", // not "can" |
| + "willingness to pay", |
| + "doe, jane", |
| + "HOSTING", |
| + "", |
| + " ", |
| + } |
| + for _, q := range lookups { |
| + if IsQuestion(q) { |
| + t.Errorf("IsQuestion(%q) = true, want false: a lookup must prime the first result", q) |
| + } |
| + } |
| + |
| + questions := []string{ |
| + "what did we decide about passwords", |
| + "What did we decide about passwords?", |
| + "who owns billing", |
| + "when did hosting fall", |
| + "where is the DEK stored", |
| + "why did revenue fall", |
| + "how do we rotate the key", |
| + "which region grew", |
| + "whose account is this", |
| + "whom should I ask", |
| + "is the DEK rotated", |
| + "are the backups sealed", |
| + "was the offsite in March", |
| + "were costs down", |
| + "do we have a runbook", |
| + "does anyone own this", |
| + "did we ship it", |
| + "can I delete a document", |
| + "could we index PDFs", |
| + "should the index be rebuilt", |
| + "would that break search", |
| + "will we need SMTP", |
| + "has anyone written this up", |
| + "have we agreed the price", |
| + "am I on the owners list", |
| + "revenue?", // a bare term with a question mark is still a question |
| + " why? ", |
| + } |
| + for _, q := range questions { |
| + if !IsQuestion(q) { |
| + t.Errorf("IsQuestion(%q) = false, want true: a question must not prime the first result", q) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index 9c60571..e5ed6df 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -25,22 +25,38 @@ var assets = rastrillo.NewAssets(appFS) |
| // template named "content" without colliding with the others. |
| var pages = map[string]*template.Template{} |
| +// fragments holds the templates that are also served on their own, with |
| +// no layout around them. results.html is parsed into both this and every |
| +// page, so the region search.js swaps in on a keystroke and the region a |
| +// scriptless form submit renders are the same markup from the same file. |
| +var fragments *template.Template |
| + |
| +// funcs is shared by both sets. Two call sites, one map: a helper added |
| +// for a page and forgotten for the fragment would fail at execute time, |
| +// on the request, rather than at parse time. |
| +func funcs() template.FuncMap { |
| + return template.FuncMap{ |
| + "asset": assets.Path, |
| + // The app's own icon set (internal/memoria/icons), scaffolded |
| + // by rastrillo new. iconAssets is whatever <head> markup the |
| + // chosen delivery needs β empty for the vendored-inline |
| + // default, so the layout calls it unconditionally and |
| + // switching delivery later needs no template edit. |
| + "icon": icons.Icon, |
| + "iconAssets": icons.Assets, |
| + "safeSnippet": safeSnippet, |
| + } |
| +} |
| + |
| func init() { |
| for _, name := range []string{"index", "document", "signin"} { |
| pages[name] = template.Must(template.New("layout"). |
| - Funcs(template.FuncMap{ |
| - "asset": assets.Path, |
| - // The app's own icon set (internal/memoria/icons), scaffolded |
| - // by rastrillo new. iconAssets is whatever <head> markup the |
| - // chosen delivery needs β empty for the vendored-inline |
| - // default, so the layout calls it unconditionally and |
| - // switching delivery later needs no template edit. |
| - "icon": icons.Icon, |
| - "iconAssets": icons.Assets, |
| - "safeSnippet": safeSnippet, |
| - }). |
| - ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| + Funcs(funcs()). |
| + ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
| } |
| + fragments = template.Must(template.New("fragments"). |
| + Funcs(funcs()). |
| + ParseFS(appFS, "templates/results.html")) |
| } |
| // render executes name's layout into a buffer before anything touches |
| @@ -56,6 +72,22 @@ func render(w http.ResponseWriter, name string, data any) { |
| buf.WriteTo(w) |
| } |
| +// renderFragment executes one template with no layout around it, for the |
| +// endpoints a script calls rather than a person navigates to. |
| +// |
| +// Buffered for the same reason render is: a template error becomes a |
| +// clean 500 rather than half a list of results, and a half-swapped |
| +// results region is the plausible-empty-list failure wearing a hat. |
| +func renderFragment(w http.ResponseWriter, name string, data any) { |
| + var buf bytes.Buffer |
| + if err := fragments.ExecuteTemplate(&buf, name, data); err != nil { |
| + http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + buf.WriteTo(w) |
| +} |
| + |
| // safeSnippet renders an FTS5 snippet, which arrives as document text with |
| // <mark> tags inserted around the matched terms. |
| // |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index 6bcbdac..0617292 100644 |
| --- a/internal/memoria/static/memoria.css |
| +++ b/internal/memoria/static/memoria.css |
| @@ -82,6 +82,24 @@ |
| } |
| [rst-result-meta] { font-size: 0.8125rem; margin: 0; } |
| +/* The primed result: the one Enter goes to. Deliberately the same mark |
| + as [rst-passage]:target on the document page, because it means the |
| + same thing in both places β this is the one you are being sent to. |
| + Set by the server, never by the script; see internal/memoria/query. */ |
| +[rst-result-primed] { |
| + border-inline-start: 3px solid var(--rst-accent, var(--rst-text)); |
| + padding-inline-start: 0.7rem; |
| +} |
| +[rst-result-primed]:first-child { border-top: 0; } |
| +/* Arrowing into the list moves real focus, so the browser's own ring is |
| + the selection indicator. It is stated here only to give it room: the |
| + default ring sits tight against 600-weight text and is easy to miss |
| + when the eye is on the input. */ |
| +[rst-result-title]:focus-visible { |
| + outline: 2px solid var(--rst-accent, var(--rst-text)); |
| + outline-offset: 3px; |
| +} |
| + |
| [rst-prose] { line-height: 1.6; } |
| [rst-passage] { border-top: 1px solid var(--rst-line); padding: 0.8rem 0; } |
| [rst-passage]:target { border-inline-start: 3px solid var(--rst-accent, var(--rst-text)); padding-inline-start: 0.7rem; } |
diff --git a/internal/memoria/static/search.js b/internal/memoria/static/search.js| new file mode 100644 |
| index 0000000..06f7b8d |
| --- /dev/null |
| +++ b/internal/memoria/static/search.js |
| @@ -0,0 +1,199 @@ |
| +/* search.js β live search, keyboard navigation, and Enter. |
| + * |
| + * App-owned, not vendored: rastrillo.js and select.js come from the |
| + * library and are pinned byte-identical by a gate; this file is |
| + * memoria's and nothing pins it. |
| + * |
| + * Same house rules as its neighbours: first-party, dependency-free, and |
| + * an enhancement rather than a mechanism. Delete this file and the |
| + * search form is still a GET form to "/" that still returns results β |
| + * what is lost is that the results arrive as you type and that Enter can |
| + * take you straight into the first one. |
| + * |
| + * Three behaviours, in the order they matter: |
| + * |
| + * Live. Debounced fetch of /search?q=β¦, whose response is the same |
| + * results template the page renders. The server sends markup, |
| + * not JSON, so a snippet is escaped once, on the server, by |
| + * the code that already does it β the browser never builds |
| + * HTML out of a document's text. |
| + * |
| + * Enter. Goes to the primed result, if there is one. WHICH result is |
| + * primed, and whether anything is primed at all, is the |
| + * server's decision (internal/memoria/query): a query that |
| + * reads as a question about the corpus primes nothing, because |
| + * navigating away from a page someone is still reading is a |
| + * worse failure than making them press Enter twice. This file |
| + * reads an attribute. It does not re-derive the rule. |
| + * |
| + * Arrows. Move real DOM focus onto the result links, rather than |
| + * mirroring a selection with aria-activedescendant. A focused |
| + * link needs no ARIA to be announced, gets the browser's own |
| + * focus ring, and Enter on it is just a link being followed. |
| + * |
| + * The subtle one is staleness. The results on screen lag the input by a |
| + * debounce, so Enter pressed quickly could otherwise navigate to the |
| + * primed result of a query the person has already finished typing over. |
| + * Every Enter therefore checks that what is rendered matches what is in |
| + * the box, and when it does not, it runs the search first and acts on |
| + * the answer. */ |
| +(function () { |
| + "use strict"; |
| + |
| + var DEBOUNCE_MS = 120; |
| + |
| + var form = document.querySelector("form[rst-search]"); |
| + var input = form && form.querySelector('input[type="search"]'); |
| + var region = document.querySelector("[rst-search-results]"); |
| + var status = document.querySelector("[rst-search-status]"); |
| + if (!form || !input || !region) return; |
| + if (!window.fetch || !window.AbortController) return; |
| + |
| + // rendered is the query the markup in region was produced from, and |
| + // null when that is unknown β after a failed fetch, say. Enter refuses |
| + // to act on results it cannot vouch for. |
| + var rendered = input.value.trim(); |
| + var timer = null; |
| + var inflight = null; |
| + |
| + function primedLink() { |
| + var li = region.querySelector("[rst-result-primed]"); |
| + return li && li.querySelector("[rst-result-title]"); |
| + } |
| + |
| + function links() { |
| + return Array.prototype.slice.call( |
| + region.querySelectorAll("[rst-result-title]") |
| + ); |
| + } |
| + |
| + /* search fetches the results region for q and swaps it in. It resolves |
| + once the swap has happened, so Enter can wait on it; it rejects if |
| + the fetch failed or was superseded, and a rejection leaves the old |
| + markup alone rather than replacing results with a lie. */ |
| + function search(q) { |
| + if (inflight) inflight.abort(); |
| + var ctl = new AbortController(); |
| + inflight = ctl; |
| + return fetch("/search?q=" + encodeURIComponent(q), { |
| + signal: ctl.signal, |
| + credentials: "same-origin", |
| + headers: { Accept: "text/html" }, |
| + }) |
| + .then(function (res) { |
| + if (!res.ok) throw new Error("search " + res.status); |
| + return res.text(); |
| + }) |
| + .then(function (html) { |
| + // A response that lost the race must not overwrite a newer one. |
| + // The abort above usually prevents this; this is the check that |
| + // holds when it does not. |
| + if (ctl !== inflight) throw new Error("superseded"); |
| + inflight = null; |
| + region.innerHTML = html; |
| + rendered = q; |
| + announce(); |
| + }) |
| + .catch(function (err) { |
| + if (ctl === inflight) { |
| + inflight = null; |
| + // Not "no results". The markup on screen stays, and rendered |
| + // goes null so Enter falls through to a real form submit |
| + // instead of following a stale link. |
| + if (err.name !== "AbortError") rendered = null; |
| + } |
| + throw err; |
| + }); |
| + } |
| + |
| + /* announce repeats the heading the server just sent into the live |
| + region. Reusing the server's sentence rather than composing one |
| + here keeps every string a person hears in the template, where it is |
| + written and reviewed. */ |
| + function announce() { |
| + if (!status) return; |
| + var h = region.querySelector("h2"); |
| + status.textContent = h ? h.textContent.trim() : ""; |
| + } |
| + |
| + function schedule() { |
| + if (timer) clearTimeout(timer); |
| + var q = input.value.trim(); |
| + timer = setTimeout(function () { |
| + timer = null; |
| + if (q === rendered) return; |
| + search(q).catch(function () {}); |
| + }, DEBOUNCE_MS); |
| + } |
| + |
| + /* submit is the fallback for everything this script cannot do |
| + confidently: no prime, a failed fetch, an unknown state. form.submit |
| + bypasses the submit event, so it cannot re-enter the handler. */ |
| + function submit() { |
| + form.submit(); |
| + } |
| + |
| + function follow() { |
| + var link = primedLink(); |
| + if (!link) return submit(); |
| + window.location.assign(link.href); |
| + } |
| + |
| + form.addEventListener("submit", function (e) { |
| + var q = input.value.trim(); |
| + if (q === "") return; // let the empty search behave as it always did |
| + if (rendered === q) { |
| + if (!primedLink()) return; // a question, or nothing found: submit |
| + e.preventDefault(); |
| + follow(); |
| + return; |
| + } |
| + // What is on screen is older than what is in the box. Find out |
| + // before deciding, so a fast typist gets the same answer as a slow |
| + // one rather than the previous query's first result. |
| + e.preventDefault(); |
| + if (timer) { |
| + clearTimeout(timer); |
| + timer = null; |
| + } |
| + search(q).then(follow, submit); |
| + }); |
| + |
| + input.addEventListener("input", schedule); |
| + |
| + input.addEventListener("keydown", function (e) { |
| + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; |
| + var all = links(); |
| + if (!all.length) return; |
| + e.preventDefault(); |
| + (e.key === "ArrowDown" ? all[0] : all[all.length - 1]).focus(); |
| + }); |
| + |
| + /* Keyboard handling for the list is delegated to the region, because |
| + the links inside it are replaced on every keystroke and a listener |
| + bound to one of them would not survive the swap. */ |
| + region.addEventListener("keydown", function (e) { |
| + var link = e.target.closest && e.target.closest("[rst-result-title]"); |
| + if (!link) return; |
| + |
| + if (e.key === "Escape") { |
| + e.preventDefault(); |
| + input.focus(); |
| + return; |
| + } |
| + if (e.key === "ArrowDown" || e.key === "ArrowUp") { |
| + var all = links(); |
| + var i = all.indexOf(link); |
| + e.preventDefault(); |
| + if (e.key === "ArrowUp" && i === 0) return input.focus(); |
| + var next = all[e.key === "ArrowDown" ? i + 1 : i - 1]; |
| + if (next) next.focus(); |
| + return; |
| + } |
| + // Typing while a result is focused means the person has gone back to |
| + // searching. Move the caret without swallowing the character. |
| + if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { |
| + input.focus(); |
| + } |
| + }); |
| +})(); |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index 699173b..47241b5 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -7,32 +7,14 @@ |
| <button rst-btn="primary">Search</button> |
| </form> |
| -{{if .Searched}} |
| - <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for β{{.Query}}β</h2> |
| - {{if not .Results}} |
| - <div rst-callout="quiet"> |
| - <p rst-callout-body> |
| - {{if lt .Chunks 0}} |
| - Nothing matched β but the passage count could not be read, so |
| - this may be an empty index rather than a real absence. |
| - {{else}} |
| - Nothing matched. There {{if eq .Chunks 1}}is{{else}}are{{end}} {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} |
| - indexed, so this is a real absence rather than an empty index. |
| - {{end}} |
| - </p> |
| - </div> |
| - {{end}} |
| - <ol rst-results> |
| - {{range .Results}} |
| - <li rst-result> |
| - <a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-result-title>{{.Title}}</a> |
| - {{if .Heading}}<span rst-result-trail>{{.Heading}}</span>{{end}} |
| - <p rst-result-snippet>{{.Snippet | safeSnippet}}</p> |
| - <p rst-result-meta rst-cell-mut>{{.Source}} Β· passage {{.Ordinal}}</p> |
| - </li> |
| - {{end}} |
| - </ol> |
| -{{end}} |
| +{{/* The results region. search.js swaps this div's contents from |
| + GET /search on every keystroke; with the script gone, the form above |
| + submits and the server renders the same template into the same |
| + place. The status paragraph lives OUTSIDE the swapped div because a |
| + live region has to exist before it changes β replacing the node is |
| + how you make a screen reader announce nothing. */}} |
| +<p class="rst-sr-only" role="status" rst-search-status></p> |
| +<div rst-search-results>{{template "results" .}}</div> |
| <section rst-section> |
| <h2>Add something</h2> |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index 459d8fa..9e9d0ef 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -14,6 +14,8 @@ |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/memoria.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| +{{/* App-owned, and inert on every page that has no search form. */}} |
| +<script defer src="{{asset "static/search.js"}}"></script> |
| {{iconAssets}} |
| </head> |
| <body> |
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html| new file mode 100644 |
| index 0000000..5c27c52 |
| --- /dev/null |
| +++ b/internal/memoria/templates/results.html |
| @@ -0,0 +1,41 @@ |
| +{{/* The results region, rendered two ways from one definition. |
| + |
| + index.html includes it, so a scriptless form submit gets it inside |
| + the page; GET /search executes it alone, so search.js can swap it in |
| + on every keystroke. One definition because the two must agree about |
| + which result is primed β if they disagreed, where Enter goes would |
| + depend on whether the script had loaded yet. */}} |
| +{{define "results"}} |
| +{{if .SearchFailed}} |
| + <div rst-callout="danger"> |
| + <p rst-callout-body> |
| + The search did not run. Nothing was found because nothing was searched. |
| + </p> |
| + </div> |
| +{{else if .Searched}} |
| + <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for β{{.Query}}β</h2> |
| + {{if not .Results}} |
| + <div rst-callout="quiet"> |
| + <p rst-callout-body> |
| + {{if lt .Chunks 0}} |
| + Nothing matched β but the passage count could not be read, so |
| + this may be an empty index rather than a real absence. |
| + {{else}} |
| + Nothing matched. There {{if eq .Chunks 1}}is{{else}}are{{end}} {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} |
| + indexed, so this is a real absence rather than an empty index. |
| + {{end}} |
| + </p> |
| + </div> |
| + {{end}} |
| + <ol rst-results> |
| + {{range $i, $r := .Results}} |
| + <li rst-result{{if and $.Primed (eq $i 0)}} rst-result-primed{{end}}> |
| + <a href="/documents/{{$r.DocumentID}}#p{{$r.Ordinal}}" rst-result-title>{{$r.Title}}</a> |
| + {{if $r.Heading}}<span rst-result-trail>{{$r.Heading}}</span>{{end}} |
| + <p rst-result-snippet>{{$r.Snippet | safeSnippet}}</p> |
| + <p rst-result-meta rst-cell-mut>{{$r.Source}} Β· passage {{$r.Ordinal}}</p> |
| + </li> |
| + {{end}} |
| + </ol> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/memoriatest/search_test.go b/internal/memoriatest/search_test.go| new file mode 100644 |
| index 0000000..74148e4 |
| --- /dev/null |
| +++ b/internal/memoriatest/search_test.go |
| @@ -0,0 +1,136 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The corpus every test in this file searches. One document so the |
| +// ordering is not the thing under test; the flag is. |
| +func withCorpus(t *testing.T) http.Handler { |
| + t.Helper() |
| + h := newApp(t) |
| + if rec := store(t, h, "Q3 board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions.\n\n## Costs\n\nHosting fell after the migration."); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("store: status %d, want 303", rec.Code) |
| + } |
| + return h |
| +} |
| + |
| +// A lookup primes the first result, which is what makes Enter go |
| +// somewhere. The attribute is the whole contract between the server's |
| +// heuristic and search.js: the script never re-derives the rule. |
| +// |
| +// Broken by hard-coding p.Primed to false β red, then reverted. |
| +// 2026-09-05. |
| +func TestALookupPrimesTheFirstResult(t *testing.T) { |
| + h := withCorpus(t) |
| + body := get(t, h, "/?q=june+revenue").Body.String() |
| + if !strings.Contains(body, "rst-result-primed") { |
| + t.Errorf("a lookup did not prime a result, so Enter goes nowhere:\n%s", body) |
| + } |
| + if n := strings.Count(body, "rst-result-primed"); n != 1 { |
| + t.Errorf("primed %d results, want exactly 1", n) |
| + } |
| + // The prime must be on the FIRST result, not merely somewhere. |
| + // Split on the <li> opener rather than comparing indexes, because |
| + // "rst-result" is a prefix of "rst-result-primed" and an index |
| + // comparison would be measuring the same attribute twice. |
| + items := strings.Split(body, "<li rst-result") |
| + if len(items) < 2 { |
| + t.Fatalf("no results rendered at all:\n%s", body) |
| + } |
| + if !strings.Contains(items[1], "rst-result-primed") { |
| + t.Error("the primed result is not the first one") |
| + } |
| +} |
| + |
| +// Broken by priming on `len(results) > 0` alone, dropping the question |
| +// term β red, then reverted. 2026-09-05. |
| +func TestAQuestionPrimesNothing(t *testing.T) { |
| + h := withCorpus(t) |
| + body := get(t, h, "/?q=what+was+june+revenue").Body.String() |
| + if !strings.Contains(body, "Q3 board pack") { |
| + t.Fatalf("the question found nothing, so this test proves nothing:\n%s", body) |
| + } |
| + if strings.Contains(body, "rst-result-primed") { |
| + t.Error("a question primed a result; Enter would navigate away from " + |
| + "a page the reader was still reading") |
| + } |
| +} |
| + |
| +// There is deliberately no test here for "a page with no results primes |
| +// nothing". It was written, and it could not fail: with no results the |
| +// template renders no list items, so priming nothing and priming a |
| +// result that does not exist are the same bytes. The invariant is real |
| +// and is guarded where it is visible β query.TestPrimesFirstResult. |
| + |
| +// The live-search endpoint returns the results region and nothing else. |
| +// A fragment that shipped a whole page would nest <html> inside the list |
| +// on every keystroke. |
| +// |
| +// Broken by pointing searchResults at render instead of renderFragment β |
| +// red, then reverted. 2026-09-05. |
| +func TestSearchFragmentIsResultsOnly(t *testing.T) { |
| + h := withCorpus(t) |
| + rec := get(t, h, "/search?q=june+revenue") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "Q3 board pack") { |
| + t.Errorf("the fragment carries no result:\n%s", body) |
| + } |
| + if !strings.Contains(body, "rst-result-primed") { |
| + t.Error("the fragment does not prime, so live search and a form " + |
| + "submit would disagree about where Enter goes") |
| + } |
| + for _, chrome := range []string{"<html", "<body", "rst-page-header", "Add something"} { |
| + if strings.Contains(body, chrome) { |
| + t.Errorf("the fragment carries page chrome (%q); it would nest a "+ |
| + "whole page inside the results list", chrome) |
| + } |
| + } |
| +} |
| + |
| +// The empty query is the state the box starts in and returns to when it |
| +// is cleared. It must render an empty region, not an error and not the |
| +// whole corpus. |
| +func TestSearchFragmentOfAnEmptyQueryIsEmpty(t *testing.T) { |
| + rec := get(t, withCorpus(t), "/search?q=") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200", rec.Code) |
| + } |
| + if body := strings.TrimSpace(rec.Body.String()); body != "" { |
| + t.Errorf("an empty query rendered %q, want nothing", body) |
| + } |
| +} |
| + |
| +// The fragment and the full page must agree, because Enter can land on |
| +// either: live search fills the region, and a scriptless submit renders |
| +// the page. If they disagreed, priming would depend on whether JS had |
| +// loaded yet. |
| +func TestFragmentAndPageAgreeOnPriming(t *testing.T) { |
| + h := withCorpus(t) |
| + for _, q := range []string{"june+revenue", "what+was+june+revenue", "xylophone"} { |
| + page := strings.Contains(get(t, h, "/?q="+q).Body.String(), "rst-result-primed") |
| + frag := strings.Contains(get(t, h, "/search?q="+q).Body.String(), "rst-result-primed") |
| + if page != frag { |
| + t.Errorf("q=%q: page primed=%v but fragment primed=%v", q, page, frag) |
| + } |
| + } |
| +} |
| + |
| +// search.js is an enhancement. The page it enhances must work with it |
| +// deleted, which means the form is a real GET form to a real route and |
| +// the script is not what makes results appear. |
| +func TestSearchWorksWithoutTheScript(t *testing.T) { |
| + h := withCorpus(t) |
| + body := get(t, h, "/").Body.String() |
| + if !strings.Contains(body, `action="/"`) || !strings.Contains(body, `method="get"`) { |
| + t.Errorf("the search form is not a plain GET form:\n%s", body) |
| + } |
| + if !strings.Contains(get(t, h, "/?q=june+revenue").Body.String(), "Q3 board pack") { |
| + t.Error("a plain form submit does not produce results") |
| + } |
| +} |