Phase 7 (AUD-149), cheapest first. - internal/brand: vendored Simple Icons path data (CC0) for 50 marks, looked up by the technology label the content already uses. Inline SVG, 16 px, currentColor, no request and no font. tools/gen_brand_icons.sh regenerates it. - Six hand-drawn area icons replace the two-letter badges in the nav and on the landing cards; the evidence index gets a seventh. - TechChips renders a mark where one exists and plain text where one does not, so a missing icon reads as a decision rather than a broken image. - Client wall: 45 names as one typeface, one size, one grid of equal tiles, and the reason for no logo collage written on the page. No mark invented. - Two diagrams, authored in-repo: homelab topology (platform-network.md) and the RAG pipeline (ai-rag-corpus.md), each referenced from the page it explains. - Content images now carry intrinsic width/height, loading="lazy" and decoding="async" at render time; a site test fails the build if an image ships without them. - Topbar is two rows at desktop: the area names plus marks no longer overlap the search form (Lighthouse target-size was failing at 1440 px). - Favicon, so the browser stops requesting a missing /favicon.ico. Satisfies: logos on every area page, area icons in nav and cards, two diagrams referenced from their pages, images sized and lazy, Lighthouse >= 95 held. (cherry picked from commit a473e4738a3751418c2097df2bcf0568fb6d0208)
359 lines
10 KiB
Plaintext
359 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="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
|
<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-mark">@AreaIcon(it.Area)
|
|
</span>
|
|
<span class="nav-title">{ it.Title }</span>
|
|
</a>
|
|
}
|
|
}
|
|
|
|
templ evidenceLink(current string) {
|
|
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
|
|
<span class="nav-mark">@AreaIcon("evidence")
|
|
</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-mark">@AreaIcon(c.Area.Key)
|
|
</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 {
|
|
@TechChips(c.Tech)
|
|
}
|
|
<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"
|
|
}
|