visuals: technology marks, area icons, client wall, two diagrams

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)
This commit is contained in:
2026-10-28 15:50:08 +11:00
parent 59580b6e45
commit a76882de67
18 changed files with 914 additions and 51 deletions

View File

@@ -14,7 +14,7 @@ last_verified: 2026-10-26
tags: [ai, retrieval, rag, artefact]
---
The query graph diagram and an ingest run log land later; this site's own corpus build is Phase 4.
Ingest and index are live; the diagram below shows both sides, and the query path is labelled as the design it is until Phase 5 ships.
## What the artefact is
@@ -24,6 +24,12 @@ The ingest and query path: documents are chunked and tagged with metadata at ing
Retrieval designed around payload filtering and a hard stop, plus the reasoning for moving off ChromaDB — heavier footprint and weaker filtering — recorded as a decision rather than a migration.
## The pipeline
![Two lanes. Ingest reads the content tree and estate notes, excludes secrets, chunks and tags every block, then embeds through the model gateway and upserts into the resume_corpus and resume_graph collections in Qdrant. The query lane runs classify, route, retrieve, compose and verify, and only composes from retrieved context.](../../assets/diagrams/rag-pipeline.svg)
The separating line is the design decision: metadata is written at ingest, so the query can filter by area, level and source before it pays for a similarity search. The fourth node is the hard stop — an answer that cannot be supported by what was retrieved is refused rather than improvised.
## When it is delivered
The query graph diagram in Phase 7. The live demonstration of the same pattern is this site's own retrieval, delivered in Phase 4 and Phase 5.
Ingest is delivered and measured (this site's own corpus, Phase 4). The query path is delivered in Phase 5; the streaming console is the visitor-facing half of it.

View File

@@ -0,0 +1,96 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1110" height="600" viewBox="0 0 1110 600" role="img" aria-labelledby="topoTitle topoDesc" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<title id="topoTitle">Homelab topology: build host, runtime host and public edge</title>
<desc id="topoDesc">Three hosts. The build host produces a release; the runtime host runs the site, the vector store, a workflow worker and the model gateway, all bound to loopback; the public edge terminates TLS and is the only host reachable from the internet. All three join one private mesh, and backups run in three tiers.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 10 5 0 10z" fill="#6ee7c6"/>
</marker>
</defs>
<g fill="#12161b" stroke="#33404d" stroke-width="1.5">
<rect x="30" y="104" width="310" height="268" rx="10"/>
<rect x="400" y="104" width="310" height="268" rx="10"/>
<rect x="770" y="104" width="310" height="268" rx="10"/>
</g>
<!-- internet -->
<g>
<rect x="810" y="24" width="230" height="46" rx="23" fill="#171d24" stroke="#33404d" stroke-width="1.5"/>
<text x="925" y="52" text-anchor="middle" font-size="13" fill="#b3bcc8">public internet · https :443</text>
<line x1="925" y1="70" x2="925" y2="100" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
</g>
<!-- host headers -->
<g fill="#e6edf3" font-size="15" font-weight="600">
<text x="54" y="136">Build and development</text>
<text x="424" y="136">Runtime host</text>
<text x="794" y="136">Public edge</text>
</g>
<g fill="#8b98a8" font-size="12" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">
<text x="54" y="156">.27 · 4-screen desktop</text>
<text x="424" y="156">.13 · 15 GiB · memory-bound</text>
<text x="794" y="156">.35 · Ubuntu · 2 vCPU</text>
</g>
<!-- build host items -->
<g fill="#b3bcc8" font-size="12.5">
<text x="54" y="192">· repository working copy</text>
<text x="54" y="219">· agent fleet, panes and approvals</text>
<text x="54" y="246">· content, diagrams and exhibits</text>
<text x="54" y="273">· release bundles and hashes</text>
</g>
<!-- runtime: loopback trust boundary -->
<rect x="416" y="172" width="278" height="150" rx="8" fill="none" stroke="#6ee7c6" stroke-width="1.2" stroke-dasharray="5 4"/>
<text x="430" y="192" font-size="11" fill="#6ee7c6" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">bound to 127.0.0.1</text>
<g fill="#b3bcc8" font-size="12.5">
<text x="430" y="216">· site server :8096</text>
<text x="430" y="241">· Qdrant :6333 / :6334</text>
<text x="430" y="266">· Prefect worker</text>
<text x="430" y="291">· model gateway (shared)</text>
</g>
<text x="424" y="345" font-size="11.5" fill="#8b98a8">Every cap equals its swap cap. Nothing here listens publicly.</text>
<!-- edge host items -->
<g fill="#b3bcc8" font-size="12.5">
<text x="794" y="192">· Caddy terminates TLS</text>
<text x="794" y="219">· proxies to the runtime host</text>
<text x="794" y="246">· Gitea · Immich · Nextcloud</text>
<text x="794" y="273">· one public listener, many sites</text>
</g>
<!-- hand-off arrows -->
<line x1="340" y1="230" x2="396" y2="230" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="368" y="218" text-anchor="middle" font-size="10.5" fill="#8b98a8">deploy</text>
<line x1="710" y1="230" x2="766" y2="230" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="738" y="218" text-anchor="middle" font-size="10.5" fill="#8b98a8">proxy</text>
<!-- mesh band -->
<rect x="30" y="410" width="1050" height="56" rx="10" fill="#12161b" stroke="#33404d" stroke-width="1.2" stroke-dasharray="6 5"/>
<text x="555" y="436" text-anchor="middle" font-size="13" fill="#b3bcc8">private mesh · SSH on every host · remote access without an exposed port</text>
<text x="555" y="454" text-anchor="middle" font-size="11" fill="#8b98a8">no service is reachable because it happened to bind to every interface</text>
<g stroke="#33404d" stroke-width="1" stroke-dasharray="3 4">
<line x1="185" y1="372" x2="185" y2="410"/>
<line x1="555" y1="372" x2="555" y2="410"/>
<line x1="925" y1="372" x2="925" y2="410"/>
</g>
<!-- backup tiers -->
<text x="30" y="506" font-size="13" font-weight="600" fill="#e6edf3">Three backup tiers — each covers a different failure</text>
<g fill="#12161b" stroke="#33404d" stroke-width="1.2">
<rect x="30" y="522" width="330" height="52" rx="8"/>
<rect x="390" y="522" width="330" height="52" rx="8"/>
<rect x="750" y="522" width="330" height="52" rx="8"/>
</g>
<g fill="#b3bcc8" font-size="12.5">
<text x="50" y="545">local snapshots, on the machine</text>
<text x="410" y="545">a copy on another machine</text>
<text x="770" y="545">encrypted object storage, offsite</text>
</g>
<g fill="#8b98a8" font-size="11">
<text x="50" y="562">restore tested, runbook beside it</text>
<text x="410" y="562">over the private mesh</text>
<text x="770" y="562">retention stated in the job</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="580" viewBox="0 0 1120 580" role="img" aria-labelledby="ragTitle ragDesc" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<title id="ragTitle">Retrieval pipeline: ingest on top, four-node query path below</title>
<desc id="ragDesc">Ingest reads content and estate notes, excludes secrets, chunks and tags each block, embeds through the model gateway and upserts into two Qdrant collections on loopback. The query path classifies the question, routes it to an area, retrieves with a payload filter, composes an answer from the retrieved context only, and verifies that every citation resolves to a page.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 10 5 0 10z" fill="#6ee7c6"/>
</marker>
<marker id="arrowDim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 10 5 0 10z" fill="#8b98a8"/>
</marker>
</defs>
<!-- ingest lane -->
<text x="30" y="42" font-size="14" font-weight="600" fill="#e6edf3">Ingest — one source of truth, one index</text>
<g fill="#12161b" stroke="#33404d" stroke-width="1.4">
<rect x="30" y="60" width="244" height="92" rx="9"/>
<rect x="302" y="60" width="244" height="92" rx="9"/>
<rect x="574" y="60" width="244" height="92" rx="9"/>
<rect x="846" y="60" width="244" height="92" rx="9"/>
</g>
<g fill="#e6edf3" font-size="13.5" font-weight="600">
<text x="48" y="86">Sources</text>
<text x="320" y="86">Normalise</text>
<text x="592" y="86">Chunk and tag</text>
<text x="864" y="86">Embed and upsert</text>
</g>
<g fill="#b3bcc8" font-size="12">
<text x="48" y="110">content/ Markdown</text>
<text x="48" y="129">estate notes</text>
<text x="48" y="148">platform guides</text>
<text x="320" y="110">front-matter parsed</text>
<text x="320" y="129">secrets and drafts</text>
<text x="320" y="148">excluded by rule</text>
<text x="592" y="110">blocks, not lines</text>
<text x="592" y="129">area · level · source</text>
<text x="592" y="148">title · tags</text>
<text x="864" y="110">model gateway only</text>
<text x="864" y="129">deterministic ids</text>
<text x="864" y="148">batched, resumable</text>
</g>
<g stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)">
<line x1="277" y1="106" x2="298" y2="106"/>
<line x1="549" y1="106" x2="570" y2="106"/>
<line x1="821" y1="106" x2="842" y2="106"/>
</g>
<line x1="968" y1="152" x2="968" y2="184" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="30" y="186" width="1060" height="62" rx="9" fill="#12161b" stroke="#6ee7c6" stroke-width="1.2" stroke-dasharray="5 4"/>
<text x="50" y="210" font-size="13" font-weight="600" fill="#e6edf3">Qdrant on 127.0.0.1</text>
<text x="50" y="231" font-size="12" fill="#b3bcc8">resume_corpus · one point per chunk resume_graph · one point per node and edge HNSW and payloads on disk · within a 400 MiB cap</text>
<text x="50" y="272" font-size="11.5" fill="#8b98a8">A sync run re-embeds only the documents whose content, model or chunker version changed.</text>
<!-- query lane -->
<text x="30" y="316" font-size="14" font-weight="600" fill="#e6edf3">Query</text>
<text x="108" y="316" font-size="12" fill="#8b98a8">— four graph nodes. It grows past four only with a written reason.</text>
<line x1="776" y1="248" x2="776" y2="352" stroke="#8b98a8" stroke-width="1.3" stroke-dasharray="4 4" marker-end="url(#arrowDim)"/>
<text x="788" y="330" font-size="11" fill="#8b98a8">payload-filtered search</text>
<g fill="#12161b" stroke="#33404d" stroke-width="1.4">
<rect x="30" y="360" width="196" height="112" rx="9"/>
<rect x="246" y="360" width="196" height="112" rx="9"/>
<rect x="462" y="360" width="196" height="112" rx="9"/>
<rect x="678" y="360" width="196" height="112" rx="9"/>
<rect x="894" y="360" width="196" height="112" rx="9"/>
</g>
<g font-size="10.5" fill="#6ee7c6" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">
<text x="46" y="382">CLASSIFY</text>
<text x="262" y="382">ROUTE</text>
<text x="478" y="382">RETRIEVE</text>
<text x="694" y="382">COMPOSE</text>
<text x="910" y="382">VERIFY</text>
</g>
<g fill="#e6edf3" font-size="13" font-weight="600">
<text x="46" y="406">Read the question</text>
<text x="262" y="406">Pick the areas</text>
<text x="478" y="406">Find the evidence</text>
<text x="694" y="406">Answer from it</text>
<text x="910" y="406">Check citations</text>
</g>
<g fill="#b3bcc8" font-size="11.5">
<text x="46" y="430">intent · injection</text>
<text x="46" y="448">off-topic · PII</text>
<text x="262" y="430">which pages can</text>
<text x="262" y="448">answer this</text>
<text x="478" y="430">vector + keyword</text>
<text x="478" y="448">ranked, top five</text>
<text x="694" y="430">context only</text>
<text x="694" y="448">no outside claims</text>
<text x="910" y="430">every claim maps</text>
<text x="910" y="448">to a real page</text>
</g>
<g stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)">
<line x1="227" y1="416" x2="242" y2="416"/>
<line x1="443" y1="416" x2="458" y2="416"/>
<line x1="659" y1="416" x2="674" y2="416"/>
<line x1="875" y1="416" x2="890" y2="416"/>
</g>
<text x="30" y="500" font-size="12" fill="#b3bcc8">A refusal is a real outcome: an off-topic question, an injection attempt or a request over the cost cap stops at the first node with zero model spend.</text>
<text x="30" y="526" font-size="11.5" fill="#8b98a8">Ingest runs today. The query path is the Phase 5 design; until it ships, every page is pre-rendered and needs no model.</text>
<text x="30" y="550" font-size="11.5" fill="#8b98a8">The public AI has no tools, no filesystem, no network fetch and no memory between visitors — architectural, not a prompt instruction.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -14,15 +14,66 @@ last_verified: 2026-10-26
tags: [history, clients, artefact]
---
The names are final; the layout, logo set and typography land in Phase 7 (the logo set is explicitly a Phase 7 decision).
The names are final. The treatment is deliberate: one typeface, one size, one grid, and no logo collage — see *Why there are no logos here* below.
## The agencies
Clemenger · Agency Republic · Brand Union · Jackpot Joy · Bryce Ford · RMG Connect · Sporting Index · Adventis Digital · Trinity Street · Inferno · SAS Design · Ogilvy · Wunderman · Illumina · Barkers · Mook · Pearsons · Penguin · Draft London · Behan Consulting · MCM Entertainment · Carsales
<div class="client-wall" role="list" aria-label="Agencies">
<div class="client-tile" role="listitem"><span class="client-name">Clemenger</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Agency Republic</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Brand Union</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Jackpot Joy</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Bryce Ford</span></div>
<div class="client-tile" role="listitem"><span class="client-name">RMG Connect</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Sporting Index</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Adventis Digital</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Trinity Street</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Inferno</span></div>
<div class="client-tile" role="listitem"><span class="client-name">SAS Design</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Ogilvy</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Wunderman</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Illumina</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Barkers</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Mook</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Pearsons</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Penguin</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Draft London</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Behan Consulting</span></div>
<div class="client-tile" role="listitem"><span class="client-name">MCM Entertainment</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Carsales</span></div>
</div>
## The brands
Carlton Draught · Diageo · KPMG · McDonald's · Accenture · BBC National Archive · Bank of America · War of the Worlds · Madonna (Hard Candy) · PlayStation · HSBC · DHL · Reuters · Volvic · AutoCAD · Channel 4 · Pearsons · Shell · Stella Artois · Baileys Irish Cream · Parkgate Homes · Carlton Dry · Cottees Cordial
<div class="client-wall" role="list" aria-label="Brands">
<div class="client-tile" role="listitem"><span class="client-name">Carlton Draught</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Diageo</span></div>
<div class="client-tile" role="listitem"><span class="client-name">KPMG</span></div>
<div class="client-tile" role="listitem"><span class="client-name">McDonald's</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Accenture</span></div>
<div class="client-tile" role="listitem"><span class="client-name">BBC National Archive</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Bank of America</span></div>
<div class="client-tile" role="listitem"><span class="client-name">War of the Worlds</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Madonna (Hard Candy)</span></div>
<div class="client-tile" role="listitem"><span class="client-name">PlayStation</span></div>
<div class="client-tile" role="listitem"><span class="client-name">HSBC</span></div>
<div class="client-tile" role="listitem"><span class="client-name">DHL</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Reuters</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Volvic</span></div>
<div class="client-tile" role="listitem"><span class="client-name">AutoCAD</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Channel 4</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Pearsons</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Shell</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Stella Artois</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Baileys Irish Cream</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Parkgate Homes</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Carlton Dry</span></div>
<div class="client-tile" role="listitem"><span class="client-name">Cottees Cordial</span></div>
</div>
## Why there are no logos here
A wall of mixed marks — some full colour, some monochrome, some missing — reads as broken, and the eight marks that exist for these names are not the eight the work is best remembered by. One typeface, one size and one grid says the same thing more clearly, and it sits beside the technical pages without competing with them. A mark is added only when it is the client's official mark and Sam has approved its use; none has been invented.
## Corrections carried into this list
@@ -36,4 +87,4 @@ The summary material said twenty-three agencies. The recovered, verified list ho
## When it is delivered
Phase 7 for the visual treatment. Names and one-line descriptions are approved as recovered and corrected.
Delivered as a body of names and descriptions, and presented as a typographic wall from Phase 7. Names and one-line descriptions are approved as recovered and corrected.

View File

@@ -14,7 +14,7 @@ last_verified: 2026-10-26
tags: [platform, networking, security, artefact]
---
The topology diagram lands in Phase 7 with addresses and hostnames redacted.
The diagram below shows three hosts and one trust boundary; internal addresses, hostnames and public DNS names are generalised, not published.
## What the artefact is
@@ -24,6 +24,12 @@ The layout: hosts on a private mesh for remote access, SSH on every machine, a p
Security posture chosen rather than inherited: no service is reachable because it happened to bind to all interfaces, and there is exactly one place where the outside world meets the estate.
## The layout
![Three hosts side by side. A build workstation hands a release to the runtime host, which runs the site, the vector store, a workflow worker and the model gateway inside a loopback trust boundary; the public edge terminates TLS and is the only host the internet can reach. All three join one private mesh, and backups run in three tiers.](../../assets/diagrams/homelab-topology.svg)
Read it left to right: work is built on one machine, runs on a memory-bound machine with every service bound to loopback, and is published by a single edge that terminates TLS. The dashed boundary is the point of the whole arrangement — crossing it is a deliberate change to the edge configuration, not a side effect of starting a container. Below both, the mesh carries remote access and the second backup tier, so neither depends on a port being open.
## When it is delivered
Phase 7. Internal addressing, hostnames and public DNS names are excluded from publication by the redaction pass.
Delivered. Internal addressing, hostnames and public DNS names are excluded from publication by the redaction pass.

90
internal/brand/brand.go Normal file
View File

@@ -0,0 +1,90 @@
// Package brand holds the small monochrome technology marks the site inlines
// beside a technology name.
//
// A mark is path data for a 24x24 viewBox, drawn with fill="currentColor" so it
// inherits the chip's colour and adds no request, no font and no layout shift.
// Marks are opt-in: a technology with no published mark renders as text alone,
// which is the honest default and keeps the chips from looking broken.
//
// The path data is vendored (see icons_gen.go and tools/gen_brand_icons.sh),
// not fetched at build or request time. The marks are trademarks of their
// owners and are used only to identify the technology named beside them.
package brand
import "strings"
// slugs maps a technology label, lowercased, to its Simple Icons slug. Labels
// are the ones the content uses; aliases point at the same mark.
var slugs = map[string]string{
"javascript": "javascript",
"php": "php",
"python": "python",
"home assistant": "homeassistant",
"raspberry pi": "raspberrypi",
"pi": "raspberrypi",
"nixos": "nixos",
"nix": "nixos",
"qdrant": "qdrant",
"prefect": "prefect",
"n8n": "n8n",
"mqtt": "mqtt",
"langgraph": "langgraph",
"langchain": "langchain",
"ffmpeg": "ffmpeg",
"zigbee": "zigbee",
"three.js": "threedotjs",
"react": "react",
"proxmox": "proxmox",
"mysql": "mysql",
"postgres": "postgresql",
"postgresql": "postgresql",
"caddy": "caddy",
"tailscale": "tailscale",
"neovim": "neovim",
"htmx": "htmx",
"go": "go",
"docker": "docker",
"compose": "docker",
"borg": "borgbackup",
"sqlite": "sqlite",
"spotify": "spotify",
"rabbitmq": "rabbitmq",
"openrouter": "openrouter",
"obsidian": "obsidian",
"niri": "niri",
"gitea": "gitea",
"ghostty": "ghostty",
"gemini": "googlegemini",
"deepseek": "deepseek",
"c++": "cplusplus",
"typescript": "typescript",
"zsh": "zsh",
"minimax": "minimax",
"esp32": "espressif",
"esp32-cam": "espressif",
"grafana": "grafana",
"wireguard": "wireguard",
"nginx": "nginx",
"linux": "linux",
"zig": "zig",
"huggingface": "huggingface",
"ollama": "ollama",
}
// Mark returns the path data for a technology label and whether one exists.
// The lookup is case-insensitive and tolerates the surrounding whitespace that
// hand-written front-matter tends to carry.
func Mark(label string) ([]string, bool) {
slug, ok := slugs[strings.ToLower(strings.TrimSpace(label))]
if !ok {
return nil, false
}
paths, ok := marks[slug]
return paths, ok
}
// Has reports whether a technology label has a published mark.
func Has(label string) bool {
_, ok := Mark(label)
return ok
}

160
internal/brand/icons_gen.go Normal file

File diff suppressed because one or more lines are too long

View File

@@ -37,22 +37,21 @@ type Area struct {
Title string
Role string
Blurb string
Gutter string // two-letter mark used by the nav and the stage panel
}
// Areas is the fixed navigation order from reference/content-map.md.
var Areas = []Area{
{Key: "ai", Title: "AI & Agentic Systems", Role: "AI / LLM Engineer", Gutter: "AI",
{Key: "ai", Title: "AI & Agentic Systems", Role: "AI / LLM Engineer",
Blurb: "Harnesses, subagents and skills, memory, model routing and cost, retrieval, voice, guardrails, agent observability."},
{Key: "platform", Title: "Platform & Infrastructure", Role: "Platform / DevOps Engineer", Gutter: "PL",
{Key: "platform", Title: "Platform & Infrastructure", Role: "Platform / DevOps Engineer",
Blurb: "Declarative hosts, packaging, a fifty-container Docker estate, networking, storage and restore-tested backups, observability."},
{Key: "edge", Title: "Edge & Devices", Role: "Edge / IoT Engineer", Gutter: "ED",
{Key: "edge", Title: "Edge & Devices", Role: "Edge / IoT Engineer",
Blurb: "Twenty-node fleet on constrained devices, custom radio, solar power, deep sleep, sensors and actuation — and the failures."},
{Key: "products", Title: "Products", Role: "", Gutter: "PR",
{Key: "products", Title: "Products", Role: "",
Blurb: "Three active businesses, built and run rather than advised on."},
{Key: "history", Title: "History", Role: "", Gutter: "HI",
{Key: "history", Title: "History", Role: "",
Blurb: "2000–2014: agencies, clients, engagements, and the code that survives from them."},
{Key: "about", Title: "About", Role: "", Gutter: "AB",
{Key: "about", Title: "About", Role: "",
Blurb: "The narrative, the non-IT work that came first, education, and how to get in touch."},
}

View File

@@ -4,9 +4,15 @@ import (
"bytes"
"fmt"
stdhtml "html"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2"
@@ -76,6 +82,7 @@ func (r *Renderer) Transform(doc *ast.Document, reader text.Reader, pc parser.Co
node.Destination = r.rewrite(current, string(node.Destination), false)
case *ast.Image:
node.Destination = r.rewrite(current, string(node.Destination), true)
r.sizeImage(current, node)
}
return ast.WalkContinue, nil
})
@@ -108,6 +115,70 @@ func (r *Renderer) rewrite(from *Document, dest string, image bool) []byte {
return []byte(dest)
}
// sizeImage records an image's intrinsic size and loading hints on the node, so
// the rendered page reserves the space before the file arrives. Without this the
// text jumps as each image lands, which is the most visible performance defect a
// page of diagrams can have.
//
// Only the four attributes in goldmark's image filter are used, and only when a
// real size is known: an unknown asset keeps the browser's own behaviour.
func (r *Renderer) sizeImage(from *Document, node *ast.Image) {
if r.set == nil || from == nil {
return
}
dest := string(node.Destination)
if strings.Contains(dest, "://") || strings.HasPrefix(dest, "data:") {
return
}
rel := dest
if strings.HasPrefix(rel, "/assets/") {
rel = "assets/" + strings.TrimPrefix(rel, "/assets/")
} else if i := strings.IndexAny(rel, "#?"); i >= 0 {
rel = rel[:i]
}
w, h, ok := imageSize(filepath.Join(r.set.Root, filepath.FromSlash(path.Clean(rel))))
if !ok {
return
}
node.SetAttributeString("width", []byte(strconv.Itoa(w)))
node.SetAttributeString("height", []byte(strconv.Itoa(h)))
node.SetAttributeString("loading", []byte("lazy"))
node.SetAttributeString("decoding", []byte("async"))
}
// svgSize reads width and height from the root element of an SVG. The diagrams
// in content/assets are authored in this repository with explicit dimensions,
// so a viewBox alone is not expected to occur.
var svgSizeRe = regexp.MustCompile(`<svg[^>]*\swidth="(\d+(?:\.\d+)?)(?:px)?"[^>]*\sheight="(\d+(?:\.\d+)?)(?:px)?"`)
// imageSize returns the intrinsic pixel size of a content asset.
func imageSize(abs string) (int, int, bool) {
f, err := os.Open(abs)
if err != nil {
return 0, 0, false
}
defer f.Close()
if strings.HasSuffix(strings.ToLower(abs), ".svg") {
head := make([]byte, 4096)
n, _ := f.Read(head)
m := svgSizeRe.FindSubmatch(head[:n])
if m == nil {
return 0, 0, false
}
w, err1 := strconv.ParseFloat(string(m[1]), 64)
h, err2 := strconv.ParseFloat(string(m[2]), 64)
if err1 != nil || err2 != nil || w <= 0 || h <= 0 {
return 0, 0, false
}
return int(w + 0.5), int(h + 0.5), true
}
cfg, _, err := image.DecodeConfig(f)
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return 0, 0, false
}
return cfg.Width, cfg.Height, true
}
// docKey carries the document being rendered through the parser context so
// the link rewriter knows what "relative" means.
var docKey = parser.NewContextKey()

View File

@@ -59,7 +59,7 @@ func (Shell) Nav(area string) []templates.NavItem {
items = append(items, templates.NavItem{
Title: a.Title,
Href: "/" + a.Key + "/",
Gutter: a.Gutter,
Area: a.Key,
Current: a.Key == area,
})
}

View File

@@ -263,3 +263,35 @@ func TestSearchIndexFollowsContent(t *testing.T) {
t.Errorf("token found at %s, want /edge/edge-fleet/", results[0].URL)
}
}
// TestImagesReserveTheirSpace is the layout-shift guard: every image a content
// page carries must declare its intrinsic size and load lazily, so a page of
// diagrams and screenshots never reflows as the files arrive.
func TestImagesReserveTheirSpace(t *testing.T) {
s := newSite(t)
seen := 0
for _, d := range s.Content.Docs {
html := d.HTML
for i := 0; ; i++ {
start := strings.Index(html, "<img ")
if start < 0 {
break
}
end := strings.Index(html[start:], ">")
if end < 0 {
break
}
tag := html[start : start+end+1]
html = html[start+end+1:]
seen++
for _, want := range []string{`width="`, `height="`, `loading="lazy"`, `alt="`} {
if !strings.Contains(tag, want) {
t.Errorf("%s: image tag missing %s: %s", d.Path, want, tag)
}
}
}
}
if seen == 0 {
t.Fatal("no images found in content; the guard is not exercising anything")
}
}

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0b0d10"/>
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#33404d" stroke-width="1.5"/>
<path d="M11.2 22.2c-1.9-.5-3-1.7-3-3.5h2.6c0 .9.5 1.4 1.5 1.6.9.2 1.6 0 1.8-.6.2-.6-.2-1-.9-1.2l-2-.4c-2-.4-3-1.4-3-3 0-2 1.6-3.3 4.1-3.4v-1.9h1.9v1.9c2 .2 3.1 1.4 3.2 3.2h-2.6c-.1-.8-.6-1.2-1.4-1.3-.8-.1-1.4.2-1.4.7-.1.5.3.8 1 .9l2 .4c2 .4 3 1.5 3 3.2 0 2.1-1.7 3.4-4.2 3.5v1.9h-1.9z" fill="#6ee7c6"/>
<path d="M19.6 10.3h4.9c1.9 0 3.1 1.1 3.1 2.8 0 1.1-.5 1.9-1.4 2.3 1.1.3 1.7 1.2 1.7 2.5v1.9h-2.6v-1.6c0-.9-.4-1.3-1.3-1.3h-1.8v-2.3h1.5c.7 0 1.1-.3 1.1-.9s-.4-.9-1.1-.9h-1.5z" fill="#b3bcc8"/>
</svg>

After

Width:  |  Height:  |  Size: 761 B

View File

@@ -151,13 +151,22 @@ samp {
/* ---- chrome ------------------------------------------------------------ */
/*
Two rows: the name and the search on the first, the six areas plus the index
on the second. The area names are long and the marks made them longer, so a
single row either overlapped the search form or scrolled the nav out of
reach. Two rows give every link a real target at every width.
*/
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr) auto;
gap: var(--pad);
grid-template-columns: var(--nav-w) minmax(0, 1fr);
grid-template-areas:
"brand search"
"nav nav";
gap: 0.5rem var(--pad);
align-items: center;
padding: 0.7rem var(--pad);
background: color-mix(in srgb, var(--bg) 92%, transparent);
@@ -165,6 +174,21 @@ samp {
border-bottom: 1px solid var(--line);
}
.brand {
grid-area: brand;
}
.nav-search {
grid-area: search;
justify-self: end;
width: min(28rem, 100%);
}
.nav-rail {
grid-area: nav;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
@@ -241,17 +265,25 @@ samp {
box-shadow: inset 0 -2px 0 var(--accent);
}
.nav-gutter {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
/* The area mark replaces the old two-letter badge. It is decoration; the nav
label beside it is the accessible name. */
.nav-mark {
display: inline-flex;
align-items: center;
color: var(--text-muted);
}
.nav-current .nav-gutter {
.nav-link:hover .nav-mark,
.nav-current .nav-mark {
color: var(--accent);
}
.area-icon {
display: block;
width: 20px;
height: 20px;
}
/* ---- shell ------------------------------------------------------------- */
.shell {
@@ -564,8 +596,9 @@ samp {
color: var(--text-muted);
}
.pillar-gutter {
font-family: var(--font-mono);
.pillar-mark {
display: inline-flex;
align-items: center;
color: var(--accent);
}
@@ -600,6 +633,11 @@ samp {
color: var(--text-muted);
}
.pillar-go {
font-weight: 600;
border-bottom: 0;
}
.pillar-count {
font-family: var(--font-mono);
font-size: 0.74rem;
@@ -644,8 +682,11 @@ samp {
list-style: none;
}
.tech li {
padding: 0.12rem 0.45rem;
.tech-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.14rem 0.45rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
@@ -653,6 +694,67 @@ samp {
color: var(--text-dim);
}
/* Monochrome and currentColor, so the chip and the mark share one colour and
no icon ever adds a request. */
.tech-mark {
display: block;
flex: none;
width: 16px;
height: 16px;
fill: currentColor;
opacity: 0.85;
}
.tech-chip:hover {
border-color: var(--line-strong);
color: var(--text);
}
.tech-chip:hover .tech-mark {
opacity: 1;
}
/* ---- client wall ------------------------------------------------------- */
/*
One typeface, one size, one tile. Mixed brand marks would read as broken;
a consistent grid reads as deliberate. Tiles are the same height whatever
the length of the name, so the wall never looks like a failed image load.
*/
.client-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 1px;
margin: 1rem 0 1.6rem;
padding: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: hidden;
list-style: none;
}
.client-tile {
display: flex;
align-items: center;
justify-content: center;
min-height: 4.2rem;
padding: 0.6rem 0.75rem;
background: var(--surface);
text-align: center;
}
.client-name {
font-size: 0.88rem;
line-height: 1.25;
letter-spacing: 0.01em;
color: var(--text-dim);
text-wrap: balance;
}
.client-tile:hover .client-name {
color: var(--text);
}
.doc-head {
padding-bottom: 1.1rem;
border-bottom: 1px solid var(--line);

View File

@@ -17,6 +17,7 @@ templ Layout(v LayoutView) {
<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>
@@ -68,7 +69,8 @@ templ Nav(items []NavItem, current string) {
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-mark">@AreaIcon(it.Area)
</span>
<span class="nav-title">{ it.Title }</span>
</a>
}
@@ -76,7 +78,8 @@ templ navLinks(items []NavItem, current string) {
templ evidenceLink(current string) {
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
<span class="nav-gutter" aria-hidden="true">EV</span>
<span class="nav-mark">@AreaIcon("evidence")
</span>
<span class="nav-title">Evidence index</span>
</a>
}
@@ -178,7 +181,8 @@ templ FragmentView(f stage.Fragment) {
</header>
switch f.Kind {
case stage.KindText:
<div class="frag-text">@f.Body</div>
<div class="frag-text">@f.Body
</div>
case stage.KindCode:
if f.Code != nil {
<figure class="code">
@@ -263,7 +267,8 @@ templ EvidenceTable(rows []EvidenceRow) {
templ PillarCard(c PillarCardView) {
<article class="pillar">
<p class="pillar-kicker">
<span class="pillar-gutter" aria-hidden="true">{ c.Area.Gutter }</span>
<span class="pillar-mark">@AreaIcon(c.Area.Key)
</span>
if c.Area.Role != "" {
<span>{ c.Area.Role }</span>
}
@@ -283,11 +288,7 @@ templ PillarCard(c PillarCardView) {
</ul>
}
if len(c.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range c.Tech {
<li>{ t }</li>
}
</ul>
@TechChips(c.Tech)
}
<p class="pillar-foot">
<span class="pillar-count">{ strconv.Itoa(c.Pages) } pages</span>

View File

@@ -0,0 +1,67 @@
package templates
import "gitea.lab.audasmedia.com.au/sam/ai-resume/internal/brand"
// AreaIcon is the mark for one area of the site. The six marks are drawn here,
// geometrically, in one visual language: 24x24, 1.6 px stroke, round caps, no
// fill, colour inherited from the text. They are decoration only — the label
// beside them carries the meaning — so every one is aria-hidden.
//
// "evidence" is the seventh mark: the index, not an area.
templ AreaIcon(area string) {
<svg class="area-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
switch area {
case "ai":
<circle cx="5.5" cy="6.5" r="2.5"/>
<circle cx="18.5" cy="6.5" r="2.5"/>
<circle cx="12" cy="17.5" r="2.5"/>
<path d="M8 6.5h8"/>
<path d="M6.7 8.8 10.8 15.6"/>
<path d="M17.3 8.8 13.2 15.6"/>
case "platform":
<rect x="3.5" y="3.5" width="17" height="4.6" rx="1.5"/>
<rect x="3.5" y="9.7" width="17" height="4.6" rx="1.5"/>
<rect x="3.5" y="15.9" width="17" height="4.6" rx="1.5"/>
<path d="M7 5.8h2M7 12h2M7 18.2h2"/>
case "edge":
<rect x="2.5" y="13" width="9" height="8" rx="1.5"/>
<circle cx="7" cy="17" r="1.6"/>
<path d="M15 11.5a3.5 3.5 0 0 1-3.5 3.5"/>
<path d="M18 11.5a6.5 6.5 0 0 1-6.5 6.5"/>
case "products":
<path d="M12 3.2 19 7.3v9.4L12 20.8 5 16.7V7.3z"/>
<path d="M5 7.3l7 4.1 7-4.1"/>
<path d="M12 11.4v9.4"/>
case "history":
<circle cx="12" cy="12" r="8.5"/>
<path d="M12 7v5.2l3.4 2.1"/>
case "about":
<circle cx="12" cy="8.4" r="3.6"/>
<path d="M5.4 20a6.6 6.6 0 0 1 13.2 0"/>
case "evidence":
<path d="M6.8 3.4h7.2l4 4v13.2H6.8z"/>
<path d="M14 3.4v4h4"/>
<path d="M9.6 14.2l2 2 3.6-4.1"/>
}
</svg>
}
// TechChips is the technology list on a page. A chip carries a published mark
// when one exists and plain text when it does not, so a missing icon reads as a
// design decision rather than a broken image.
templ TechChips(items []string) {
<ul class="tech" aria-label="Technology">
for _, t := range items {
<li class="tech-chip">
if paths, ok := brand.Mark(t); ok {
<svg class="tech-mark" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
for _, d := range paths {
<path d={ d } fill="currentColor"/>
}
</svg>
}
<span>{ t }</span>
</li>
}
</ul>
}

View File

@@ -17,7 +17,6 @@ templ HomePage(v HomeView) {
</p>
@SearchForm("", "hero-search")
</section>
<section class="pillars" aria-label="Six areas">
for _, c := range v.Cards {
@PillarCard(c)
@@ -46,14 +45,9 @@ templ DocPage(v DocView) {
}
</div>
if len(v.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range v.Tech {
<li>{ t }</li>
}
</ul>
@TechChips(v.Tech)
}
</header>
<div class="prose">
@v.Body
</div>
@@ -97,7 +91,6 @@ templ DocPage(v DocView) {
</ul>
</section>
}
<footer class="doc-foot">
<p>Last verified { v.Doc.LastVerified } · <code>content/{ v.Doc.Path }</code></p>
</footer>
@@ -207,15 +200,21 @@ templ StreamPage(v StreamView) {
<p class="doc-claim">This page renders the same layout and the same panel as every other page. Its panel is bound to an event stream instead of pre-rendered files, and no layout code changed.</p>
</header>
<div class="prose">
<p>The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
<p>
The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
HTMX: you click a row in an evidence table and one file is swapped in. On this page the same
panel is bound to <code>/api/stage/stream</code> and receives { strconv.Itoa(v.Frames) } fragments
over server-sent events, one after another.</p>
<p>Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
over server-sent events, one after another.
</p>
<p>
Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
view, its empty state — knows the difference. That is the point: the Phase 5 console
re-points this panel at retrieval output and the layout does not change.</p>
<p class="section-note">This demonstration streams the artefacts already built for the site, with
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.</p>
re-points this panel at retrieval output and the layout does not change.
</p>
<p class="section-note">
This demonstration streams the artefacts already built for the site, with
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.
</p>
</div>
if len(v.Fragment) > 0 {
<section class="doc-sub">

View File

@@ -23,7 +23,7 @@ import (
type NavItem struct {
Title string
Href string
Gutter string
Area string
Current bool
}

71
tools/gen_brand_icons.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Regenerate internal/brand/icons_gen.go from Simple Icons.
#
# Simple Icons (https://simpleicons.org) is CC0 1.0: the icon paths are public
# domain. The marks themselves remain the trademarks of their owners and are
# used here only to identify the technology being described.
#
# This script runs at development time on a machine with network access. The
# generated Go file IS committed, so `make build` never needs the network, never
# needs Node and never needs node_modules. Re-run it only when the icon set
# changes:
#
# tools/gen_brand_icons.sh
#
# Every icon is normalised to a 24x24 viewBox and inlined as path data, so the
# page carries no image request and the mark takes the chip's text colour.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
out="$root/internal/brand/icons_gen.go"
cdn="${SIMPLE_ICONS_CDN:-https://cdn.simpleicons.org}"
# slug:name as published by Simple Icons. Keep this list to marks the content
# actually uses; an unused icon is dead weight in the binary.
slugs=(
javascript php python homeassistant raspberrypi nixos
qdrant prefect n8n mqtt langgraph langchain
ffmpeg zigbee threedotjs react proxmox mysql postgresql
caddy tailscale neovim htmx go docker borgbackup
sqlite spotify rabbitmq openrouter obsidian niri gitea ghostty
googlegemini deepseek cplusplus typescript zsh minimax espressif
grafana wireguard nginx hashicorp vault linux zig huggingface ollama
)
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
{
echo "// Code generated by tools/gen_brand_icons.sh; DO NOT EDIT."
echo "//"
echo "// Path data from Simple Icons (CC0 1.0), retrieved from $cdn."
echo "// Every mark is a 24x24 viewBox drawn in currentColor."
echo
echo "package brand"
echo
echo "// marks maps a Simple Icons slug to its path data."
echo "var marks = map[string][]string{"
} > "$out"
for slug in "${slugs[@]}"; do
if ! curl -fsS -m 20 -o "$tmp/$slug.svg" "$cdn/$slug"; then
echo "gen_brand_icons: could not fetch $slug" >&2
exit 1
fi
# One or more <path d="..."> elements; monochrome, so the path data is all we keep.
mapfile -t paths < <(grep -o 'd="[^"]*"' "$tmp/$slug.svg" | sed 's/^d="//; s/"$//')
if [ "${#paths[@]}" -eq 0 ]; then
echo "gen_brand_icons: $slug has no path data" >&2
exit 1
fi
printf '\t"%s": {\n' "$slug" >> "$out"
for p in "${paths[@]}"; do
printf '\t\t`%s`,\n' "$p" >> "$out"
done
printf '\t},\n' >> "$out"
done
echo "}" >> "$out"
gofmt -w "$out" 2>/dev/null || true
echo "gen_brand_icons: wrote $out (${#slugs[@]} marks)"