Files
ai-resume/internal/web/templates/components.templ
Sam Rolfe d793ab0871 search: make the navigation form work from every page (AUD-151)
The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
2026-10-28 15:55:34 +11:00

358 lines
10 KiB
Plaintext

package templates
import (
"strconv"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
// Layout is the whole document. It renders the stage binding it is handed and
// never inspects which driver produced it.
templ Layout(v LayoutView) {
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ v.Title }</title>
<meta name="description" content={ v.Description }/>
<meta name="color-scheme" content="dark"/>
<link rel="stylesheet" href="/static/site.css"/>
<link rel="stylesheet" href="/static/chroma.css"/>
<script src="/static/htmx.min.js" defer></script>
if v.Stage.Mode == stage.ModeStream {
<script src="/static/sse.js" defer></script>
}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
@Nav(v.Nav, v.Area)
<div class="shell">
<main id="main" class="main">
@Breadcrumbs(v.Crumbs)
{ children... }
</main>
@Stage(StageView{Binding: v.Stage})
</div>
@Footer(v)
</body>
</html>
}
// Nav is the six-area navigation: a sidebar on desktop, a <details> disclosure
// below 900 px so it still works with JavaScript disabled.
templ Nav(items []NavItem, current string) {
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark">SR</span>
<span class="brand-text">
Sam Rolfe
<span class="brand-sub">AI · Platform · Edge</span>
</span>
</a>
@SearchForm("", "nav-search")
<details class="nav-disclosure">
<summary class="nav-toggle" aria-label="Sections">Sections</summary>
<nav class="nav" aria-label="Sections">
@navLinks(items, current)
@evidenceLink(current)
</nav>
</details>
<nav class="nav nav-rail" aria-label="Sections">
@navLinks(items, current)
@evidenceLink(current)
</nav>
</header>
}
templ navLinks(items []NavItem, current string) {
for _, it := range items {
<a href={ templ.SafeURL(it.Href) } class={ navClass(it) } aria-current={ currentAttr(it) }>
<span class="nav-gutter" aria-hidden="true">{ it.Gutter }</span>
<span class="nav-title">{ it.Title }</span>
</a>
}
}
templ evidenceLink(current string) {
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
<span class="nav-gutter" aria-hidden="true">EV</span>
<span class="nav-title">Evidence index</span>
</a>
}
// Breadcrumbs shows where the reader is, and is a real link trail.
templ Breadcrumbs(crumbs []Crumb) {
if len(crumbs) > 0 {
<nav class="crumbs" aria-label="Breadcrumb">
<ol>
for i, c := range crumbs {
<li>
if i == len(crumbs)-1 {
<span aria-current="page">{ c.Label }</span>
} else {
<a href={ templ.SafeURL(c.Href) }>{ c.Label }</a>
<span class="crumb-sep" aria-hidden="true">/</span>
}
</li>
}
</ol>
</nav>
}
}
// SearchForm is the plain GET form used in the navigation and the landing page.
// It navigates to /search and carries no htmx attributes, because #search-results
// only exists on the search page: an hx-target that is not in the document makes
// htmx discard the response, which is a form that looks like it works and does
// nothing. A plain GET works from every page and with JavaScript disabled.
templ SearchForm(q string, class string) {
<form class={ "search", class } action="/search" method="get" role="search">
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// 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) {
<form
class={ "search", class }
action="/search"
method="get"
role="search"
hx-get="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-push-url="true"
>
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// 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) {
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
}
// 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.
templ Stage(v StageView) {
<aside id="stage" class="stage" aria-label="Presentation panel">
<header class="stage-head">
<h2 class="stage-title">Presentation panel</h2>
<p class="stage-driver" data-driver={ string(v.Binding.Mode) }>{ driverLabel(v.Binding.Mode) }</p>
</header>
<div id={ stage.PanelID } class="stage-body" { v.Binding.Attributes()... }>
if v.Fragment != nil {
@FragmentView(*v.Fragment)
} else {
<div class="stage-empty">
<p class="stage-empty-title">{ v.Binding.Label }</p>
<p class="stage-hint">{ v.Binding.Hint }</p>
</div>
}
</div>
<footer class="stage-foot">
<a href="/evidence/">Every artefact</a>
<a href="/stream/">Streaming panel</a>
</footer>
</aside>
}
// FragmentView renders one stage fragment. This is the only place that knows
// what a fragment kind looks like, and it is shared by the route driver (at
// build time) and the stream driver (at request time).
templ FragmentView(f stage.Fragment) {
<article class={ "frag frag-" + f.Kind }>
<header class="frag-head">
<p class="frag-kind">{ f.Kind }</p>
<h3 class="frag-title">{ f.Title }</h3>
if f.Subtitle != "" {
<p class="frag-sub">{ f.Subtitle }</p>
}
</header>
switch f.Kind {
case stage.KindText:
<div class="frag-text">@f.Body</div>
case stage.KindCode:
if f.Code != nil {
<figure class="code">
<figcaption>{ f.Code.Label }<span class="code-lang">{ f.Code.Language }</span></figcaption>
@f.Code.HTML
</figure>
}
case stage.KindDiagram, stage.KindImage:
<figure class="media">
<img src={ templ.SafeURL(f.Src) } alt={ f.Alt } loading="lazy"/>
if f.Subtitle != "" {
<figcaption>{ f.Subtitle }</figcaption>
}
</figure>
case stage.KindVideo:
<figure class="media">
<video controls preload="metadata" src={ templ.SafeURL(f.Src) }></video>
if f.Subtitle != "" {
<figcaption>{ f.Subtitle }</figcaption>
}
</figure>
case stage.KindLinks:
if len(f.Links) > 0 {
<ul class="frag-links">
for _, l := range f.Links {
<li>
<a href={ templ.SafeURL(l.Href) }>{ l.Label }</a>
if l.Note != "" {
<span class="frag-note">{ l.Note }</span>
}
</li>
}
</ul>
}
}
<footer class="frag-foot">
if f.Confidence != "" {
<span class={ ConfidenceClass(f.Confidence) }>{ f.Confidence }</span>
}
if f.DocURL != "" {
<a class="frag-doc" href={ templ.SafeURL(f.DocURL) }>{ f.DocTitle }</a>
}
</footer>
</article>
}
// EvidenceTable lists artefacts. Rows with HXGet load the artefact into the
// stage panel; every row is also a plain link, so this works with JavaScript
// disabled.
templ EvidenceTable(rows []EvidenceRow) {
<table class="evidence">
<thead>
<tr>
<th scope="col">Evidence</th>
<th scope="col">Kind</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
for _, r := range rows {
<tr>
<td>
if r.HXGet != "" {
<a href={ templ.SafeURL(r.Href) } hx-get={ r.HXGet } hx-target={ stage.PanelTarget() } hx-swap="innerHTML" hx-push-url="false">{ r.Label }</a>
} else {
<a href={ templ.SafeURL(r.Href) }>{ r.Label }</a>
}
</td>
<td><span class="kind">{ r.Kind }</span></td>
<td>
if r.Confidence != "" {
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
}
</td>
</tr>
}
</tbody>
</table>
}
// PillarCard is one landing-page pillar: the claim, the numbers, and the way in.
templ PillarCard(c PillarCardView) {
<article class="pillar">
<p class="pillar-kicker">
<span class="pillar-gutter" aria-hidden="true">{ c.Area.Gutter }</span>
if c.Area.Role != "" {
<span>{ c.Area.Role }</span>
}
</p>
<h2 class="pillar-title">
<a href={ templ.SafeURL(c.Href) }>{ c.Title }</a>
</h2>
<p class="pillar-claim">{ c.Claim }</p>
if len(c.Metrics) > 0 {
<ul class="metrics">
for _, m := range c.Metrics {
<li class="chip">
<strong>{ m.Value }</strong>
<span>{ m.Label }</span>
</li>
}
</ul>
}
if len(c.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range c.Tech {
<li>{ t }</li>
}
</ul>
}
<p class="pillar-foot">
<span class="pillar-count">{ strconv.Itoa(c.Pages) } pages</span>
<span class={ ConfidenceClass(c.Confidence) }>{ c.Confidence }</span>
</p>
</article>
}
// Timeline renders the history area as a timeline.
templ Timeline(items []TimelineItem) {
<ol class="timeline">
for _, it := range items {
<li class="timeline-item">
<p class="timeline-period">{ it.Period }</p>
<div class="timeline-body">
<h3><a href={ templ.SafeURL(it.Href) }>{ it.Title }</a></h3>
<p>{ it.Claim }</p>
<span class={ ConfidenceClass(it.Confidence) }>{ it.Confidence }</span>
</div>
</li>
}
</ol>
}
// CodeBlock renders an already-highlighted code exhibit.
templ CodeBlock(c stage.Code) {
<figure class="code">
<figcaption>{ c.Label }<span class="code-lang">{ c.Language }</span></figcaption>
@c.HTML
</figure>
}
// Footer closes the page.
templ Footer(v LayoutView) {
<footer class="footer">
<p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p>
<p class="footer-links">
<a href="/evidence/">Evidence index</a>
<a href="/search">Search</a>
<a href="/stream/">Streaming panel</a>
<span class="footer-copy">© { strconv.Itoa(v.Year) } Sam Rolfe</span>
</p>
</footer>
}
func navClass(it NavItem) string { return navClassFor(it.Current) }
func navClassFor(current bool) string {
if current {
return "nav-link nav-current"
}
return "nav-link"
}
func currentAttr(it NavItem) string {
if it.Current {
return "page"
}
return ""
}
func driverLabel(m stage.Mode) string {
if m == stage.ModeStream {
return "stream driver · SSE"
}
return "route driver · pre-rendered"
}