`)
}
-// handleSearch runs a keyword search. With HTMX it returns just the results
-// block; without JavaScript the same handler returns the whole page, so the form
-// works in both cases.
+// handleSearch runs a keyword search. With HTMX it returns just the contents of
+// the results block, which the search page's own form swaps into #search-results;
+// without JavaScript the same handler returns the whole page, so the form works
+// in both cases.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
failed := false
@@ -451,7 +452,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Render(context.Context, io.Writer) error
} = templates.SearchPage(view)
if r.Header.Get("HX-Request") == "true" {
- c = templates.SearchResults(view)
+ // The element that holds #search-results stays where it is, so the
+ // fragment carries the contents and not the wrapper.
+ c = templates.SearchResultsBody(view)
}
if err := c.Render(r.Context(), &buf); err != nil {
s.log.Error("render search", "error", err)
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index 0738bde..77065dd 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "regexp"
"strings"
"testing"
"time"
@@ -274,8 +275,14 @@ func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
if strings.Contains(frag, "")
+ if start < 0 || end < 0 {
+ t.Fatalf("%s: could not isolate the navigation search form", path)
+ }
+ return body[start : marker+end]
+}
+
+// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav
+// form used to carry hx-target="#search-results", which only exists on /search,
+// so htmx silently discarded the response everywhere else. The nav form must be
+// a plain GET, and no page may target a fragment it does not carry.
+func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) {
+ ts := newServer(t)
+ pages := []string{
+ "/",
+ "/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/",
+ "/evidence/", "/edge/edge-fleet/", "/404.html",
+ }
+ for _, path := range pages {
+ code, _, body := get(t, ts, path, nil)
+ if code != http.StatusOK {
+ t.Errorf("%s: status %d", path, code)
+ continue
+ }
+ form := navSearchForm(t, path, body)
+ if strings.Contains(form, "hx-") {
+ t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form)
+ }
+ if !strings.Contains(form, `action="/search"`) {
+ t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form)
+ }
+ if !strings.Contains(form, `method="get"`) {
+ t.Errorf("%s: navigation search form is not a GET:\n%s", path, form)
+ }
+ if strings.Contains(body, `hx-target="#search-results"`) {
+ t.Errorf("%s: page targets #search-results, which only the search page carries", path)
+ }
+ }
+}
+
+// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with
+// JavaScript disabled would: GET the form action with q set, expect a page.
+func TestSearchFromAnyPageLandsOnResults(t *testing.T) {
+ ts := newServer(t)
+ for _, path := range []string{"/", "/edge/", "/platform/"} {
+ code, _, body := get(t, ts, path, nil)
+ if code != http.StatusOK {
+ t.Errorf("%s: status %d", path, code)
+ continue
+ }
+ form := navSearchForm(t, path, body)
+ if !strings.Contains(form, `action="/search"`) {
+ t.Errorf("%s: nav form has no /search action to follow", path)
+ continue
+ }
+ // No HX-Request header: this is the no-JavaScript path.
+ code, _, landed := get(t, ts, "/search?q=zigbee", nil)
+ if code != http.StatusOK {
+ t.Errorf("search from %s: status %d", path, code)
+ continue
+ }
+ if !strings.Contains(strings.ToLower(landed), "") {
+ t.Errorf("search from %s did not land on a page", path)
+ }
+ if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") {
+ t.Errorf("search from %s landed on a page with no results", path)
+ }
+ }
+}
+
+// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every
+// hx-target selector must name an element in the same document. This is the
+// general form of the AUD-151 bug, so the next one fails a test instead of
+// failing silently in a browser.
+func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) {
+ target := regexp.MustCompile(`hx-target="([^"]+)"`)
+ files := 0
+ err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() || !strings.HasSuffix(p, ".html") {
+ return nil
+ }
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ files++
+ body := string(raw)
+ for _, m := range target.FindAllStringSubmatch(body, -1) {
+ selector := m[1]
+ if !strings.HasPrefix(selector, "#") {
+ continue // closest/find targets are resolved from the trigger
+ }
+ id := strings.TrimPrefix(selector, "#")
+ if !strings.Contains(body, `id="`+id+`"`) {
+ t.Errorf("%s: hx-target=%q has no matching element", p, selector)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walk %s: %v", distDir, err)
+ }
+ if files == 0 {
+ t.Fatal("no HTML files scanned; the build fixture is empty")
+ }
+}
+
+// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an
+// empty list".
+func TestSearchEmptyQueryShowsAPrompt(t *testing.T) {
+ ts := newServer(t)
+ code, _, body := get(t, ts, "/search", nil)
+ if code != http.StatusOK {
+ t.Fatalf("status = %d", code)
+ }
+ if !strings.Contains(body, "Type one or two words above") {
+ t.Error("empty search shows no prompt")
+ }
+ if strings.Contains(body, `class="results"`) {
+ t.Error("empty search renders a results list")
+ }
+}
+
+// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a
+// clear nothing-found message and suggests browsing the areas".
+func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) {
+ ts := newServer(t)
+ code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil)
+ if code != http.StatusOK {
+ t.Fatalf("status = %d", code)
+ }
+ if !strings.Contains(body, "Nothing found for") {
+ t.Error("no nothing-found message")
+ }
+ for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} {
+ if !strings.Contains(body, href) {
+ t.Errorf("nothing-found message does not offer %s", href)
+ }
+ }
+}
+
+// TestSearchCountIsAnnounced checks the count is inside the polite live region
+// and marked as a status, so a screen reader reads "4 results for …" rather than
+// an undifferentiated block of titles.
+func TestSearchCountIsAnnounced(t *testing.T) {
+ ts := newServer(t)
+ _, _, body := get(t, ts, "/search?q=zigbee", nil)
+ if !strings.Contains(body, `id="search-results" aria-live="polite"`) {
+ t.Error("results container is not a live region")
+ }
+ if !regexp.MustCompile(`
\d+ results for`).MatchString(body) {
+ t.Error("result count is not announced as a status inside the live region")
+ }
+}
+
+// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with
+// the header set, the handler returns the block and not a whole page.
+func TestHTMXSearchStillReturnsTheFragment(t *testing.T) {
+ ts := newServer(t)
+ code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"})
+ if code != http.StatusOK {
+ t.Fatalf("status = %d", code)
+ }
+ if strings.Contains(frag, "
-
-
+
}
+// SearchFormLive is the search page's own form. HTMX swaps the results block in
+// place, which is safe here and only here: #search-results is part of the page.
+templ SearchFormLive(q string, class string) {
+
+}
+
+// searchField is the label and input both forms share. The id is derived from the
+// form class, so two forms on one page never collide.
+templ searchField(q string, class string) {
+
+
+}
+
// Stage is the presentation panel. Everything about how it is fed comes from
// the binding: route mode swaps in pre-rendered fragments, stream mode listens
// on an event stream. The markup is identical either way.
diff --git a/internal/web/templates/pages.templ b/internal/web/templates/pages.templ
index 0a93b33..4c8e781 100644
--- a/internal/web/templates/pages.templ
+++ b/internal/web/templates/pages.templ
@@ -135,40 +135,67 @@ templ SearchPage(v SearchView) {
Search { strconv.Itoa(v.Indexed) } pages
Full-text search over page titles and body text. Served from a local index — no model, no network call.
- @SearchForm(v.Query, "page-search")
+ @SearchFormLive(v.Query, "page-search")
@SearchResults(v)
}
}
-// SearchResults is swapped into #search-results by HTMX.
+// SearchResults is the results block as a page element: the live region wrapper
+// plus its contents, which is what the whole page renders. Without JavaScript the
+// form reloads the page and this markup arrives in the document.
templ SearchResults(v SearchView) {
- if v.Query == "" {
-
Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.
- } else if v.Failed {
-
That query could not be run. Try fewer or simpler words.
- } else if len(v.Results) == 0 {
-
No page matches { v.Query }. The evidence index lists everything by area.
- } else {
-
{ strconv.Itoa(len(v.Results)) } results for { v.Query }
}
+// SearchResultsBody is the results block without the live region. This is what an
+// htmx request returns: the search page's form swaps it into #search-results with
+// innerHTML, so returning the wrapper too would nest a second element with the
+// same id inside the first and make the live region announce twice.
+//
+// Everything here arrives inside the polite live region, and the count carries
+// role="status" on top of that, so the number is read as one utterance instead of
+// a run of result titles.
+templ SearchResultsBody(v SearchView) {
+ if v.Query == "" {
+
Type one or two words above and press Enter. Good starting points: “zigbee”, “signage”, “routing”, “backups”, “guardrails”.
+ } else if v.Failed {
+
That query could not be run. Try fewer or simpler words.
+ } else if len(v.Results) == 0 {
+
Nothing found for { v.Query }. Try a shorter or more general word, or browse an area:
+ @searchAreaLinks(v.Areas)
+ } else {
+
{ strconv.Itoa(len(v.Results)) } results for { v.Query }
+ }
+
+ }
+}
+
+// searchAreaLinks is the way out of an empty result: the six areas plus the
+// evidence index, so a failed search never leaves the reader at a dead end.
+templ searchAreaLinks(areas []LinkView) {
+
+}
+
// StreamPage proves the panel can be re-pointed at a stream. The layout and the
// panel markup are the ones every other page uses; only the binding differs.
templ StreamPage(v StreamView) {
diff --git a/internal/web/templates/view.go b/internal/web/templates/view.go
index f2b0d94..fb70a34 100644
--- a/internal/web/templates/view.go
+++ b/internal/web/templates/view.go
@@ -151,6 +151,9 @@ type (
Total int
Indexed int
Failed bool
+ // Areas are the six area landings, offered as the way out of an
+ // empty result. Nothing here was ever a search hit.
+ Areas []LinkView
}
// StreamView is the SSE-driven stage demonstration page.